Compare commits
	
		
			69 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 70c29c778c | |||
| 0fc302699e | |||
| 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 | 
							
								
								
									
										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" | ||||
							
								
								
									
										174
									
								
								CLAUDE.md
									
									
									
									
									
								
							
							
						
						
									
										174
									
								
								CLAUDE.md
									
									
									
									
									
								
							| @@ -1,174 +0,0 @@ | ||||
| # CLAUDE.md | ||||
|  | ||||
| This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | ||||
|  | ||||
| ## Project Overview | ||||
|  | ||||
| @design.estate/dees-catalog is a comprehensive web components library built with TypeScript and LitElement. It provides a large collection of UI components for building modern web applications with consistent design and behavior. | ||||
|  | ||||
| ## Build and Development Commands | ||||
|  | ||||
| ```bash | ||||
| # Install dependencies | ||||
| pnpm install | ||||
|  | ||||
| # Build the project | ||||
| pnpm run build | ||||
| # This runs: tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild | ||||
|  | ||||
| # Run development watch mode | ||||
| pnpm run watch | ||||
| # This runs: tswatch element | ||||
|  | ||||
| # Run tests (browser tests) | ||||
| pnpm test | ||||
| # This runs: tstest test/ --web --verbose --timeout 30 --logfile | ||||
|  | ||||
| # Run a specific test file | ||||
| tsx test/test.wysiwyg-basic.browser.ts --verbose | ||||
|  | ||||
| # Build documentation | ||||
| pnpm run buildDocs | ||||
| ``` | ||||
|  | ||||
| ### Testing Notes | ||||
| - Test files follow the pattern: `test.*.browser.ts`, `test.*.node.ts`, or `test.*.both.ts` | ||||
| - Browser tests run in a headless browser environment | ||||
| - Use `--logfile` option to store logs in `.nogit/testlogs/` | ||||
| - For debugging, create files in `.nogit/debug/` and run with `tsx` | ||||
|  | ||||
| ## Architecture Overview | ||||
|  | ||||
| ### Component Structure | ||||
| The library is organized into several categories: | ||||
|  | ||||
| 1. **Core UI Components** (`dees-button`, `dees-badge`, `dees-icon`, etc.) | ||||
|    - Basic building blocks with consistent theming | ||||
|    - All support light/dark themes via `cssManager.bdTheme()` | ||||
|  | ||||
| 2. **Form Components** (`dees-form`, `dees-input-*`) | ||||
|    - Complete form system with validation | ||||
|    - Base class `DeesInputBase` provides common functionality | ||||
|    - Form data collection via `DeesForm` container | ||||
|  | ||||
| 3. **Layout Components** (`dees-appui-*`) | ||||
|    - Application shell components | ||||
|    - `DeesAppuiBase` orchestrates the entire layout | ||||
|    - Grid-based responsive design | ||||
|  | ||||
| 4. **Data Display** (`dees-table`, `dees-dataview-*`, `dees-statsgrid`) | ||||
|    - Complex data visualization components | ||||
|    - Interactive tables with sorting/filtering | ||||
|    - Chart components using ApexCharts | ||||
|  | ||||
| 5. **Overlays** (`dees-modal`, `dees-contextmenu`, `dees-toast`) | ||||
|    - Managed by central z-index registry | ||||
|    - Window layer system for proper stacking | ||||
|  | ||||
| ### Key Architectural Patterns | ||||
|  | ||||
| #### Z-Index Management | ||||
| All overlay components use a centralized z-index registry system: | ||||
| - Definition in `ts_web/elements/00zindex.ts` | ||||
| - Dynamic z-index assignment via `ZIndexRegistry` class | ||||
| - Components get z-index from registry when showing | ||||
| - Ensures proper stacking order (dropdowns above modals, etc.) | ||||
|  | ||||
| #### Theme System | ||||
| - All components support light/dark themes | ||||
| - Use `cssManager.bdTheme(lightValue, darkValue)` for theme-aware colors | ||||
| - Consistent color palette defined in `00colors.ts` | ||||
|  | ||||
| #### Component Demo System | ||||
| - Each component has a static `demo` property | ||||
| - Demo functions in separate `.demo.ts` files | ||||
| - Showcase pages aggregate demos (e.g., `input-showcase.ts`) | ||||
|  | ||||
| #### WYSIWYG Editor Architecture | ||||
| The WYSIWYG editor uses a sophisticated architecture with separated concerns: | ||||
| - **Main Component**: `dees-input-wysiwyg.ts` - Orchestrates the editor | ||||
| - **Handler Classes**: | ||||
|   - `WysiwygInputHandler` - Handles text input and block transformations | ||||
|   - `WysiwygKeyboardHandler` - Manages keyboard shortcuts and navigation | ||||
|   - `WysiwygDragDropHandler` - Manages block reordering | ||||
|   - `WysiwygModalManager` - Shows configuration modals | ||||
|   - `WysiwygBlockOperations` - Core block manipulation logic | ||||
| - **Global Menus**:  | ||||
|   - `DeesSlashMenu` and `DeesFormattingMenu` render globally to avoid focus issues | ||||
|   - Singleton pattern ensures single instance | ||||
| - **Programmatic Rendering**: Uses manual DOM manipulation to prevent focus loss | ||||
|  | ||||
| ### Component Communication | ||||
| - Custom events for parent-child communication | ||||
| - Form components emit standardized events (`change`, `blur`, etc.) | ||||
| - Complex components like `DeesAppuiBase` re-emit child events | ||||
|  | ||||
| ### Build System | ||||
| - TypeScript compilation with decorators support | ||||
| - Web component bundling with esbuild | ||||
| - Element exports in `ts_web/elements/index.ts` | ||||
| - Distribution builds in `dist_ts_web/` | ||||
|  | ||||
| ## Important Implementation Details | ||||
|  | ||||
| ### When Creating New Components | ||||
| 1. Extend `DeesElement` from `@design.estate/dees-element` | ||||
| 2. Use `@customElement('dees-componentname')` decorator | ||||
| 3. Implement theme support with `cssManager.bdTheme()` | ||||
| 4. Create a demo function in a separate `.demo.ts` file | ||||
| 5. Export from `elements/index.ts` | ||||
|  | ||||
| ### Form Input Components | ||||
| 1. Extend `DeesInputBase` for form inputs | ||||
| 2. Implement `getValue()` and `setValue()` methods | ||||
| 3. Use `changeSubject.next(this)` to emit changes | ||||
| 4. Support `disabled` and `required` properties | ||||
|  | ||||
| ### Overlay Components | ||||
| 1. Import z-index from `00zindex.ts` | ||||
| 2. Get z-index from registry when showing: `zIndexRegistry.getNextZIndex()` | ||||
| 3. Register/unregister with the registry | ||||
| 4. Use `DeesWindowLayer` for backdrop if needed | ||||
|  | ||||
| ### Testing Components | ||||
| 1. Create test files in `test/` directory | ||||
| 2. Use `@git.zone/tstest` with tap-bundle | ||||
| 3. Test in browser environment for web components | ||||
| 4. Use proper async/await for component lifecycle | ||||
|  | ||||
| ## Common Patterns and Pitfalls | ||||
|  | ||||
| ### Focus Management | ||||
| - WYSIWYG editor uses programmatic rendering to prevent focus loss | ||||
| - Use `requestAnimationFrame` for timing-sensitive focus operations | ||||
| - Avoid reactive re-renders during user input | ||||
|  | ||||
| ### Event Handling | ||||
| - Prevent event bubbling in nested interactive components | ||||
| - Use `pointer-events: none/auto` for click-through behavior | ||||
| - Handle both mouse and keyboard events for accessibility | ||||
|  | ||||
| ### Performance Considerations | ||||
| - Large components (editor, terminal) use lazy loading | ||||
| - Charts use debounced resize observers | ||||
| - Tables implement virtual scrolling for large datasets | ||||
|  | ||||
| ## File Organization | ||||
| ``` | ||||
| ts_web/ | ||||
| ├── elements/           # All component files | ||||
| │   ├── 00*.ts         # Shared utilities (colors, z-index, plugins) | ||||
| │   ├── dees-*.ts      # Component implementations | ||||
| │   ├── dees-*.demo.ts # Component demos | ||||
| │   ├── interfaces/    # Shared TypeScript interfaces | ||||
| │   ├── helperclasses/ # Utility classes (FormController) | ||||
| │   └── wysiwyg/       # WYSIWYG editor subsystem | ||||
| ├── pages/             # Demo showcase pages | ||||
| └── index.ts           # Main export file | ||||
| ``` | ||||
|  | ||||
| ## Recent Major Changes | ||||
| - Z-Index Registry System (2025-12-24): Dynamic stacking order management | ||||
| - WYSIWYG Refactoring (2025-06-24): Complete architecture overhaul with separated concerns | ||||
| - Form System Enhancement: Unified validation and data collection | ||||
| - Theme System: Consistent light/dark theme support across all components | ||||
							
								
								
									
										115
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										115
									
								
								changelog.md
									
									
									
									
									
								
							| @@ -1,5 +1,118 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-10-23 - 1.12.6 - fix(dependencies) | ||||
| Bump FontAwesome to ^7.1.0 and add local claude settings | ||||
|  | ||||
| - Updated @fortawesome packages (@fortawesome/fontawesome-svg-core, @fortawesome/free-brands-svg-icons, @fortawesome/free-regular-svg-icons, @fortawesome/free-solid-svg-icons) to ^7.1.0 in package.json | ||||
| - Added .claude/settings.local.json to configure local Claude/tooling permissions for repository operations | ||||
|  | ||||
| ## 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 | ||||
|  | ||||
| @@ -161,7 +274,7 @@ Add dees-searchbar component with live search and filter demo | ||||
| ## 2025-04-22 - 1.6.0 - feat(documentation/dees-heading) | ||||
| Add codex documentation overview and dees-heading component demo | ||||
|  | ||||
| - Introduce 'codex.md' to provide a high-level overview of project layout, component patterns, and build workflow | ||||
| - Introduce contributor overview doc (`codex.md`, now consolidated into `readme.info.md`) to provide a high-level overview of project layout, component patterns, and build workflow | ||||
| - Add and update dees-heading component with demo to support multiple heading levels and horizontal rule styles | ||||
| - Update component export index to include dees-heading | ||||
|  | ||||
|   | ||||
							
								
								
									
										43
									
								
								codex.md
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								codex.md
									
									
									
									
									
								
							| @@ -1,43 +0,0 @@ | ||||
| # Codex: Project Overview and Codebase Structure | ||||
|  | ||||
| ## Project Overview | ||||
| - Package: `@design.estate/dees-catalog` | ||||
| - Focus: Web Components library providing UI elements and layouts for modern web apps. | ||||
|  | ||||
| ## Directory Layout | ||||
| - ts_web/: TypeScript source files | ||||
|   - elements/: Individual Web Component definitions | ||||
|   - pages/: Page-level templates for composite layouts | ||||
| - html/: Demo/app entry point loading the bundled scripts | ||||
| - dist_bundle/: Bundled browser JS and source maps | ||||
| - dist_ts_web/: ES module outputs for TypeScript/web consumers | ||||
| - dist_watch/: Watch-mode development bundle with live reload | ||||
| - test/: Browser-based tests using `@push.rocks/tapbundle` | ||||
|  | ||||
| ## Component Patterns | ||||
| - Each component in ts_web/elements/: | ||||
|   - Decorated with `@customElement('tag-name')` | ||||
|   - Extends `DeesElement` from `@design.estate/dees-element` | ||||
|   - Uses `@property` for reactive, reflected attributes | ||||
|   - Defines `static styles = [cssManager.defaultStyles, css`...`]` | ||||
|   - Implements `render()` returning a Lit `html` template with slots or markup | ||||
|   - Exposes a demo via `public static demo` linking to `.demo.ts` files | ||||
|  | ||||
| ## Build & Development Workflow | ||||
| - Install dependencies: `npm install` or `pnpm install` | ||||
| - Build production bundle: `npm run build` | ||||
| - Start dev watch mode: `npm run watch` | ||||
| - Run tests: `npm test` (launches browser fixtures) | ||||
|  | ||||
| ## Theming & Utilities | ||||
| - Default global styles via `cssManager.defaultStyles` | ||||
| - Theme-aware values with `cssManager.bdTheme(light, dark)` | ||||
| - DOM utilities set up in `html/index.ts` using `@design.estate/dees-domtools` | ||||
|  | ||||
| ## Documentation | ||||
| - `readme.md` provides an overview of all components and basic usage | ||||
| - Live examples in `.demo.ts` files | ||||
|   accessible via component `demo` static property | ||||
|  | ||||
| ## Updates to this file | ||||
| If you have pattern insisights or general changes to the codebase, please update this file. | ||||
							
								
								
									
										26
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "@design.estate/dees-catalog", | ||||
|   "version": "1.10.12", | ||||
|   "version": "1.12.6", | ||||
|   "private": false, | ||||
|   "description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.", | ||||
|   "main": "dist_ts_web/index.js", | ||||
| @@ -10,21 +10,22 @@ | ||||
|     "test": "tstest test/ --web --verbose --timeout 30 --logfile", | ||||
|     "build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild", | ||||
|     "watch": "tswatch element", | ||||
|     "buildDocs": "tsdoc" | ||||
|     "buildDocs": "tsdoc", | ||||
|     "postinstall": "node scripts/update-monaco-version.cjs" | ||||
|   }, | ||||
|   "author": "Lossless GmbH", | ||||
|   "license": "MIT", | ||||
|   "dependencies": { | ||||
|     "@design.estate/dees-domtools": "^2.3.3", | ||||
|     "@design.estate/dees-element": "^2.1.2", | ||||
|     "@design.estate/dees-wcctools": "^1.1.1", | ||||
|     "@fortawesome/fontawesome-svg-core": "^7.0.1", | ||||
|     "@fortawesome/free-brands-svg-icons": "^7.0.1", | ||||
|     "@fortawesome/free-regular-svg-icons": "^7.0.1", | ||||
|     "@fortawesome/free-solid-svg-icons": "^7.0.1", | ||||
|     "@design.estate/dees-wcctools": "^1.2.0", | ||||
|     "@fortawesome/fontawesome-svg-core": "^7.1.0", | ||||
|     "@fortawesome/free-brands-svg-icons": "^7.1.0", | ||||
|     "@fortawesome/free-regular-svg-icons": "^7.1.0", | ||||
|     "@fortawesome/free-solid-svg-icons": "^7.1.0", | ||||
|     "@push.rocks/smarti18n": "^1.0.4", | ||||
|     "@push.rocks/smartpromise": "^4.2.0", | ||||
|     "@push.rocks/smartstring": "^4.0.15", | ||||
|     "@push.rocks/smartstring": "^4.1.0", | ||||
|     "@tiptap/core": "^2.23.0", | ||||
|     "@tiptap/extension-link": "^2.23.0", | ||||
|     "@tiptap/extension-text-align": "^2.23.0", | ||||
| @@ -33,11 +34,12 @@ | ||||
|     "@tiptap/starter-kit": "^2.23.0", | ||||
|     "@tsclass/tsclass": "^9.2.0", | ||||
|     "@webcontainer/api": "1.2.0", | ||||
|     "apexcharts": "^5.3.4", | ||||
|     "apexcharts": "^5.3.5", | ||||
|     "highlight.js": "11.11.1", | ||||
|     "ibantools": "^4.5.1", | ||||
|     "lucide": "^0.542.0", | ||||
|     "monaco-editor": "^0.52.2", | ||||
|     "lit": "^3.3.1", | ||||
|     "lucide": "^0.544.0", | ||||
|     "monaco-editor": "0.52.2", | ||||
|     "pdfjs-dist": "^4.10.38", | ||||
|     "xterm": "^5.3.0", | ||||
|     "xterm-addon-fit": "^0.8.0" | ||||
| @@ -45,7 +47,7 @@ | ||||
|   "devDependencies": { | ||||
|     "@git.zone/tsbuild": "^2.6.8", | ||||
|     "@git.zone/tsbundle": "^2.5.1", | ||||
|     "@git.zone/tstest": "^2.3.6", | ||||
|     "@git.zone/tstest": "^2.3.8", | ||||
|     "@git.zone/tswatch": "^2.2.1", | ||||
|     "@push.rocks/projectinfo": "^5.0.2", | ||||
|     "@push.rocks/tapbundle": "^6.0.3", | ||||
|   | ||||
							
								
								
									
										671
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										671
									
								
								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 | ||||
							
								
								
									
										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. | ||||
							
								
								
									
										829
									
								
								readme.md
									
									
									
									
									
								
							
							
						
						
									
										829
									
								
								readme.md
									
									
									
									
									
								
							| @@ -92,15 +92,6 @@ Display icons from FontAwesome and Lucide icon libraries with library prefixes. | ||||
|   strokeWidth="2"       // Optional: stroke width for Lucide icons | ||||
| ></dees-icon> | ||||
|  | ||||
| // Available FontAwesome icons include: | ||||
| // fa:check, fa:bell, fa:gear, fa:trash, fa:copy, fa:paste, fa:eye, fa:eyeSlash, | ||||
| // fa:plus, fa:minus, fa:circleInfo, fa:circleCheck, fa:circleXmark, fa:message, | ||||
| // fa:arrowRight, fa:facebook, fa:twitter, fa:linkedin, fa:instagram, etc. | ||||
|  | ||||
| // Available Lucide icons include: | ||||
| // lucide:menu, lucide:settings, lucide:home, lucide:file, lucide:folder, | ||||
| // lucide:search, lucide:user, lucide:heart, lucide:star, lucide:download, etc. | ||||
|  | ||||
| // Legacy API (deprecated but still supported) | ||||
| <dees-icon | ||||
|   iconFA="check"        // Without prefix - assumes FontAwesome | ||||
| @@ -599,10 +590,10 @@ Base container component for application layout structure with integrated appbar | ||||
|   .appbarMenuItems=${[ | ||||
|     { | ||||
|       name: 'File', | ||||
|       action: async () => {}, | ||||
|       action: async () => {},  // No-op for parent menu items | ||||
|       submenu: [ | ||||
|         { name: 'New', shortcut: 'Cmd+N', iconName: 'file-plus', action: async () => {} }, | ||||
|         { name: 'Open', shortcut: 'Cmd+O', iconName: 'folder-open', action: async () => {} }, | ||||
|         { name: 'New File', shortcut: 'Cmd+N', iconName: 'file-plus', action: async () => {} }, | ||||
|         { name: 'Open...', shortcut: 'Cmd+O', iconName: 'folder-open', action: async () => {} }, | ||||
|         { divider: true }, | ||||
|         { name: 'Save', shortcut: 'Cmd+S', iconName: 'save', action: async () => {} } | ||||
|       ] | ||||
| @@ -618,10 +609,7 @@ Base container component for application layout structure with integrated appbar | ||||
|   ]} | ||||
|   .appbarBreadcrumbs=${'Dashboard > Overview'} | ||||
|   .appbarTheme=${'dark'} | ||||
|   .appbarUser=${{ | ||||
|     name: 'John Doe', | ||||
|     status: 'online' | ||||
|   }} | ||||
|   .appbarUser=${{ name: 'John Doe', status: 'online' }} | ||||
|   .appbarShowSearch=${true} | ||||
|   .appbarShowWindowControls=${true} | ||||
|    | ||||
| @@ -667,43 +655,6 @@ Key Features: | ||||
| - **Responsive Grid**: Uses CSS Grid for flexible, responsive layout | ||||
| - **Slot Support**: Main content area supports custom content via slots | ||||
|  | ||||
| Layout Structure: | ||||
| ``` | ||||
| ┌─────────────────────────────────────────────────┐ | ||||
| │                    AppBar                       │ | ||||
| ├────┬──────────────┬─────────────────┬──────────┤ | ||||
| │    │              │                 │          │ | ||||
| │ M  │   Selector   │   Main Content  │ Activity │ | ||||
| │ e  │              │                 │   Log    │ | ||||
| │ n  │              │                 │          │ | ||||
| │ u  │              │                 │          │ | ||||
| │    │              │                 │          │ | ||||
| └────┴──────────────┴─────────────────┴──────────┘ | ||||
| ``` | ||||
|  | ||||
| Grid Configuration: | ||||
| - Main Menu: 60px width | ||||
| - Selector: 240px width | ||||
| - Main Content: Flexible (1fr) | ||||
| - Activity Log: 240px width | ||||
|  | ||||
| Child Component Access: | ||||
| ```typescript | ||||
| // Access child components after firstUpdated | ||||
| const base = document.querySelector('dees-appui-base'); | ||||
| base.appbar;       // DeesAppuiAppbar instance | ||||
| base.mainmenu;     // DeesAppuiMainmenu instance | ||||
| base.mainselector; // DeesAppuiMainselector instance | ||||
| base.maincontent;  // DeesAppuiMaincontent instance | ||||
| base.activitylog;  // DeesAppuiActivitylog instance | ||||
| ``` | ||||
|  | ||||
| Best Practices: | ||||
| 1. **Configuration**: Set all properties on the base component for consistency | ||||
| 2. **Event Handling**: Listen to events on the base component rather than child components | ||||
| 3. **Content**: Use the `maincontent` slot for your application's primary interface | ||||
| 4. **State Management**: Manage selected tabs and options at the base component level | ||||
|  | ||||
| #### `DeesAppuiMainmenu` | ||||
| Main navigation menu component for application-wide navigation. | ||||
|  | ||||
| @@ -860,257 +811,6 @@ Key Features: | ||||
|   - Focus management | ||||
|   - Screen reader compatible | ||||
|  | ||||
| Menu Item Interface: | ||||
| ```typescript | ||||
| // Regular menu item | ||||
| interface IAppBarMenuItemRegular { | ||||
|   name: string;          // Display text | ||||
|   action: () => Promise<any>;  // Click handler | ||||
|   iconName?: string;     // Optional icon (for dropdown items) | ||||
|   shortcut?: string;     // Keyboard shortcut display | ||||
|   submenu?: IAppBarMenuItem[];  // Nested menu items | ||||
|   disabled?: boolean;    // Disabled state | ||||
|   checked?: boolean;     // For checkbox menu items | ||||
|   radioGroup?: string;   // For radio button menu items | ||||
| } | ||||
|  | ||||
| // Divider item | ||||
| interface IAppBarMenuDivider { | ||||
|   divider: true; | ||||
| } | ||||
|  | ||||
| // Combined type | ||||
| type IAppBarMenuItem = IAppBarMenuItemRegular | IAppBarMenuDivider; | ||||
| ``` | ||||
|  | ||||
| Best Practices: | ||||
| 1. **Menu Structure** | ||||
|    - Keep top-level menus text-only (no icons) | ||||
|    - Use icons in dropdown items for visual clarity | ||||
|    - Group related actions with dividers | ||||
|    - Provide keyboard shortcuts for common actions | ||||
|     | ||||
| 2. **Navigation** | ||||
|    - Use breadcrumbs for deep navigation hierarchies | ||||
|    - Keep breadcrumb labels concise | ||||
|    - Provide meaningful navigation events | ||||
|     | ||||
| 3. **User Experience** | ||||
|    - Show user status when relevant | ||||
|    - Provide clear visual feedback | ||||
|    - Ensure smooth transitions | ||||
|    - Handle edge cases (long menus, small screens) | ||||
|     | ||||
| 4. **Accessibility** | ||||
|    - Always provide text labels | ||||
|    - Ensure keyboard navigation works | ||||
|    - Test with screen readers | ||||
|    - Maintain focus management | ||||
|  | ||||
| #### `DeesAppuiActivitylog` | ||||
| Activity log component for displaying system events and user actions. | ||||
|  | ||||
| ```typescript | ||||
| <dees-appui-activitylog | ||||
|   .entries=${[ | ||||
|     { | ||||
|       timestamp: new Date(), | ||||
|       type: 'info', | ||||
|       message: 'User logged in', | ||||
|       details: { userId: '123' } | ||||
|     }, | ||||
|     { | ||||
|       timestamp: new Date(), | ||||
|       type: 'error', | ||||
|       message: 'Failed to save document', | ||||
|       details: { error: 'Network error' } | ||||
|     } | ||||
|   ]} | ||||
|   maxEntries={100}    // Maximum entries to display | ||||
|   @entry-click=${handleEntryClick} | ||||
| ></dees-appui-activitylog> | ||||
| ``` | ||||
|  | ||||
| #### `DeesAppuiProfiledropdown` | ||||
| User profile dropdown component with status and menu options. | ||||
|  | ||||
| ```typescript | ||||
| <dees-appui-profiledropdown | ||||
|   .user=${{ | ||||
|     name: 'John Doe', | ||||
|     email: 'john@example.com', | ||||
|     avatar: '/path/to/avatar.jpg', | ||||
|     status: 'online'  // Options: online, offline, busy, away | ||||
|   }} | ||||
|   .menuItems=${[ | ||||
|     { name: 'Profile', iconName: 'user', action: async () => {} }, | ||||
|     { name: 'Settings', iconName: 'settings', action: async () => {} }, | ||||
|     { divider: true }, | ||||
|     { name: 'Logout', iconName: 'logOut', action: async () => {} } | ||||
|   ]} | ||||
|   @status-change=${handleStatusChange} | ||||
| ></dees-appui-profiledropdown> | ||||
| ``` | ||||
|  | ||||
| #### `DeesAppuiTabs` | ||||
| Tab navigation component for organizing content sections. | ||||
|  | ||||
| ```typescript | ||||
| <dees-appui-tabs | ||||
|   .tabs=${[ | ||||
|     { | ||||
|       key: 'overview', | ||||
|       label: 'Overview', | ||||
|       icon: 'home', | ||||
|       content: html`<div>Overview content</div>` | ||||
|     }, | ||||
|     { | ||||
|       key: 'details', | ||||
|       label: 'Details', | ||||
|       icon: 'info', | ||||
|       content: html`<div>Details content</div>` | ||||
|     } | ||||
|   ]} | ||||
|   selectedTab="overview" | ||||
|   @tab-change=${handleTabChange} | ||||
| ></dees-appui-tabs> | ||||
| ``` | ||||
|  | ||||
| #### `DeesAppuiView` | ||||
| View container component for consistent page layouts. | ||||
|  | ||||
| ```typescript | ||||
| <dees-appui-view | ||||
|   viewTitle="Dashboard" | ||||
|   viewSubtitle="System Overview" | ||||
|   .headerActions=${[ | ||||
|     { icon: 'refresh', action: handleRefresh }, | ||||
|     { icon: 'settings', action: handleSettings } | ||||
|   ]} | ||||
| > | ||||
|   <!-- View content --> | ||||
| </dees-appui-view> | ||||
| ``` | ||||
|  | ||||
| #### `DeesMobileNavigation` | ||||
| Responsive navigation component for mobile devices. | ||||
|  | ||||
| ```typescript | ||||
| <dees-mobile-navigation | ||||
|   .menuItems=${[ | ||||
|     { | ||||
|       key: 'home', | ||||
|       label: 'Home', | ||||
|       icon: 'home', | ||||
|       action: () => navigate('home') | ||||
|     }, | ||||
|     { | ||||
|       key: 'profile', | ||||
|       label: 'Profile', | ||||
|       icon: 'user', | ||||
|       action: () => navigate('profile') | ||||
|     } | ||||
|   ]} | ||||
|   activeKey="home"    // Currently active item | ||||
|   position="bottom"   // Options: bottom, top | ||||
|   @item-click=${handleNavigationClick} | ||||
| ></dees-mobile-navigation> | ||||
| ``` | ||||
|  | ||||
| #### `DeesDashboardGrid` | ||||
| Drag-and-drop grid layout system for creating customizable dashboards. | ||||
|  | ||||
| ```typescript | ||||
| <dees-dashboardgrid | ||||
|   .widgets=${[ | ||||
|     { | ||||
|       id: 'widget1', | ||||
|       x: 0,          // Grid column position | ||||
|       y: 0,          // Grid row position | ||||
|       w: 4,          // Width in grid units | ||||
|       h: 3,          // Height in grid units | ||||
|       minW: 2,       // Minimum width | ||||
|       minH: 2,       // Minimum height | ||||
|       maxW: 6,       // Maximum width | ||||
|       title: 'Sales Overview', | ||||
|       icon: 'fa:chart-line', | ||||
|       content: html`<div>Widget content here</div>`, | ||||
|       noMove: false,  // Allow moving | ||||
|       noResize: false // Allow resizing | ||||
|     }, | ||||
|     { | ||||
|       id: 'widget2', | ||||
|       x: 4, | ||||
|       y: 0, | ||||
|       w: 4, | ||||
|       h: 3, | ||||
|       title: 'Recent Activity', | ||||
|       content: html`<dees-table .data=${activityData}></dees-table>`, | ||||
|       autoPosition: true  // Auto-find position | ||||
|     } | ||||
|   ]} | ||||
|   columns={12}         // Number of grid columns | ||||
|   cellHeight={80}      // Height of each grid cell in pixels | ||||
|   cellHeightUnit="px"  // Options: px, em, rem, auto | ||||
|   margin={10}          // Gap between widgets | ||||
|   editable={true}      // Enable drag and resize | ||||
|   showGridLines={false} // Show grid guidelines | ||||
|   enableAnimation={true} // Smooth transitions | ||||
|   rtl={false}          // Right-to-left support | ||||
|   @widget-move=${handleWidgetMove} | ||||
|   @widget-resize=${handleWidgetResize} | ||||
| ></dees-dashboardgrid> | ||||
|  | ||||
| // Programmatic methods | ||||
| const grid = document.querySelector('dees-dashboardgrid'); | ||||
|  | ||||
| // Add a new widget | ||||
| grid.addWidget({ | ||||
|   id: 'newWidget', | ||||
|   x: 0, | ||||
|   y: 0, | ||||
|   w: 3, | ||||
|   h: 2, | ||||
|   content: html`<div>New widget</div>` | ||||
| }, true); // true = auto-position | ||||
|  | ||||
| // Remove widget | ||||
| grid.removeWidget('widget1'); | ||||
|  | ||||
| // Update widget | ||||
| grid.updateWidget('widget2', {  | ||||
|   title: 'Updated Title', | ||||
|   w: 6  | ||||
| }); | ||||
|  | ||||
| // Get/set layout | ||||
| const layout = grid.getLayout(); // Returns position data | ||||
| grid.setLayout(savedLayout);      // Restore positions | ||||
|  | ||||
| // Compact widgets | ||||
| grid.compact('vertical');  // Or 'horizontal' | ||||
|  | ||||
| // Lock/unlock editing | ||||
| grid.lockGrid(); | ||||
| grid.unlockGrid(); | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Drag-and-drop widget repositioning | ||||
| - Resize handles on edges and corners | ||||
| - Grid-based layout system | ||||
| - Collision detection | ||||
| - Auto-positioning for new widgets | ||||
| - Configurable constraints (min/max dimensions) | ||||
| - Lock individual widgets or entire grid | ||||
| - Compact layout algorithm | ||||
| - Save/restore layout positions | ||||
| - RTL layout support | ||||
| - Optional grid lines for alignment | ||||
| - Smooth animations | ||||
| - Responsive sizing | ||||
| - Empty state display | ||||
|  | ||||
| ### Data Display Components | ||||
|  | ||||
| #### `DeesTable` | ||||
| @@ -1145,6 +845,19 @@ Advanced table component with sorting, filtering, and action support. | ||||
| ></dees-table> | ||||
| ``` | ||||
|  | ||||
| ##### DeesTable (Updated) | ||||
|  | ||||
| Newer features available in `dees-table`: | ||||
| - Schema-first columns or `displayFunction` rendering | ||||
| - Sorting via header clicks with `aria-sort` + `sortChange` | ||||
| - Global search with Lucene-like syntax; modes: `table`, `data`, `server` | ||||
| - Per-column quick filters row; `showColumnFilters` and `column.filterable=false` | ||||
| - Selection: `none` | `single` | `multi`, with select-all and `selectionChange` | ||||
| - Sticky header + internal scroll (`stickyHeader`, `--table-max-height`) | ||||
| - Rich actions: header/in-row/contextmenu/footer/doubleClick; pinned Actions column | ||||
| - Editable cells via `editableFields` | ||||
| - Drag & drop files onto rows | ||||
|  | ||||
| #### `DeesDataviewCodebox` | ||||
| Code display component with syntax highlighting and line numbers. | ||||
|  | ||||
| @@ -1155,7 +868,7 @@ Code display component with syntax highlighting and line numbers. | ||||
|     import { html } from '@design.estate/dees-element'; | ||||
|      | ||||
|     export const myComponent = () => { | ||||
|       return html\`<div>Hello World</div>\`; | ||||
|       return html`<div>Hello World</div>`; | ||||
|     }; | ||||
|   `} | ||||
| ></dees-dataview-codebox> | ||||
| @@ -1208,37 +921,6 @@ PDF viewer component with navigation and zoom controls. | ||||
| ></dees-pdf> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - `DeesTable`: | ||||
|   - Sortable columns | ||||
|   - Searchable content | ||||
|   - Customizable row actions | ||||
|   - Selection support | ||||
|   - Form compatibility | ||||
|   - Custom display formatting | ||||
|  | ||||
| - `DeesDataviewCodebox`: | ||||
|   - Syntax highlighting for multiple languages | ||||
|   - Line numbering | ||||
|   - Copy to clipboard functionality | ||||
|   - Custom theme support | ||||
|   - Window-like appearance with controls | ||||
|  | ||||
| - `DeesDataviewStatusobject`: | ||||
|   - Hierarchical status display | ||||
|   - Color-coded status indicators | ||||
|   - Expandable details | ||||
|   - JSON export capability | ||||
|   - Customizable styling | ||||
|  | ||||
| - `DeesPdf`: | ||||
|   - Page navigation | ||||
|   - Zoom controls | ||||
|   - Download support | ||||
|   - Print functionality | ||||
|   - Responsive layout | ||||
|   - Loading states | ||||
|  | ||||
| #### `DeesStatsGrid` | ||||
| A responsive grid component for displaying statistical data with various visualization types including numbers, gauges, percentages, and trends. | ||||
|  | ||||
| @@ -1336,116 +1018,6 @@ A responsive grid component for displaying statistical data with various visuali | ||||
| ></dees-statsgrid> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Auto-responsive grid layout with configurable minimum tile width | ||||
| - Multiple tile types for different data visualizations | ||||
| - Full theme support (light/dark mode) | ||||
| - Interactive tiles with action support | ||||
| - Grid-level and tile-level actions | ||||
| - Smooth animations and transitions | ||||
| - Icon support for visual hierarchy | ||||
|  | ||||
| Tile Types: | ||||
| 1. **`number`** - Display numeric values with optional units | ||||
|    - Large, prominent value display | ||||
|    - Optional unit display | ||||
|    - Custom color support | ||||
|    - Description text | ||||
|  | ||||
| 2. **`gauge`** - Circular gauge visualization | ||||
|    - Min/max value configuration | ||||
|    - Color thresholds for visual alerts | ||||
|    - Animated value transitions | ||||
|    - Compact circular design | ||||
|  | ||||
| 3. **`percentage`** - Progress bar visualization | ||||
|    - Horizontal progress bar | ||||
|    - Percentage display overlay | ||||
|    - Custom color support | ||||
|    - Ideal for capacity metrics | ||||
|  | ||||
| 4. **`trend`** - Mini sparkline chart | ||||
|    - Array of numeric values for trend data | ||||
|    - Area chart visualization | ||||
|    - Current value display | ||||
|    - Responsive SVG rendering | ||||
|  | ||||
| 5. **`text`** - Simple text display | ||||
|    - Flexible text content | ||||
|    - Custom color support | ||||
|    - Ideal for status messages | ||||
|  | ||||
| Action System: | ||||
| - **Grid Actions**: Displayed as buttons in the grid header | ||||
|   - Apply to the entire stats grid | ||||
|   - Use standard `dees-button` components | ||||
|   - Support icons and text | ||||
|  | ||||
| - **Tile Actions**: Context-specific actions per tile | ||||
|   - Single action: Direct click on tile | ||||
|   - Multiple actions: Right-click context menu | ||||
|   - Actions access tile data through closures | ||||
|   - Consistent with other library components | ||||
|  | ||||
| Configuration Options: | ||||
| - `tiles`: Array of `IStatsTile` objects defining the grid content | ||||
| - `gridActions`: Array of actions for the entire grid | ||||
| - `minTileWidth`: Minimum width for tiles (default: 250px) | ||||
| - `gap`: Space between tiles (default: 16px) | ||||
|  | ||||
| Best Practices: | ||||
| 1. **Data Organization** | ||||
|    - Group related metrics together | ||||
|    - Use consistent units and scales | ||||
|    - Provide meaningful descriptions | ||||
|    - Choose appropriate tile types for data | ||||
|  | ||||
| 2. **Visual Hierarchy** | ||||
|    - Use colors strategically for alerts | ||||
|    - Include relevant icons | ||||
|    - Keep titles concise | ||||
|    - Balance tile types for visual interest | ||||
|  | ||||
| 3. **Interactivity** | ||||
|    - Provide relevant actions for detailed views | ||||
|    - Use tile actions for item-specific operations | ||||
|    - Use grid actions for global operations | ||||
|    - Keep action names clear and concise | ||||
|  | ||||
| 4. **Performance** | ||||
|    - Update only changed tiles | ||||
|    - Use reasonable update intervals | ||||
|    - Batch updates when possible | ||||
|    - Consider data volume for trends | ||||
|  | ||||
| Common Use Cases: | ||||
| - System monitoring dashboards | ||||
| - Business intelligence displays | ||||
| - Performance metrics | ||||
| - Resource utilization | ||||
| - Real-time statistics | ||||
| - KPI tracking | ||||
|  | ||||
| Integration Example: | ||||
| ```typescript | ||||
| // Real-time updates | ||||
| setInterval(() => { | ||||
|   const grid = document.querySelector('dees-statsgrid'); | ||||
|   const updatedTiles = [...grid.tiles]; | ||||
|    | ||||
|   // Update specific tile | ||||
|   const cpuTile = updatedTiles.find(t => t.id === 'cpu'); | ||||
|   cpuTile.value = Math.round(Math.random() * 100); | ||||
|    | ||||
|   // Update trend data | ||||
|   const trendTile = updatedTiles.find(t => t.id === 'requests'); | ||||
|   trendTile.trendData = [...trendTile.trendData.slice(1),  | ||||
|     Math.round(Math.random() * 100)]; | ||||
|    | ||||
|   grid.tiles = updatedTiles; | ||||
| }, 3000); | ||||
| ``` | ||||
|  | ||||
| #### `DeesPagination` | ||||
| Pagination component for navigating through large datasets. | ||||
|  | ||||
| @@ -1459,14 +1031,6 @@ Pagination component for navigating through large datasets. | ||||
| ></dees-pagination> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Page number navigation | ||||
| - Previous/next buttons | ||||
| - Jump to first/last page | ||||
| - Configurable items per page | ||||
| - Responsive design | ||||
| - Keyboard navigation support | ||||
|  | ||||
| ### Visualization Components | ||||
|  | ||||
| #### `DeesChartArea` | ||||
| @@ -1496,22 +1060,6 @@ Area chart component built on ApexCharts for visualizing time-series data. | ||||
| ></dees-chart-area> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Responsive design with automatic resizing | ||||
| - Gradient fill support | ||||
| - Interactive tooltips | ||||
| - Grid customization | ||||
| - Multiple series support | ||||
| - Time-based x-axis | ||||
| - Customizable styling | ||||
|  | ||||
| Configuration Options: | ||||
| - Series data format: `{ x: timestamp, y: value }` | ||||
| - Tooltip customization with datetime formatting | ||||
| - Grid line styling and colors | ||||
| - Gradient fill properties | ||||
| - Chart dimensions and responsiveness | ||||
|  | ||||
| #### `DeesChartLog` | ||||
| Specialized chart component for visualizing log data and events. | ||||
|  | ||||
| @@ -1535,44 +1083,6 @@ Specialized chart component for visualizing log data and events. | ||||
| ></dees-chart-log> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Event timeline visualization | ||||
| - Color-coded event types | ||||
| - Interactive event details | ||||
| - Filtering capabilities | ||||
| - Zoom and pan support | ||||
| - Time-based navigation | ||||
| - Event clustering | ||||
|  | ||||
| Common Use Cases: | ||||
| - System monitoring | ||||
| - Performance tracking | ||||
| - Resource utilization | ||||
| - Event logging | ||||
| - Time-series analysis | ||||
| - Trend visualization | ||||
|  | ||||
| Best Practices: | ||||
| 1. Data Formatting | ||||
|    - Use consistent timestamp formats | ||||
|    - Provide meaningful series names | ||||
|    - Include appropriate data points | ||||
|  | ||||
| 2. Responsiveness | ||||
|    - Charts automatically adjust to container size | ||||
|    - Consider mobile viewports | ||||
|    - Set appropriate min/max dimensions | ||||
|  | ||||
| 3. Interaction | ||||
|    - Enable relevant tooltips | ||||
|    - Configure meaningful click handlers | ||||
|    - Implement appropriate zoom levels | ||||
|  | ||||
| 4. Styling | ||||
|    - Use consistent color schemes | ||||
|    - Configure appropriate grid lines | ||||
|    - Set readable font sizes | ||||
|  | ||||
| ### Dialogs & Overlays Components | ||||
|  | ||||
| #### `DeesModal` | ||||
| @@ -1616,14 +1126,6 @@ DeesModal.createAndShow({ | ||||
| ></dees-modal> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Backdrop blur effect | ||||
| - Customizable content using HTML templates | ||||
| - Flexible action buttons | ||||
| - Outside click handling | ||||
| - Animated transitions | ||||
| - Automatic window layer management | ||||
|  | ||||
| #### `DeesContextmenu` | ||||
| Context menu component for right-click actions. | ||||
|  | ||||
| @@ -1663,13 +1165,6 @@ const bubble = await DeesSpeechbubble.createAndShow( | ||||
| ></dees-speechbubble> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Automatic positioning | ||||
| - Non-intrusive overlay | ||||
| - Animated appearance | ||||
| - Reference element tracking | ||||
| - Custom styling options | ||||
|  | ||||
| #### `DeesWindowlayer` | ||||
| Base overlay component used by modal dialogs and other overlay components. | ||||
|  | ||||
| @@ -1692,43 +1187,6 @@ const layer = await DeesWindowLayer.createAndShow({ | ||||
| </dees-windowlayer> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Backdrop blur support | ||||
| - Click event handling | ||||
| - Z-index management | ||||
| - Animated transitions | ||||
| - Flexible content container | ||||
|  | ||||
| Best Practices: | ||||
|  | ||||
| 1. Modal Dialogs | ||||
|    - Use for important user interactions | ||||
|    - Provide clear action buttons | ||||
|    - Include close/cancel options | ||||
|    - Handle outside clicks appropriately | ||||
|    - Use meaningful headings | ||||
|  | ||||
| 2. Context Menus | ||||
|    - Group related actions | ||||
|    - Use consistent icons | ||||
|    - Provide keyboard shortcuts | ||||
|    - Consider position constraints | ||||
|    - Handle menu item states | ||||
|  | ||||
| 3. Speech Bubbles | ||||
|    - Keep content concise | ||||
|    - Position strategically | ||||
|    - Avoid overlapping | ||||
|    - Consider mobile viewports | ||||
|    - Use appropriate timing | ||||
|  | ||||
| 4. Window Layers | ||||
|    - Manage z-index carefully | ||||
|    - Handle backdrop interactions | ||||
|    - Consider performance impact | ||||
|    - Implement proper cleanup | ||||
|    - Manage multiple layers | ||||
|  | ||||
| ### Navigation Components | ||||
|  | ||||
| #### `DeesStepper` | ||||
| @@ -1761,14 +1219,6 @@ Multi-step navigation component for guided user flows. | ||||
| ></dees-stepper> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Linear or non-linear progression | ||||
| - Step validation | ||||
| - Progress tracking | ||||
| - Customizable step content | ||||
| - Navigation controls | ||||
| - Step completion indicators | ||||
|  | ||||
| #### `DeesProgressbar` | ||||
| Progress indicator component for tracking completion status. | ||||
|  | ||||
| @@ -1783,53 +1233,6 @@ Progress indicator component for tracking completion status. | ||||
| ></dees-progressbar> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Determinate and indeterminate states | ||||
| - Percentage display | ||||
| - Custom styling options | ||||
| - Status indicators | ||||
| - Animation support | ||||
| - Accessibility features | ||||
|  | ||||
| Best Practices: | ||||
|  | ||||
| 1. Stepper Implementation | ||||
|    - Clear step labels | ||||
|    - Validation feedback | ||||
|    - Progress indication | ||||
|    - Error handling | ||||
|    - Consistent navigation | ||||
|  | ||||
| 2. Progress Tracking | ||||
|    - Accurate progress calculation | ||||
|    - Clear visual feedback | ||||
|    - Status communication | ||||
|    - Performance monitoring | ||||
|    - Error state handling | ||||
|  | ||||
| Common Use Cases: | ||||
|  | ||||
| 1. Stepper | ||||
|    - Multi-step forms | ||||
|    - User onboarding | ||||
|    - Checkout processes | ||||
|    - Setup wizards | ||||
|    - Tutorial flows | ||||
|  | ||||
| 2. Progress Bar | ||||
|    - File uploads | ||||
|    - Process tracking | ||||
|    - Loading indicators | ||||
|    - Task completion | ||||
|    - Step progression | ||||
|  | ||||
| Accessibility Considerations: | ||||
| - Keyboard navigation support | ||||
| - ARIA labels and roles | ||||
| - Focus management | ||||
| - Screen reader compatibility | ||||
| - Color contrast compliance | ||||
|  | ||||
| ### Development Components | ||||
|  | ||||
| #### `DeesEditor` | ||||
| @@ -1849,17 +1252,6 @@ Code editor component with syntax highlighting and code completion, powered by M | ||||
| ></dees-editor> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Syntax highlighting for multiple languages | ||||
| - Code completion | ||||
| - Line numbers | ||||
| - Minimap navigation | ||||
| - Customizable options | ||||
| - Theme support | ||||
| - Search and replace | ||||
| - Multiple cursors | ||||
| - Code folding | ||||
|  | ||||
| #### `DeesEditorMarkdown` | ||||
| Markdown editor component with live preview. | ||||
|  | ||||
| @@ -1876,16 +1268,6 @@ Markdown editor component with live preview. | ||||
| ></dees-editor-markdown> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Live preview | ||||
| - Toolbar for common formatting | ||||
| - Markdown syntax highlighting | ||||
| - Image upload support | ||||
| - Table editor | ||||
| - Customizable preview styles | ||||
| - Spellcheck integration | ||||
| - Auto-save functionality | ||||
|  | ||||
| #### `DeesEditorMarkdownoutlet` | ||||
| Markdown preview component for rendering markdown content. | ||||
|  | ||||
| @@ -1898,14 +1280,6 @@ Markdown preview component for rendering markdown content. | ||||
| ></dees-editor-markdownoutlet> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Safe markdown rendering | ||||
| - Multiple themes | ||||
| - Plugin support (mermaid diagrams, syntax highlighting) | ||||
| - XSS protection | ||||
| - Custom CSS injection | ||||
| - Responsive images | ||||
|  | ||||
| #### `DeesTerminal` | ||||
| Terminal emulator component for command-line interface. | ||||
|  | ||||
| @@ -1922,16 +1296,6 @@ Terminal emulator component for command-line interface. | ||||
| ></dees-terminal> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Command history | ||||
| - Custom commands | ||||
| - Auto-completion | ||||
| - Copy/paste support | ||||
| - ANSI color support | ||||
| - Scrollback buffer | ||||
| - Keyboard shortcuts | ||||
| - Command aliases | ||||
|  | ||||
| #### `DeesUpdater` | ||||
| Component for managing application updates and version control. | ||||
|  | ||||
| @@ -1945,112 +1309,6 @@ Component for managing application updates and version control. | ||||
| ></dees-updater> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Version checking | ||||
| - Update notifications | ||||
| - Progress tracking | ||||
| - Automatic updates | ||||
| - Rollback support | ||||
| - Update scheduling | ||||
| - Dependency management | ||||
|  | ||||
| Best Practices: | ||||
|  | ||||
| 1. Code Editor Usage | ||||
|    - Configure appropriate language | ||||
|    - Set reasonable defaults | ||||
|    - Handle content changes | ||||
|    - Manage undo/redo stack | ||||
|    - Consider performance | ||||
|  | ||||
| 2. Markdown Editing | ||||
|    - Provide clear toolbar | ||||
|    - Show live preview | ||||
|    - Handle image uploads | ||||
|    - Support shortcuts | ||||
|    - Maintain consistent styling | ||||
|  | ||||
| 3. Terminal Implementation | ||||
|    - Clear command documentation | ||||
|    - Handle errors gracefully | ||||
|    - Provide command history | ||||
|    - Support common shortcuts | ||||
|    - Implement auto-completion | ||||
|  | ||||
| 4. Update Management | ||||
|    - Regular version checks | ||||
|    - Clear update messaging | ||||
|    - Progress indication | ||||
|    - Error recovery | ||||
|    - User confirmation | ||||
|  | ||||
| Common Use Cases: | ||||
|  | ||||
| 1. Code Editor | ||||
|    - Configuration editing | ||||
|    - Script development | ||||
|    - Code snippets | ||||
|    - Documentation | ||||
|    - Teaching tools | ||||
|  | ||||
| 2. Markdown Editor | ||||
|    - Documentation | ||||
|    - Content creation | ||||
|    - README files | ||||
|    - Blog posts | ||||
|    - Release notes | ||||
|  | ||||
| 3. Terminal | ||||
|    - Command execution | ||||
|    - System monitoring | ||||
|    - Development tools | ||||
|    - Debugging | ||||
|    - Training environments | ||||
|  | ||||
| 4. Updater | ||||
|    - Application updates | ||||
|    - Plugin management | ||||
|    - Feature deployment | ||||
|    - Security patches | ||||
|    - Configuration updates | ||||
|  | ||||
| Integration Examples: | ||||
|  | ||||
| ```typescript | ||||
| // Combining components for a development environment | ||||
| <dees-form> | ||||
|   <dees-editor | ||||
|     .language=${'javascript'} | ||||
|     .value=${code} | ||||
|     @change=${updatePreview} | ||||
|   ></dees-editor> | ||||
|    | ||||
|   <dees-terminal | ||||
|     .commands=${devCommands} | ||||
|     @command=${executeCommand} | ||||
|   ></dees-terminal> | ||||
|    | ||||
|   <dees-updater | ||||
|     .currentVersion=${appVersion} | ||||
|     @update-available=${notifyUser} | ||||
|   ></dees-updater> | ||||
| </dees-form> | ||||
| ``` | ||||
|  | ||||
| Performance Considerations: | ||||
| - Lazy loading of heavy components | ||||
| - Memory management | ||||
| - Resource cleanup | ||||
| - Event handling optimization | ||||
| - Efficient updates | ||||
|  | ||||
| Accessibility Features: | ||||
| - Keyboard navigation | ||||
| - Screen reader support | ||||
| - High contrast themes | ||||
| - Focus management | ||||
| - ARIA attributes | ||||
|  | ||||
| ### Auth & Utilities Components | ||||
|  | ||||
| #### `DeesSimpleAppdash` | ||||
| @@ -2073,13 +1331,6 @@ Simple application dashboard component for quick prototyping. | ||||
| </dees-simple-appdash> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Quick setup dashboard layout | ||||
| - Built-in navigation | ||||
| - User profile section | ||||
| - Responsive design | ||||
| - Minimal configuration | ||||
|  | ||||
| #### `DeesSimpleLogin` | ||||
| Simple login form component with validation and customization. | ||||
|  | ||||
| @@ -2096,15 +1347,6 @@ Simple login form component with validation and customization. | ||||
| ></dees-simple-login> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Customizable fields | ||||
| - Built-in validation | ||||
| - Remember me option | ||||
| - Forgot password link | ||||
| - Custom branding | ||||
| - Responsive layout | ||||
| - Loading states | ||||
|  | ||||
| ### Shopping Components | ||||
|  | ||||
| #### `DeesShoppingProductcard` | ||||
| @@ -2133,41 +1375,6 @@ Product card component for e-commerce applications. | ||||
| ></dees-shopping-productcard> | ||||
| ``` | ||||
|  | ||||
| Key Features: | ||||
| - Product image with fallback icon | ||||
| - Category label | ||||
| - Product name and description | ||||
| - Price display with original price strikethrough | ||||
| - Stock status indicator | ||||
| - Built-in quantity selector | ||||
| - Selection mode for bulk operations | ||||
| - Hover effects | ||||
| - Responsive design | ||||
| - Theme-aware styling | ||||
|  | ||||
| Product Data Interface: | ||||
| ```typescript | ||||
| interface IProductData { | ||||
|   name: string; | ||||
|   category?: string; | ||||
|   description?: string; | ||||
|   price: number; | ||||
|   originalPrice?: number; | ||||
|   currency?: string; | ||||
|   inStock?: boolean; | ||||
|   stockText?: string; | ||||
|   imageUrl?: string; | ||||
|   iconName?: string; | ||||
| } | ||||
| ``` | ||||
|  | ||||
| Common Use Cases: | ||||
| - Product listings | ||||
| - Shopping carts | ||||
| - Order summaries | ||||
| - Product comparisons | ||||
| - Wishlist displays | ||||
|  | ||||
| ## License and Legal Information | ||||
|  | ||||
| This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the license file within this repository. | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								readme.plan.md
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								readme.plan.md
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										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; | ||||
| } | ||||
							
								
								
									
										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(); | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@design.estate/dees-catalog', | ||||
|   version: '1.10.12', | ||||
|   version: '1.12.6', | ||||
|   description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' | ||||
| } | ||||
|   | ||||
| @@ -5,19 +5,19 @@ import { | ||||
|   property, | ||||
|   state, | ||||
|   html, | ||||
|   css, | ||||
|   cssManager, | ||||
| } from '@design.estate/dees-element'; | ||||
| 
 | ||||
| import * as domtools from '@design.estate/dees-domtools'; | ||||
| import * as interfaces from './interfaces/index.js'; | ||||
| import * as plugins from './00plugins.js'; | ||||
| import { demoFunc } from './dees-appui-appbar.demo.js'; | ||||
| import * as interfaces from '../interfaces/index.js'; | ||||
| import * as plugins from '../00plugins.js'; | ||||
| import { demoFunc } from './demo.js'; | ||||
| import { appuiAppbarStyles } from './styles.js'; | ||||
| import { renderAppuiAppbar } from './template.js'; | ||||
| 
 | ||||
| // Import required components
 | ||||
| import './dees-icon.js'; | ||||
| import './dees-windowcontrols.js'; | ||||
| import './dees-appui-profiledropdown.js'; | ||||
| import '../dees-icon.js'; | ||||
| import '../dees-windowcontrols.js'; | ||||
| import '../dees-appui-profiledropdown.js'; | ||||
| 
 | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
| @@ -73,259 +73,16 @@ export class DeesAppuiBar extends DeesElement { | ||||
|   @state() | ||||
|   private isProfileDropdownOpen: boolean = false; | ||||
| 
 | ||||
|   public static styles = [ | ||||
|     cssManager.defaultStyles, | ||||
|     css` | ||||
|       :host { | ||||
|         /* CSS Variables for theming */ | ||||
|         --appbar-height: 40px; | ||||
|         --appbar-font-size: 12px; | ||||
|          | ||||
|         display: block; | ||||
|         position: relative; | ||||
|         width: 100%; | ||||
|         height: var(--appbar-height); | ||||
|         border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')}; | ||||
|         background: ${cssManager.bdTheme('#ffffff', '#000000')}; | ||||
|         color: ${cssManager.bdTheme('#00000080', '#ffffff80')}; | ||||
|         font-size: var(--appbar-font-size); | ||||
|         display: grid; | ||||
|         grid-template-columns: ${cssManager.cssGridColumns(3, 20)}; | ||||
|         -webkit-app-region: drag; | ||||
|         user-select: none; | ||||
|       } | ||||
| 
 | ||||
|       .menus { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 4px; | ||||
|         padding: 0 8px; | ||||
|         cursor: default; | ||||
|       } | ||||
| 
 | ||||
|       .menuItem { | ||||
|         position: relative; | ||||
|         line-height: 24px; | ||||
|         padding: 0px 12px; | ||||
|         margin: 8px 0px; | ||||
|         border-radius: 4px; | ||||
|         -webkit-app-region: no-drag; | ||||
|         transition: all 0.2s ease; | ||||
|         cursor: default; | ||||
|         outline: none; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 4px; | ||||
|       } | ||||
| 
 | ||||
|       /* Optional: Style for menu items with icons (not typically used for top-level items) */ | ||||
|       .menuItem dees-icon { | ||||
|         font-size: 14px; | ||||
|         opacity: 0.8; | ||||
|       } | ||||
| 
 | ||||
|       .menuItem:hover { | ||||
|         background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; | ||||
|         color: ${cssManager.bdTheme('#000000', '#ffffff')}; | ||||
|       } | ||||
| 
 | ||||
|       .menuItem.active { | ||||
|         background: ${cssManager.bdTheme('#00000020', '#ffffff30')}; | ||||
|         color: ${cssManager.bdTheme('#000000', '#ffffff')}; | ||||
|       } | ||||
| 
 | ||||
|       .menuItem[disabled] { | ||||
|         opacity: 0.5; | ||||
|         cursor: not-allowed; | ||||
|         pointer-events: none; | ||||
|       } | ||||
| 
 | ||||
|       .menuItem:focus-visible { | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')}; | ||||
|       } | ||||
| 
 | ||||
| 
 | ||||
|       /* Dropdown styles */ | ||||
|       .dropdown { | ||||
|         position: absolute; | ||||
|         top: 100%; | ||||
|         left: 0; | ||||
|         min-width: 200px; | ||||
|         background: ${cssManager.bdTheme('#ffffff', '#000000')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')}; | ||||
|         border-radius: 4px; | ||||
|         box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')}; | ||||
|         margin-top: 4px; | ||||
|         z-index: 1000; | ||||
|         opacity: 0; | ||||
|         transform: translateY(-10px); | ||||
|         transition: opacity 0.2s, transform 0.2s; | ||||
|         pointer-events: none; | ||||
|       } | ||||
| 
 | ||||
|       .dropdown.open { | ||||
|         opacity: 1; | ||||
|         transform: translateY(0); | ||||
|         pointer-events: auto; | ||||
|       } | ||||
| 
 | ||||
|       .dropdown-item { | ||||
|         padding: 8px 16px; | ||||
|         cursor: default; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 8px; | ||||
|         transition: background 0.1s; | ||||
|       } | ||||
| 
 | ||||
|       .dropdown-item:hover, | ||||
|       .dropdown-item.focused { | ||||
|         background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; | ||||
|       } | ||||
| 
 | ||||
|       .dropdown-divider { | ||||
|         height: 1px; | ||||
|         background: ${cssManager.bdTheme('#e0e0e0', '#202020')}; | ||||
|         margin: 4px 0; | ||||
|       } | ||||
| 
 | ||||
|       .dropdown-item[disabled] { | ||||
|         opacity: 0.5; | ||||
|         cursor: not-allowed; | ||||
|         pointer-events: none; | ||||
|       } | ||||
| 
 | ||||
|       .dropdown-item .shortcut { | ||||
|         margin-left: auto; | ||||
|         opacity: 0.6; | ||||
|         font-size: 11px; | ||||
|       } | ||||
| 
 | ||||
|       /* Breadcrumbs */ | ||||
|       .breadcrumbs { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         height: 100%; | ||||
|         padding: 0 16px; | ||||
|         overflow: hidden; | ||||
|         text-overflow: ellipsis; | ||||
|         white-space: nowrap; | ||||
|       } | ||||
| 
 | ||||
|       .breadcrumb-item { | ||||
|         color: ${cssManager.bdTheme('#00000080', '#ffffff80')}; | ||||
|         cursor: default; | ||||
|         transition: color 0.2s; | ||||
|       } | ||||
| 
 | ||||
|       .breadcrumb-item:hover { | ||||
|         color: ${cssManager.bdTheme('#000000', '#ffffff')}; | ||||
|       } | ||||
| 
 | ||||
|       .breadcrumb-separator { | ||||
|         margin: 0 8px; | ||||
|         opacity: 0.5; | ||||
|       } | ||||
| 
 | ||||
|       /* Account section */ | ||||
|       .account { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: flex-end; | ||||
|         padding: 0 16px; | ||||
|         gap: 12px; | ||||
|       } | ||||
| 
 | ||||
|       .search-icon { | ||||
|         cursor: default; | ||||
|         opacity: 0.7; | ||||
|         transition: opacity 0.2s; | ||||
|       } | ||||
| 
 | ||||
|       .search-icon:hover { | ||||
|         opacity: 1; | ||||
|       } | ||||
| 
 | ||||
|       .user-info { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 8px; | ||||
|         cursor: default; | ||||
|         padding: 4px 8px; | ||||
|         border-radius: 4px; | ||||
|         transition: background 0.2s; | ||||
|       } | ||||
| 
 | ||||
|       .user-info:hover { | ||||
|         background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; | ||||
|       } | ||||
| 
 | ||||
|       .user-avatar { | ||||
|         position: relative; | ||||
|         width: 24px; | ||||
|         height: 24px; | ||||
|         border-radius: 50%; | ||||
|         background: ${cssManager.bdTheme('#00000020', '#ffffff30')}; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         font-size: 10px; | ||||
|         font-weight: bold; | ||||
|       } | ||||
| 
 | ||||
|       .user-avatar img { | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|         border-radius: 50%; | ||||
|         object-fit: cover; | ||||
|       } | ||||
| 
 | ||||
|       .user-status { | ||||
|         position: absolute; | ||||
|         bottom: -2px; | ||||
|         right: -2px; | ||||
|         width: 8px; | ||||
|         height: 8px; | ||||
|         border-radius: 50%; | ||||
|         border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')}; | ||||
|       } | ||||
| 
 | ||||
|       .user-status.online { | ||||
|         background: #4caf50; | ||||
|       } | ||||
| 
 | ||||
|       .user-status.offline { | ||||
|         background: #757575; | ||||
|       } | ||||
| 
 | ||||
|       .user-status.busy { | ||||
|         background: #f44336; | ||||
|       } | ||||
| 
 | ||||
|       .user-status.away { | ||||
|         background: #ff9800; | ||||
|       } | ||||
|     `,
 | ||||
|   ]; | ||||
|   public static styles = appuiAppbarStyles; | ||||
| 
 | ||||
|   // INSTANCE
 | ||||
|   public render(): TemplateResult { | ||||
|     return html` | ||||
|       <div class="menus"> | ||||
|         ${this.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''} | ||||
|         ${this.renderMenuItems()} | ||||
|       </div> | ||||
|       <div class="breadcrumbs"> | ||||
|         ${this.renderBreadcrumbs()} | ||||
|       </div> | ||||
|       <div class="account"> | ||||
|         ${this.renderAccountSection()} | ||||
|       </div> | ||||
|     `;
 | ||||
|     return renderAppuiAppbar(this); | ||||
|   } | ||||
| 
 | ||||
|   private renderMenuItems(): TemplateResult { | ||||
| 
 | ||||
| 
 | ||||
|   public renderMenuItems(): TemplateResult { | ||||
|     return html` | ||||
|       ${this.menuItems.map((item, index) => this.renderMenuItem(item, `menu-${index}`))} | ||||
|     `;
 | ||||
| @@ -398,7 +155,7 @@ export class DeesAppuiBar extends DeesElement { | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   private renderBreadcrumbs(): TemplateResult { | ||||
|   public renderBreadcrumbs(): TemplateResult { | ||||
|     if (!this.breadcrumbs) { | ||||
|       return html``; | ||||
|     } | ||||
| @@ -417,7 +174,7 @@ export class DeesAppuiBar extends DeesElement { | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   private renderAccountSection(): TemplateResult { | ||||
|   public renderAccountSection(): TemplateResult { | ||||
|     return html` | ||||
|       ${this.showSearch ? html` | ||||
|         <dees-icon  | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { html, css } from '@design.estate/dees-element'; | ||||
| import type { DeesAppuiBar } from './dees-appui-appbar.js'; | ||||
| import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js'; | ||||
| import type { DeesAppuiBar } from './component.js'; | ||||
| import type { IAppBarMenuItem } from '../interfaces/appbarmenuitem.js'; | ||||
| import '@design.estate/dees-wcctools/demotools'; | ||||
| import './component.js'; | ||||
| 
 | ||||
| export const demoFunc = () => { | ||||
|   // Sample menu items with various configurations
 | ||||
							
								
								
									
										3
									
								
								ts_web/elements/dees-appui-appbar/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts_web/elements/dees-appui-appbar/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| export * from './component.js'; | ||||
| export { appuiAppbarStyles } from './styles.js'; | ||||
| export { renderAppuiAppbar } from './template.js'; | ||||
							
								
								
									
										238
									
								
								ts_web/elements/dees-appui-appbar/styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								ts_web/elements/dees-appui-appbar/styles.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,238 @@ | ||||
| import { css, cssManager } from '@design.estate/dees-element'; | ||||
|  | ||||
| export const appuiAppbarStyles = [ | ||||
|     cssManager.defaultStyles, | ||||
|     css` | ||||
|       :host { | ||||
|         /* CSS Variables for theming */ | ||||
|         --appbar-height: 40px; | ||||
|         --appbar-font-size: 12px; | ||||
|          | ||||
|         display: block; | ||||
|         position: relative; | ||||
|         width: 100%; | ||||
|         height: var(--appbar-height); | ||||
|         border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')}; | ||||
|         background: ${cssManager.bdTheme('#ffffff', '#000000')}; | ||||
|         color: ${cssManager.bdTheme('#00000080', '#ffffff80')}; | ||||
|         font-size: var(--appbar-font-size); | ||||
|         display: grid; | ||||
|         grid-template-columns: ${cssManager.cssGridColumns(3, 20)}; | ||||
|         -webkit-app-region: drag; | ||||
|         user-select: none; | ||||
|       } | ||||
|  | ||||
|       .menus { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 4px; | ||||
|         padding: 0 8px; | ||||
|         cursor: default; | ||||
|       } | ||||
|  | ||||
|       .menuItem { | ||||
|         position: relative; | ||||
|         line-height: 24px; | ||||
|         padding: 0px 12px; | ||||
|         margin: 8px 0px; | ||||
|         border-radius: 4px; | ||||
|         -webkit-app-region: no-drag; | ||||
|         transition: all 0.2s ease; | ||||
|         cursor: default; | ||||
|         outline: none; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 4px; | ||||
|       } | ||||
|  | ||||
|       /* Optional: Style for menu items with icons (not typically used for top-level items) */ | ||||
|       .menuItem dees-icon { | ||||
|         font-size: 14px; | ||||
|         opacity: 0.8; | ||||
|       } | ||||
|  | ||||
|       .menuItem:hover { | ||||
|         background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; | ||||
|         color: ${cssManager.bdTheme('#000000', '#ffffff')}; | ||||
|       } | ||||
|  | ||||
|       .menuItem.active { | ||||
|         background: ${cssManager.bdTheme('#00000020', '#ffffff30')}; | ||||
|         color: ${cssManager.bdTheme('#000000', '#ffffff')}; | ||||
|       } | ||||
|  | ||||
|       .menuItem[disabled] { | ||||
|         opacity: 0.5; | ||||
|         cursor: not-allowed; | ||||
|         pointer-events: none; | ||||
|       } | ||||
|  | ||||
|       .menuItem:focus-visible { | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')}; | ||||
|       } | ||||
|  | ||||
|  | ||||
|       /* Dropdown styles */ | ||||
|       .dropdown { | ||||
|         position: absolute; | ||||
|         top: 100%; | ||||
|         left: 0; | ||||
|         min-width: 200px; | ||||
|         background: ${cssManager.bdTheme('#ffffff', '#000000')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')}; | ||||
|         border-radius: 4px; | ||||
|         box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')}; | ||||
|         margin-top: 4px; | ||||
|         z-index: 1000; | ||||
|         opacity: 0; | ||||
|         transform: translateY(-10px); | ||||
|         transition: opacity 0.2s, transform 0.2s; | ||||
|         pointer-events: none; | ||||
|       } | ||||
|  | ||||
|       .dropdown.open { | ||||
|         opacity: 1; | ||||
|         transform: translateY(0); | ||||
|         pointer-events: auto; | ||||
|       } | ||||
|  | ||||
|       .dropdown-item { | ||||
|         padding: 8px 16px; | ||||
|         cursor: default; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 8px; | ||||
|         transition: background 0.1s; | ||||
|       } | ||||
|  | ||||
|       .dropdown-item:hover, | ||||
|       .dropdown-item.focused { | ||||
|         background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; | ||||
|       } | ||||
|  | ||||
|       .dropdown-divider { | ||||
|         height: 1px; | ||||
|         background: ${cssManager.bdTheme('#e0e0e0', '#202020')}; | ||||
|         margin: 4px 0; | ||||
|       } | ||||
|  | ||||
|       .dropdown-item[disabled] { | ||||
|         opacity: 0.5; | ||||
|         cursor: not-allowed; | ||||
|         pointer-events: none; | ||||
|       } | ||||
|  | ||||
|       .dropdown-item .shortcut { | ||||
|         margin-left: auto; | ||||
|         opacity: 0.6; | ||||
|         font-size: 11px; | ||||
|       } | ||||
|  | ||||
|       /* Breadcrumbs */ | ||||
|       .breadcrumbs { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         height: 100%; | ||||
|         padding: 0 16px; | ||||
|         overflow: hidden; | ||||
|         text-overflow: ellipsis; | ||||
|         white-space: nowrap; | ||||
|       } | ||||
|  | ||||
|       .breadcrumb-item { | ||||
|         color: ${cssManager.bdTheme('#00000080', '#ffffff80')}; | ||||
|         cursor: default; | ||||
|         transition: color 0.2s; | ||||
|       } | ||||
|  | ||||
|       .breadcrumb-item:hover { | ||||
|         color: ${cssManager.bdTheme('#000000', '#ffffff')}; | ||||
|       } | ||||
|  | ||||
|       .breadcrumb-separator { | ||||
|         margin: 0 8px; | ||||
|         opacity: 0.5; | ||||
|       } | ||||
|  | ||||
|       /* Account section */ | ||||
|       .account { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: flex-end; | ||||
|         padding: 0 16px; | ||||
|         gap: 12px; | ||||
|       } | ||||
|  | ||||
|       .search-icon { | ||||
|         cursor: default; | ||||
|         opacity: 0.7; | ||||
|         transition: opacity 0.2s; | ||||
|       } | ||||
|  | ||||
|       .search-icon:hover { | ||||
|         opacity: 1; | ||||
|       } | ||||
|  | ||||
|       .user-info { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 8px; | ||||
|         cursor: default; | ||||
|         padding: 4px 8px; | ||||
|         border-radius: 4px; | ||||
|         transition: background 0.2s; | ||||
|       } | ||||
|  | ||||
|       .user-info:hover { | ||||
|         background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; | ||||
|       } | ||||
|  | ||||
|       .user-avatar { | ||||
|         position: relative; | ||||
|         width: 24px; | ||||
|         height: 24px; | ||||
|         border-radius: 50%; | ||||
|         background: ${cssManager.bdTheme('#00000020', '#ffffff30')}; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         font-size: 10px; | ||||
|         font-weight: bold; | ||||
|       } | ||||
|  | ||||
|       .user-avatar img { | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|         border-radius: 50%; | ||||
|         object-fit: cover; | ||||
|       } | ||||
|  | ||||
|       .user-status { | ||||
|         position: absolute; | ||||
|         bottom: -2px; | ||||
|         right: -2px; | ||||
|         width: 8px; | ||||
|         height: 8px; | ||||
|         border-radius: 50%; | ||||
|         border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')}; | ||||
|       } | ||||
|  | ||||
|       .user-status.online { | ||||
|         background: #4caf50; | ||||
|       } | ||||
|  | ||||
|       .user-status.offline { | ||||
|         background: #757575; | ||||
|       } | ||||
|  | ||||
|       .user-status.busy { | ||||
|         background: #f44336; | ||||
|       } | ||||
|  | ||||
|       .user-status.away { | ||||
|         background: #ff9800; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|  | ||||
							
								
								
									
										18
									
								
								ts_web/elements/dees-appui-appbar/template.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								ts_web/elements/dees-appui-appbar/template.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| import { html, type TemplateResult } from '@design.estate/dees-element'; | ||||
| import type { DeesAppuiBar } from './component.js'; | ||||
|  | ||||
| export const renderAppuiAppbar = (component: DeesAppuiBar): TemplateResult => { | ||||
|       return html` | ||||
|         <div class="menus"> | ||||
|           ${component.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''} | ||||
|           ${component.renderMenuItems()} | ||||
|         </div> | ||||
|         <div class="breadcrumbs"> | ||||
|           ${component.renderBreadcrumbs()} | ||||
|         </div> | ||||
|         <div class="account"> | ||||
|           ${component.renderAccountSection()} | ||||
|         </div> | ||||
|       `; | ||||
|    | ||||
| }; | ||||
| @@ -10,7 +10,7 @@ import { | ||||
| } from '@design.estate/dees-element'; | ||||
| import * as interfaces from './interfaces/index.js'; | ||||
| import * as plugins from './00plugins.js'; | ||||
| import type { DeesAppuiBar } from './dees-appui-appbar.js'; | ||||
| import type { DeesAppuiBar } from './dees-appui-appbar/index.js'; | ||||
| import type { DeesAppuiMainmenu } from './dees-appui-mainmenu.js'; | ||||
| import type { DeesAppuiMainselector } from './dees-appui-mainselector.js'; | ||||
| import type { DeesAppuiMaincontent } from './dees-appui-maincontent.js'; | ||||
| @@ -18,7 +18,7 @@ import type { DeesAppuiActivitylog } from './dees-appui-activitylog.js'; | ||||
| import { demoFunc } from './dees-appui-base.demo.js'; | ||||
|  | ||||
| // Import child components | ||||
| import './dees-appui-appbar.js'; | ||||
| import './dees-appui-appbar/index.js'; | ||||
| import './dees-appui-mainmenu.js'; | ||||
| import './dees-appui-mainselector.js'; | ||||
| import './dees-appui-maincontent.js'; | ||||
|   | ||||
| @@ -1,16 +1,15 @@ | ||||
| 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 { demoFunc } from './demo.js'; | ||||
| import { chartAreaStyles } from './styles.js'; | ||||
| import { renderChartArea } from './template.js'; | ||||
| 
 | ||||
| import ApexCharts from 'apexcharts'; | ||||
| 
 | ||||
| @@ -141,73 +140,14 @@ export class DeesChartArea extends DeesElement { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public static styles = [ | ||||
|     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; | ||||
|       } | ||||
|     `,
 | ||||
|   ]; | ||||
|   public static styles = chartAreaStyles; | ||||
| 
 | ||||
|   public render(): TemplateResult { | ||||
|     return html` | ||||
|       <div class="mainbox"> | ||||
|         <div class="chartTitle">${this.label}</div> | ||||
|         <div class="chartContainer"></div> | ||||
|       </div> | ||||
|     `;
 | ||||
|     return renderChartArea(this); | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|   public async firstUpdated() { | ||||
|     await this.domtoolsPromise; | ||||
|      | ||||
| @@ -1,6 +1,7 @@ | ||||
| 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 './component.js'; | ||||
| 
 | ||||
| export const demoFunc = () => { | ||||
|   // Initial dataset values
 | ||||
							
								
								
									
										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> | ||||
|       `; | ||||
|    | ||||
| }; | ||||
| @@ -1,191 +0,0 @@ | ||||
| import { html, css, cssManager } from '@design.estate/dees-element'; | ||||
| import type { DeesDashboardgrid } from './dees-dashboardgrid.js'; | ||||
| import '@design.estate/dees-wcctools/demotools'; | ||||
|  | ||||
| export const demoFunc = () => { | ||||
|   return html` | ||||
|     <dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => { | ||||
|       const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid; | ||||
|        | ||||
|       // Set initial widgets | ||||
|       grid.widgets = [ | ||||
|         { | ||||
|           id: 'metrics1', | ||||
|           x: 0, | ||||
|           y: 0, | ||||
|           w: 3, | ||||
|           h: 2, | ||||
|           title: 'Revenue', | ||||
|           icon: 'lucide:dollarSign', | ||||
|           content: html` | ||||
|             <div style="padding: 20px;"> | ||||
|               <div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div> | ||||
|               <div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div> | ||||
|             </div> | ||||
|           ` | ||||
|         }, | ||||
|         { | ||||
|           id: 'metrics2', | ||||
|           x: 3, | ||||
|           y: 0, | ||||
|           w: 3, | ||||
|           h: 2, | ||||
|           title: 'Users', | ||||
|           icon: 'lucide:users', | ||||
|           content: html` | ||||
|             <div style="padding: 20px;"> | ||||
|               <div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div> | ||||
|               <div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div> | ||||
|             </div> | ||||
|           ` | ||||
|         }, | ||||
|         { | ||||
|           id: 'chart1', | ||||
|           x: 6, | ||||
|           y: 0, | ||||
|           w: 6, | ||||
|           h: 4, | ||||
|           title: 'Analytics', | ||||
|           icon: 'lucide:lineChart', | ||||
|           content: html` | ||||
|             <div style="padding: 20px; height: 100%; display: flex; align-items: center; justify-content: center;"> | ||||
|               <div style="text-align: center; color: #71717a;"> | ||||
|                 <dees-icon .icon=${'lucide:lineChart'} style="font-size: 48px; margin-bottom: 16px;"></dees-icon> | ||||
|                 <div>Chart visualization area</div> | ||||
|               </div> | ||||
|             </div> | ||||
|           ` | ||||
|         } | ||||
|       ]; | ||||
|        | ||||
|       // Configure grid | ||||
|       grid.cellHeight = 80; | ||||
|       grid.margin = { top: 10, right: 10, bottom: 10, left: 10 }; | ||||
|       grid.enableAnimation = true; | ||||
|       grid.showGridLines = false; | ||||
|        | ||||
|       let widgetCounter = 4; | ||||
|        | ||||
|       // Control buttons | ||||
|       const buttons = elementArg.querySelectorAll('dees-button'); | ||||
|       buttons.forEach(button => { | ||||
|         const text = button.textContent?.trim(); | ||||
|          | ||||
|         if (text === 'Toggle Animation') { | ||||
|           button.addEventListener('click', () => { | ||||
|             grid.enableAnimation = !grid.enableAnimation; | ||||
|           }); | ||||
|         } else if (text === 'Toggle Grid Lines') { | ||||
|           button.addEventListener('click', () => { | ||||
|             grid.showGridLines = !grid.showGridLines; | ||||
|           }); | ||||
|         } else if (text === 'Add Widget') { | ||||
|           button.addEventListener('click', () => { | ||||
|             const newWidget = { | ||||
|               id: `widget${widgetCounter++}`, | ||||
|               x: 0, | ||||
|               y: 0, | ||||
|               w: 3, | ||||
|               h: 2, | ||||
|               autoPosition: true, | ||||
|               title: `Widget ${widgetCounter - 1}`, | ||||
|               icon: 'lucide:package', | ||||
|               content: html` | ||||
|                 <div style="padding: 20px; text-align: center;"> | ||||
|                   <div style="color: #71717a;">New widget content</div> | ||||
|                   <div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(Math.random() * 1000)}</div> | ||||
|                 </div> | ||||
|               ` | ||||
|             }; | ||||
|             grid.addWidget(newWidget, true); | ||||
|           }); | ||||
|         } else if (text === 'Compact Grid') { | ||||
|           button.addEventListener('click', () => { | ||||
|             grid.compact(); | ||||
|           }); | ||||
|         } else if (text === 'Toggle Edit Mode') { | ||||
|           button.addEventListener('click', () => { | ||||
|             grid.editable = !grid.editable; | ||||
|             button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid'; | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
|        | ||||
|       // Listen to grid events | ||||
|       grid.addEventListener('widget-move', (e: CustomEvent) => { | ||||
|         console.log('Widget moved:', e.detail.widget); | ||||
|       }); | ||||
|        | ||||
|       grid.addEventListener('widget-resize', (e: CustomEvent) => { | ||||
|         console.log('Widget resized:', e.detail.widget); | ||||
|       }); | ||||
|     }}> | ||||
|       <style> | ||||
|         ${css` | ||||
|           .demoBox { | ||||
|             position: relative; | ||||
|             background: ${cssManager.bdTheme('#f4f4f5', '#09090b')}; | ||||
|             height: 100%; | ||||
|             width: 100%; | ||||
|             padding: 40px; | ||||
|             box-sizing: border-box; | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             gap: 24px; | ||||
|           } | ||||
|            | ||||
|           .demo-controls { | ||||
|             display: flex; | ||||
|             flex-wrap: wrap; | ||||
|             gap: 12px; | ||||
|           } | ||||
|            | ||||
|           .demo-controls dees-button { | ||||
|             flex-shrink: 0; | ||||
|           } | ||||
|            | ||||
|           .grid-container-wrapper { | ||||
|             flex: 1; | ||||
|             min-height: 600px; | ||||
|             position: relative; | ||||
|           } | ||||
|            | ||||
|           .info { | ||||
|             color: ${cssManager.bdTheme('#71717a', '#71717a')}; | ||||
|             font-size: 12px; | ||||
|             font-family: 'Geist Sans', sans-serif; | ||||
|             text-align: center; | ||||
|           } | ||||
|         `} | ||||
|       </style> | ||||
|       <div class="demoBox"> | ||||
|         <div class="demo-controls"> | ||||
|           <dees-button-group label="Animation:"> | ||||
|             <dees-button>Toggle Animation</dees-button> | ||||
|           </dees-button-group> | ||||
|            | ||||
|           <dees-button-group label="Display:"> | ||||
|             <dees-button>Toggle Grid Lines</dees-button> | ||||
|           </dees-button-group> | ||||
|            | ||||
|           <dees-button-group label="Actions:"> | ||||
|             <dees-button>Add Widget</dees-button> | ||||
|             <dees-button>Compact Grid</dees-button> | ||||
|           </dees-button-group> | ||||
|            | ||||
|           <dees-button-group label="Mode:"> | ||||
|             <dees-button>Toggle Edit Mode</dees-button> | ||||
|           </dees-button-group> | ||||
|         </div> | ||||
|          | ||||
|         <div class="grid-container-wrapper"> | ||||
|           <dees-dashboardgrid id="dashboardGrid"></dees-dashboardgrid> | ||||
|         </div> | ||||
|          | ||||
|         <div class="info"> | ||||
|           Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning | ||||
|         </div> | ||||
|       </div> | ||||
|     </dees-demowrapper> | ||||
|   `; | ||||
| }; | ||||
| @@ -1,813 +0,0 @@ | ||||
| import * as plugins from './00plugins.js'; | ||||
| import { | ||||
|   DeesElement, | ||||
|   type TemplateResult, | ||||
|   property, | ||||
|   customElement, | ||||
|   html, | ||||
|   css, | ||||
|   cssManager, | ||||
|   state, | ||||
| } from '@design.estate/dees-element'; | ||||
|  | ||||
| import * as domtools from '@design.estate/dees-domtools'; | ||||
| import './dees-icon.js'; | ||||
| import { demoFunc } from './dees-dashboardgrid.demo.js'; | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     'dees-dashboardgrid': DeesDashboardgrid; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export interface IDashboardWidget { | ||||
|   id: string; | ||||
|   x: number; | ||||
|   y: number; | ||||
|   w: number; | ||||
|   h: number; | ||||
|   minW?: number; | ||||
|   minH?: number; | ||||
|   maxW?: number; | ||||
|   maxH?: number; | ||||
|   content: TemplateResult | string; | ||||
|   title?: string; | ||||
|   icon?: string; | ||||
|   noMove?: boolean; | ||||
|   noResize?: boolean; | ||||
|   locked?: boolean; | ||||
|   autoPosition?: boolean; // Auto-position widget in first available space | ||||
| } | ||||
|  | ||||
| @customElement('dees-dashboardgrid') | ||||
| export class DeesDashboardgrid extends DeesElement { | ||||
|   // STATIC | ||||
|   public static demo = demoFunc; | ||||
|  | ||||
|   // INSTANCE | ||||
|   @property({ type: Array }) | ||||
|   public widgets: IDashboardWidget[] = []; | ||||
|  | ||||
|   @property({ type: Number }) | ||||
|   public cellHeight: number = 80; | ||||
|  | ||||
|   @property({ type: Object }) | ||||
|   public margin: number | { top?: number; right?: number; bottom?: number; left?: number } = 10; | ||||
|  | ||||
|   @property({ type: Number }) | ||||
|   public columns: number = 12; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   public editable: boolean = true; | ||||
|    | ||||
|   @property({ type: Boolean, reflect: true }) | ||||
|   public enableAnimation: boolean = true; | ||||
|    | ||||
|   @property({ type: String }) | ||||
|   public cellHeightUnit: 'px' | 'em' | 'rem' | 'auto' = 'px'; | ||||
|    | ||||
|   @property({ type: Boolean }) | ||||
|   public rtl: boolean = false; // Right-to-left support | ||||
|    | ||||
|   @property({ type: Boolean }) | ||||
|   public showGridLines: boolean = false; | ||||
|  | ||||
|   @state() | ||||
|   private draggedWidget: IDashboardWidget | null = null; | ||||
|  | ||||
|   @state() | ||||
|   private draggedElement: HTMLElement | null = null; | ||||
|  | ||||
|   @state() | ||||
|   private dragOffsetX: number = 0; | ||||
|  | ||||
|   @state() | ||||
|   private dragOffsetY: number = 0; | ||||
|    | ||||
|   @state() | ||||
|   private dragMouseX: number = 0; | ||||
|    | ||||
|   @state() | ||||
|   private dragMouseY: number = 0; | ||||
|    | ||||
|   @state() | ||||
|   private placeholderPosition: { x: number; y: number } | null = null; | ||||
|  | ||||
|   @state() | ||||
|   private resizingWidget: IDashboardWidget | null = null; | ||||
|  | ||||
|   @state() | ||||
|   private resizeStartW: number = 0; | ||||
|  | ||||
|   @state() | ||||
|   private resizeStartH: number = 0; | ||||
|  | ||||
|   @state() | ||||
|   private resizeStartX: number = 0; | ||||
|  | ||||
|   @state() | ||||
|   private resizeStartY: number = 0; | ||||
|  | ||||
|   public static styles = [ | ||||
|     cssManager.defaultStyles, | ||||
|     css` | ||||
|       :host { | ||||
|         display: block; | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|         position: relative; | ||||
|       } | ||||
|  | ||||
|       .grid-container { | ||||
|         position: relative; | ||||
|         width: 100%; | ||||
|         min-height: 400px; | ||||
|         box-sizing: border-box; | ||||
|       } | ||||
|  | ||||
|       .grid-widget { | ||||
|         position: absolute; | ||||
|         will-change: auto; | ||||
|       } | ||||
|        | ||||
|       :host([enableanimation]) .grid-widget { | ||||
|         transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | ||||
|       } | ||||
|  | ||||
|       .grid-widget.dragging { | ||||
|         z-index: 1000; | ||||
|         transition: none !important; | ||||
|         opacity: 0.8; | ||||
|         cursor: grabbing; | ||||
|         pointer-events: none; | ||||
|         will-change: transform; | ||||
|       } | ||||
|        | ||||
|       .grid-widget.placeholder { | ||||
|         pointer-events: none; | ||||
|         z-index: 1; | ||||
|       } | ||||
|        | ||||
|       .grid-widget.placeholder .widget-content { | ||||
|         background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')}; | ||||
|         border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; | ||||
|         box-shadow: none; | ||||
|       } | ||||
|  | ||||
|       .grid-widget.resizing { | ||||
|         transition: none !important; | ||||
|       } | ||||
|  | ||||
|       .widget-content { | ||||
|         position: absolute; | ||||
|         left: 0; | ||||
|         right: 0; | ||||
|         top: 0; | ||||
|         bottom: 0; | ||||
|         overflow: hidden; | ||||
|         background: ${cssManager.bdTheme('#ffffff', '#09090b')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; | ||||
|         border-radius: 8px; | ||||
|         box-shadow: ${cssManager.bdTheme( | ||||
|           '0 1px 3px rgba(0, 0, 0, 0.1)', | ||||
|           '0 1px 3px rgba(0, 0, 0, 0.3)' | ||||
|         )}; | ||||
|         transition: box-shadow 0.2s ease; | ||||
|       } | ||||
|  | ||||
|       .grid-widget:hover .widget-content { | ||||
|         box-shadow: ${cssManager.bdTheme( | ||||
|           '0 4px 12px rgba(0, 0, 0, 0.15)', | ||||
|           '0 4px 12px rgba(0, 0, 0, 0.4)' | ||||
|         )}; | ||||
|       } | ||||
|  | ||||
|       .grid-widget.dragging .widget-content { | ||||
|         box-shadow: ${cssManager.bdTheme( | ||||
|           '0 16px 48px rgba(0, 0, 0, 0.25)', | ||||
|           '0 16px 48px rgba(0, 0, 0, 0.6)' | ||||
|         )}; | ||||
|         transform: scale(1.05); | ||||
|       } | ||||
|  | ||||
|       .widget-header { | ||||
|         padding: 12px 16px; | ||||
|         border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 8px; | ||||
|         font-size: 14px; | ||||
|         font-weight: 600; | ||||
|         color: ${cssManager.bdTheme('#09090b', '#fafafa')}; | ||||
|         background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')}; | ||||
|         cursor: grab; | ||||
|         user-select: none; | ||||
|       } | ||||
|        | ||||
|       .widget-header:hover { | ||||
|         background: ${cssManager.bdTheme('#f4f4f5', '#18181b')}; | ||||
|       } | ||||
|        | ||||
|       .widget-header:active { | ||||
|         cursor: grabbing; | ||||
|       } | ||||
|  | ||||
|       .widget-header.locked { | ||||
|         cursor: default; | ||||
|       } | ||||
|        | ||||
|       .widget-header.locked:hover { | ||||
|         background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')}; | ||||
|       } | ||||
|  | ||||
|       .widget-header dees-icon { | ||||
|         font-size: 16px; | ||||
|         color: ${cssManager.bdTheme('#71717a', '#71717a')}; | ||||
|       } | ||||
|  | ||||
|       .widget-body { | ||||
|         position: absolute; | ||||
|         top: 0; | ||||
|         left: 0; | ||||
|         right: 0; | ||||
|         bottom: 0; | ||||
|         overflow: auto; | ||||
|         color: ${cssManager.bdTheme('#09090b', '#fafafa')}; | ||||
|       } | ||||
|  | ||||
|       .widget-body.has-header { | ||||
|         top: 45px; | ||||
|       } | ||||
|  | ||||
|       /* Resize handles */ | ||||
|       .resize-handle { | ||||
|         position: absolute; | ||||
|         background: transparent; | ||||
|         z-index: 10; | ||||
|       } | ||||
|  | ||||
|       .resize-handle:hover { | ||||
|         background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; | ||||
|         opacity: 0.3; | ||||
|       } | ||||
|  | ||||
|       .resize-handle-e { | ||||
|         cursor: ew-resize; | ||||
|         width: 12px; | ||||
|         right: -6px; | ||||
|         top: 10%; | ||||
|         height: 80%; | ||||
|       } | ||||
|  | ||||
|       .resize-handle-s { | ||||
|         cursor: ns-resize; | ||||
|         height: 12px; | ||||
|         width: 80%; | ||||
|         bottom: -6px; | ||||
|         left: 10%; | ||||
|       } | ||||
|  | ||||
|       .resize-handle-se { | ||||
|         cursor: se-resize; | ||||
|         width: 20px; | ||||
|         height: 20px; | ||||
|         right: -2px; | ||||
|         bottom: -2px; | ||||
|         opacity: 0; | ||||
|         transition: opacity 0.2s ease; | ||||
|       } | ||||
|        | ||||
|       .resize-handle-se::after { | ||||
|         content: ''; | ||||
|         position: absolute; | ||||
|         right: 4px; | ||||
|         bottom: 4px; | ||||
|         width: 6px; | ||||
|         height: 6px; | ||||
|         border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')}; | ||||
|         border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')}; | ||||
|       } | ||||
|  | ||||
|       .grid-widget:hover .resize-handle-se { | ||||
|         opacity: 0.7; | ||||
|       } | ||||
|  | ||||
|       .resize-handle-se:hover { | ||||
|         opacity: 1 !important; | ||||
|       } | ||||
|        | ||||
|       .resize-handle-se:hover::after { | ||||
|         border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; | ||||
|       } | ||||
|  | ||||
|       /* Placeholder */ | ||||
|       .grid-placeholder { | ||||
|         position: absolute; | ||||
|         background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; | ||||
|         opacity: 0.1; | ||||
|         border-radius: 8px; | ||||
|         border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; | ||||
|         transition: all 0.2s ease; | ||||
|         pointer-events: none; | ||||
|       } | ||||
|  | ||||
|       /* Empty state */ | ||||
|       .empty-state { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         height: 400px; | ||||
|         color: ${cssManager.bdTheme('#71717a', '#71717a')}; | ||||
|         text-align: center; | ||||
|         padding: 32px; | ||||
|       } | ||||
|  | ||||
|       .empty-state dees-icon { | ||||
|         font-size: 48px; | ||||
|         margin-bottom: 16px; | ||||
|         opacity: 0.5; | ||||
|       } | ||||
|        | ||||
|       /* Grid lines */ | ||||
|       .grid-lines { | ||||
|         position: absolute; | ||||
|         top: 0; | ||||
|         left: 0; | ||||
|         right: 0; | ||||
|         bottom: 0; | ||||
|         pointer-events: none; | ||||
|         z-index: -1; | ||||
|       } | ||||
|        | ||||
|       .grid-line-vertical { | ||||
|         position: absolute; | ||||
|         top: 0; | ||||
|         bottom: 0; | ||||
|         width: 1px; | ||||
|         background: ${cssManager.bdTheme('#e5e7eb', '#27272a')}; | ||||
|         opacity: 0.3; | ||||
|       } | ||||
|        | ||||
|       .grid-line-horizontal { | ||||
|         position: absolute; | ||||
|         left: 0; | ||||
|         right: 0; | ||||
|         height: 1px; | ||||
|         background: ${cssManager.bdTheme('#e5e7eb', '#27272a')}; | ||||
|         opacity: 0.3; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|  | ||||
|   public render(): TemplateResult { | ||||
|     if (this.widgets.length === 0) { | ||||
|       return html` | ||||
|         <div class="empty-state"> | ||||
|           <dees-icon .icon=${'lucide:layoutGrid'}></dees-icon> | ||||
|           <div>No widgets configured</div> | ||||
|           <div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div> | ||||
|         </div> | ||||
|       `; | ||||
|     } | ||||
|  | ||||
|     const margins = this.getMargins(); | ||||
|     const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 4); | ||||
|     const cellHeightValue = this.getCellHeight(); | ||||
|     const gridHeight = maxY * cellHeightValue + (maxY + 1) * margins.vertical; | ||||
|  | ||||
|     return html` | ||||
|       <div class="grid-container" style="height: ${gridHeight}px;"> | ||||
|         ${this.showGridLines ? this.renderGridLines(gridHeight) : ''} | ||||
|         ${this.widgets.map(widget => this.renderWidget(widget))} | ||||
|         ${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private renderGridLines(gridHeight: number): TemplateResult { | ||||
|     const margins = this.getMargins(); | ||||
|     const cellHeightValue = this.getCellHeight(); | ||||
|      | ||||
|     // Convert margin to percentage for consistent calculation | ||||
|     const containerWidth = this.getBoundingClientRect().width; | ||||
|     const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100; | ||||
|      | ||||
|     const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns; | ||||
|      | ||||
|     const verticalLines = []; | ||||
|     const horizontalLines = []; | ||||
|      | ||||
|     // Vertical lines | ||||
|     for (let i = 0; i <= this.columns; i++) { | ||||
|       const left = i * cellWidth + i * marginHorizontalPercent; | ||||
|       verticalLines.push(html` | ||||
|         <div class="grid-line-vertical" style="left: ${left}%;"></div> | ||||
|       `); | ||||
|     } | ||||
|      | ||||
|     // Horizontal lines | ||||
|     const numHorizontalLines = Math.ceil(gridHeight / (cellHeightValue + margins.vertical)); | ||||
|     for (let i = 0; i <= numHorizontalLines; i++) { | ||||
|       const top = i * cellHeightValue + i * margins.vertical; | ||||
|       horizontalLines.push(html` | ||||
|         <div class="grid-line-horizontal" style="top: ${top}px;"></div> | ||||
|       `); | ||||
|     } | ||||
|      | ||||
|     return html` | ||||
|       <div class="grid-lines"> | ||||
|         ${verticalLines} | ||||
|         ${horizontalLines} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private renderWidget(widget: IDashboardWidget): TemplateResult { | ||||
|     const isDragging = this.draggedWidget?.id === widget.id; | ||||
|     const isResizing = this.resizingWidget?.id === widget.id; | ||||
|     const isLocked = widget.locked || !this.editable; | ||||
|  | ||||
|     const margins = this.getMargins(); | ||||
|     const cellHeightValue = this.getCellHeight(); | ||||
|      | ||||
|     // Convert margin to percentage of container width for consistent calculation | ||||
|     const containerWidth = this.getBoundingClientRect().width; | ||||
|     const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100; | ||||
|      | ||||
|     const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns; | ||||
|      | ||||
|     const left = widget.x * cellWidth + (widget.x + 1) * marginHorizontalPercent; | ||||
|     const top = widget.y * cellHeightValue + (widget.y + 1) * margins.vertical; | ||||
|     const width = widget.w * cellWidth + (widget.w - 1) * marginHorizontalPercent; | ||||
|     const height = widget.h * cellHeightValue + (widget.h - 1) * margins.vertical; | ||||
|      | ||||
|     // Apply transform when dragging for smooth movement | ||||
|     let transform = ''; | ||||
|     if (isDragging && this.draggedElement) { | ||||
|       const containerRect = this.getBoundingClientRect(); | ||||
|       const translateX = this.dragMouseX - containerRect.left - this.dragOffsetX - (left / 100 * containerRect.width); | ||||
|       const translateY = this.dragMouseY - containerRect.top - this.dragOffsetY - top; | ||||
|       transform = `transform: translate(${translateX}px, ${translateY}px);`; | ||||
|     } | ||||
|  | ||||
|     return html` | ||||
|       <div  | ||||
|         class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}" | ||||
|         style=" | ||||
|           ${this.rtl ? 'right' : 'left'}: ${left}%; | ||||
|           top: ${top}px; | ||||
|           width: ${width}%; | ||||
|           height: ${height}px; | ||||
|           ${transform} | ||||
|         " | ||||
|         data-widget-id="${widget.id}" | ||||
|       > | ||||
|         <div class="widget-content"> | ||||
|           ${widget.title ? html` | ||||
|             <div  | ||||
|               class="widget-header ${isLocked ? 'locked' : ''}" | ||||
|               @mousedown=${!isLocked && !widget.noMove ? (e: MouseEvent) => this.startDrag(e, widget) : null} | ||||
|             > | ||||
|               ${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : ''} | ||||
|               ${widget.title} | ||||
|             </div> | ||||
|           ` : ''} | ||||
|           <div class="widget-body ${widget.title ? 'has-header' : ''}"> | ||||
|             ${widget.content} | ||||
|           </div> | ||||
|           ${!isLocked && !widget.noResize ? html` | ||||
|             <div class="resize-handle resize-handle-e" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'e')}></div> | ||||
|             <div class="resize-handle resize-handle-s" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 's')}></div> | ||||
|             <div class="resize-handle resize-handle-se" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'se')}></div> | ||||
|           ` : ''} | ||||
|         </div> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|    | ||||
|   private renderPlaceholder(): TemplateResult { | ||||
|     if (!this.placeholderPosition || !this.draggedWidget) return html``; | ||||
|      | ||||
|     const margins = this.getMargins(); | ||||
|     const cellHeightValue = this.getCellHeight(); | ||||
|      | ||||
|     // Convert margin to percentage of container width for consistent calculation | ||||
|     const containerWidth = this.getBoundingClientRect().width; | ||||
|     const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100; | ||||
|      | ||||
|     const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns; | ||||
|      | ||||
|     const left = this.placeholderPosition.x * cellWidth + (this.placeholderPosition.x + 1) * marginHorizontalPercent; | ||||
|     const top = this.placeholderPosition.y * cellHeightValue + (this.placeholderPosition.y + 1) * margins.vertical; | ||||
|     const width = this.draggedWidget.w * cellWidth + (this.draggedWidget.w - 1) * marginHorizontalPercent; | ||||
|     const height = this.draggedWidget.h * cellHeightValue + (this.draggedWidget.h - 1) * margins.vertical; | ||||
|      | ||||
|     return html` | ||||
|       <div  | ||||
|         class="grid-widget placeholder" | ||||
|         style=" | ||||
|           ${this.rtl ? 'right' : 'left'}: ${left}%; | ||||
|           top: ${top}px; | ||||
|           width: ${width}%; | ||||
|           height: ${height}px; | ||||
|         " | ||||
|       > | ||||
|         <div class="widget-content"></div> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private startDrag(e: MouseEvent, widget: IDashboardWidget) { | ||||
|     e.preventDefault(); | ||||
|     this.draggedWidget = widget; | ||||
|     this.draggedElement = (e.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement; | ||||
|      | ||||
|     const rect = this.draggedElement.getBoundingClientRect(); | ||||
|      | ||||
|     this.dragOffsetX = e.clientX - rect.left; | ||||
|     this.dragOffsetY = e.clientY - rect.top; | ||||
|      | ||||
|     // Initialize mouse position | ||||
|     this.dragMouseX = e.clientX; | ||||
|     this.dragMouseY = e.clientY; | ||||
|      | ||||
|     // Initialize placeholder at current widget position | ||||
|     this.placeholderPosition = { x: widget.x, y: widget.y }; | ||||
|  | ||||
|     document.addEventListener('mousemove', this.handleDrag); | ||||
|     document.addEventListener('mouseup', this.endDrag); | ||||
|      | ||||
|     this.requestUpdate(); | ||||
|   } | ||||
|  | ||||
|   private handleDrag = (e: MouseEvent) => { | ||||
|     if (!this.draggedWidget || !this.draggedElement) return; | ||||
|      | ||||
|     // Update mouse position for smooth dragging | ||||
|     this.dragMouseX = e.clientX; | ||||
|     this.dragMouseY = e.clientY; | ||||
|  | ||||
|     const containerRect = this.getBoundingClientRect(); | ||||
|     const margins = this.getMargins(); | ||||
|     const cellHeightValue = this.getCellHeight(); | ||||
|      | ||||
|     // Get widget position relative to grid container | ||||
|     const mouseX = e.clientX - containerRect.left - this.dragOffsetX; | ||||
|     const mouseY = e.clientY - containerRect.top - this.dragOffsetY; | ||||
|      | ||||
|     // Use pixel calculations for accuracy | ||||
|     const totalWidth = containerRect.width; | ||||
|     const totalMarginWidth = margins.horizontal * (this.columns + 1); | ||||
|     const availableWidth = totalWidth - totalMarginWidth; | ||||
|     const cellWidthPx = availableWidth / this.columns; | ||||
|      | ||||
|     // Calculate grid X position | ||||
|     // Account for the initial margin and then repeating pattern of cell+margin | ||||
|     let gridX = 0; | ||||
|     if (mouseX > margins.horizontal) { | ||||
|       const adjustedX = mouseX - margins.horizontal; | ||||
|       const cellPlusMargin = cellWidthPx + margins.horizontal; | ||||
|       gridX = Math.floor(adjustedX / cellPlusMargin + 0.5); // +0.5 for rounding to nearest | ||||
|     } | ||||
|      | ||||
|     // Calculate grid Y position   | ||||
|     let gridY = 0; | ||||
|     if (mouseY > margins.vertical) { | ||||
|       const adjustedY = mouseY - margins.vertical; | ||||
|       const cellPlusMargin = cellHeightValue + margins.vertical; | ||||
|       gridY = Math.floor(adjustedY / cellPlusMargin + 0.5); // +0.5 for rounding to nearest | ||||
|     } | ||||
|      | ||||
|     const clampedX = Math.max(0, Math.min(gridX, this.columns - this.draggedWidget.w)); | ||||
|     const clampedY = Math.max(0, gridY); | ||||
|      | ||||
|     // Update placeholder position instead of widget position during drag | ||||
|     if (!this.placeholderPosition ||  | ||||
|         clampedX !== this.placeholderPosition.x ||  | ||||
|         clampedY !== this.placeholderPosition.y) { | ||||
|       const collision = this.checkCollision(this.draggedWidget, clampedX, clampedY); | ||||
|       if (!collision) { | ||||
|         this.placeholderPosition = { x: clampedX, y: clampedY }; | ||||
|         this.requestUpdate(); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private endDrag = () => { | ||||
|     // Apply final position from placeholder | ||||
|     if (this.draggedWidget && this.placeholderPosition) { | ||||
|       this.draggedWidget.x = this.placeholderPosition.x; | ||||
|       this.draggedWidget.y = this.placeholderPosition.y; | ||||
|        | ||||
|       this.dispatchEvent(new CustomEvent('widget-move', { | ||||
|         detail: { widget: this.draggedWidget }, | ||||
|         bubbles: true, | ||||
|         composed: true, | ||||
|       })); | ||||
|     } | ||||
|      | ||||
|     // Clear drag state | ||||
|     this.draggedWidget = null; | ||||
|     this.draggedElement = null; | ||||
|     this.placeholderPosition = null; | ||||
|     this.dragMouseX = 0; | ||||
|     this.dragMouseY = 0; | ||||
|      | ||||
|     document.removeEventListener('mousemove', this.handleDrag); | ||||
|     document.removeEventListener('mouseup', this.endDrag); | ||||
|      | ||||
|     this.requestUpdate(); | ||||
|   }; | ||||
|  | ||||
|   private startResize(e: MouseEvent, widget: IDashboardWidget, handle: string) { | ||||
|     e.preventDefault(); | ||||
|     e.stopPropagation(); | ||||
|     this.resizingWidget = widget; | ||||
|     this.resizeStartW = widget.w; | ||||
|     this.resizeStartH = widget.h; | ||||
|     this.resizeStartX = e.clientX; | ||||
|     this.resizeStartY = e.clientY; | ||||
|  | ||||
|     const handleResize = (e: MouseEvent) => { | ||||
|       if (!this.resizingWidget) return; | ||||
|  | ||||
|       const containerRect = this.getBoundingClientRect(); | ||||
|       const margins = this.getMargins(); | ||||
|       const cellHeightValue = this.getCellHeight(); | ||||
|       const cellWidth = (containerRect.width - margins.horizontal * (this.columns + 1)) / this.columns; | ||||
|        | ||||
|       const deltaX = e.clientX - this.resizeStartX; | ||||
|       const deltaY = e.clientY - this.resizeStartY; | ||||
|        | ||||
|       if (handle.includes('e')) { | ||||
|         const newW = Math.round(this.resizeStartW + deltaX / (cellWidth + margins.horizontal)); | ||||
|         const maxW = widget.maxW || (this.columns - this.resizingWidget.x); | ||||
|         this.resizingWidget.w = Math.max(widget.minW || 1, Math.min(newW, maxW)); | ||||
|       } | ||||
|        | ||||
|       if (handle.includes('s')) { | ||||
|         const newH = Math.round(this.resizeStartH + deltaY / (cellHeightValue + margins.vertical)); | ||||
|         const maxH = widget.maxH || Infinity; | ||||
|         this.resizingWidget.h = Math.max(widget.minH || 1, Math.min(newH, maxH)); | ||||
|       } | ||||
|        | ||||
|       this.requestUpdate(); | ||||
|        | ||||
|       this.dispatchEvent(new CustomEvent('widget-resize', { | ||||
|         detail: { widget: this.resizingWidget }, | ||||
|         bubbles: true, | ||||
|         composed: true, | ||||
|       })); | ||||
|     }; | ||||
|  | ||||
|     const endResize = () => { | ||||
|       this.resizingWidget = null; | ||||
|       document.removeEventListener('mousemove', handleResize); | ||||
|       document.removeEventListener('mouseup', endResize); | ||||
|     }; | ||||
|  | ||||
|     document.addEventListener('mousemove', handleResize); | ||||
|     document.addEventListener('mouseup', endResize); | ||||
|   } | ||||
|  | ||||
|  | ||||
|   public removeWidget(widgetId: string) { | ||||
|     this.widgets = this.widgets.filter(w => w.id !== widgetId); | ||||
|   } | ||||
|  | ||||
|   public updateWidget(widgetId: string, updates: Partial<IDashboardWidget>) { | ||||
|     this.widgets = this.widgets.map(w =>  | ||||
|       w.id === widgetId ? { ...w, ...updates } : w | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   public getLayout(): Array<{ id: string; x: number; y: number; w: number; h: number }> { | ||||
|     return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h })); | ||||
|   } | ||||
|  | ||||
|   public setLayout(layout: Array<{ id: string; x: number; y: number; w: number; h: number }>) { | ||||
|     this.widgets = this.widgets.map(widget => { | ||||
|       const layoutItem = layout.find(l => l.id === widget.id); | ||||
|       return layoutItem ? { ...widget, ...layoutItem } : widget; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   public lockGrid() { | ||||
|     this.editable = false; | ||||
|   } | ||||
|  | ||||
|   public unlockGrid() { | ||||
|     this.editable = true; | ||||
|   } | ||||
|  | ||||
|   private getMargins(): { horizontal: number; vertical: number; top: number; right: number; bottom: number; left: number } { | ||||
|     if (typeof this.margin === 'number') { | ||||
|       return { | ||||
|         horizontal: this.margin, | ||||
|         vertical: this.margin, | ||||
|         top: this.margin, | ||||
|         right: this.margin, | ||||
|         bottom: this.margin, | ||||
|         left: this.margin, | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|     const margins = { | ||||
|       top: this.margin.top ?? 10, | ||||
|       right: this.margin.right ?? 10, | ||||
|       bottom: this.margin.bottom ?? 10, | ||||
|       left: this.margin.left ?? 10, | ||||
|     }; | ||||
|      | ||||
|     return { | ||||
|       ...margins, | ||||
|       horizontal: (margins.left + margins.right) / 2, | ||||
|       vertical: (margins.top + margins.bottom) / 2, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   private getCellHeight(): number { | ||||
|     if (this.cellHeightUnit === 'auto') { | ||||
|       // Calculate square cells based on container width | ||||
|       const containerWidth = this.getBoundingClientRect().width; | ||||
|       const margins = this.getMargins(); | ||||
|       const cellWidth = (containerWidth - margins.horizontal * (this.columns + 1)) / this.columns; | ||||
|       return cellWidth; | ||||
|     } | ||||
|      | ||||
|     return this.cellHeight; | ||||
|   } | ||||
|  | ||||
|   private checkCollision(widget: IDashboardWidget, newX: number, newY: number): boolean { | ||||
|     const widgets = this.widgets.filter(w => w.id !== widget.id); | ||||
|      | ||||
|     for (const other of widgets) { | ||||
|       if (newX < other.x + other.w && | ||||
|           newX + widget.w > other.x && | ||||
|           newY < other.y + other.h && | ||||
|           newY + widget.h > other.y) { | ||||
|         return true; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   public addWidget(widget: IDashboardWidget, autoPosition = false) { | ||||
|     if (autoPosition || widget.autoPosition) { | ||||
|       // Find first available position | ||||
|       const position = this.findAvailablePosition(widget.w, widget.h); | ||||
|       widget.x = position.x; | ||||
|       widget.y = position.y; | ||||
|     } | ||||
|      | ||||
|     this.widgets = [...this.widgets, widget]; | ||||
|   } | ||||
|  | ||||
|   private findAvailablePosition(width: number, height: number): { x: number; y: number } { | ||||
|     // Try to find space starting from top-left | ||||
|     for (let y = 0; y < 100; y++) { // Reasonable limit | ||||
|       for (let x = 0; x <= this.columns - width; x++) { | ||||
|         const testWidget = { id: 'test', x, y, w: width, h: height, content: '' } as IDashboardWidget; | ||||
|         if (!this.checkCollision(testWidget, x, y)) { | ||||
|           return { x, y }; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // If no space found, place at bottom | ||||
|     const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 0); | ||||
|     return { x: 0, y: maxY }; | ||||
|   } | ||||
|  | ||||
|   public compact(direction: 'vertical' | 'horizontal' = 'vertical') { | ||||
|     const sortedWidgets = [...this.widgets].sort((a, b) => { | ||||
|       if (direction === 'vertical') { | ||||
|         if (a.y !== b.y) return a.y - b.y; | ||||
|         return a.x - b.x; | ||||
|       } else { | ||||
|         if (a.x !== b.x) return a.x - b.x; | ||||
|         return a.y - b.y; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     for (const widget of sortedWidgets) { | ||||
|       if (widget.locked || widget.noMove) continue; | ||||
|        | ||||
|       if (direction === 'vertical') { | ||||
|         // Move up as far as possible | ||||
|         while (widget.y > 0 && !this.checkCollision(widget, widget.x, widget.y - 1)) { | ||||
|           widget.y--; | ||||
|         } | ||||
|       } else { | ||||
|         // Move left as far as possible | ||||
|         while (widget.x > 0 && !this.checkCollision(widget, widget.x - 1, widget.y)) { | ||||
|           widget.x--; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     this.requestUpdate(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										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; | ||||
| } | ||||
| @@ -8,6 +8,7 @@ import { | ||||
|   cssManager, | ||||
| } from '@design.estate/dees-element'; | ||||
| import * as domtools from '@design.estate/dees-domtools'; | ||||
| import { MONACO_VERSION } from './version.js'; | ||||
| 
 | ||||
| import type * as monaco from 'monaco-editor'; | ||||
| 
 | ||||
| @@ -80,10 +81,11 @@ export class DeesEditor extends DeesElement { | ||||
|   ): Promise<void> { | ||||
|     super.firstUpdated(_changedProperties); | ||||
|     const container = this.shadowRoot.getElementById('container'); | ||||
|     const monacoCdnBase = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}`; | ||||
| 
 | ||||
|     if (!DeesEditor.monacoDeferred) { | ||||
|       DeesEditor.monacoDeferred = domtools.plugins.smartpromise.defer(); | ||||
|       const scriptUrl = `https://cdn.jsdelivr.net/npm/monaco-editor/min/vs/loader.js`; | ||||
|       const scriptUrl = `${monacoCdnBase}/min/vs/loader.js`; | ||||
|       const script = document.createElement('script'); | ||||
|       script.src = scriptUrl; | ||||
|       script.onload = () => { | ||||
| @@ -94,7 +96,7 @@ export class DeesEditor extends DeesElement { | ||||
|     await DeesEditor.monacoDeferred.promise; | ||||
| 
 | ||||
|     (window as any).require.config({ | ||||
|       paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor/min/vs' }, | ||||
|       paths: { vs: `${monacoCdnBase}/min/vs` }, | ||||
|     }); | ||||
|     (window as any).require(['vs/editor/editor.main'], async () => { | ||||
|       const editor = ((window as any).monaco.editor as typeof monaco.editor).create(container, { | ||||
| @@ -109,7 +111,7 @@ export class DeesEditor extends DeesElement { | ||||
|       this.editorDeferred.resolve(editor); | ||||
|     }); | ||||
|     const css = await ( | ||||
|       await fetch('https://cdn.jsdelivr.net/npm/monaco-editor/min/vs/editor/editor.main.css') | ||||
|       await fetch(`${monacoCdnBase}/min/vs/editor/editor.main.css`) | ||||
|     ).text(); | ||||
|     const styleElement = document.createElement('style'); | ||||
|     styleElement.textContent = css; | ||||
							
								
								
									
										2
									
								
								ts_web/elements/dees-editor/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								ts_web/elements/dees-editor/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| export * from './dees-editor.js'; | ||||
| export * from './version.js'; | ||||
							
								
								
									
										2
									
								
								ts_web/elements/dees-editor/version.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								ts_web/elements/dees-editor/version.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| // Auto-generated by scripts/update-monaco-version.cjs | ||||
| export const MONACO_VERSION = '0.52.2'; | ||||
| @@ -57,9 +57,10 @@ export class DeesFormSubmit extends DeesElement { | ||||
|     if (this.disabled) { | ||||
|       return; | ||||
|     } | ||||
|     const parentElement: DeesForm = this.parentElement as DeesForm; | ||||
|     if (parentElement && parentElement.gatherAndDispatch) { | ||||
|       parentElement.gatherAndDispatch(); | ||||
|     // Walk up the DOM tree to find the nearest dees-form element | ||||
|     const parentFormElement = this.closest('dees-form') as DeesForm; | ||||
|     if (parentFormElement && parentFormElement.gatherAndDispatch) { | ||||
|       parentFormElement.gatherAndDispatch(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -9,18 +9,18 @@ import { | ||||
| import * as domtools from '@design.estate/dees-domtools'; | ||||
|  | ||||
| import { DeesInputCheckbox } from './dees-input-checkbox.js'; | ||||
| import { DeesInputDatepicker } from './dees-input-datepicker.js'; | ||||
| import { DeesInputDatepicker } from './dees-input-datepicker/index.js'; | ||||
| import { DeesInputText } from './dees-input-text.js'; | ||||
| import { DeesInputQuantitySelector } from './dees-input-quantityselector.js'; | ||||
| import { DeesInputRadiogroup } from './dees-input-radiogroup.js'; | ||||
| import { DeesInputDropdown } from './dees-input-dropdown.js'; | ||||
| import { DeesInputFileupload } from './dees-input-fileupload.js'; | ||||
| import { DeesInputFileupload } from './dees-input-fileupload/index.js'; | ||||
| import { DeesInputIban } from './dees-input-iban.js'; | ||||
| import { DeesInputMultitoggle } from './dees-input-multitoggle.js'; | ||||
| import { DeesInputPhone } from './dees-input-phone.js'; | ||||
| import { DeesInputTypelist } from './dees-input-typelist.js'; | ||||
| import { DeesFormSubmit } from './dees-form-submit.js'; | ||||
| import { DeesTable } from './dees-table.js'; | ||||
| import { DeesTable } from './dees-table/index.js'; | ||||
| import { demoFunc } from './dees-form.demo.js'; | ||||
|  | ||||
| // Unified set for form input types | ||||
|   | ||||
| @@ -40,6 +40,26 @@ export const demoFunc = () => { | ||||
|   } | ||||
|  | ||||
|   // Define the functions in TS scope instead of script tags | ||||
|   const copyAllIconNames = () => { | ||||
|     // Generate complete list of all icon names with prefixes | ||||
|     const faIconsList = faIcons.map(name => `fa:${name}`); | ||||
|     const lucideIconsListPrefixed = lucideIconsList.map(name => `lucide:${name}`); | ||||
|     const allIcons = [...faIconsList, ...lucideIconsListPrefixed]; | ||||
|     const textToCopy = allIcons.join('\n'); | ||||
|      | ||||
|     navigator.clipboard.writeText(textToCopy).then(() => { | ||||
|       // Show feedback | ||||
|       const currentEvent = window.event as MouseEvent; | ||||
|       const button = currentEvent.currentTarget as HTMLElement; | ||||
|       const originalText = button.textContent; | ||||
|       button.textContent = `✓ Copied ${allIcons.length} icon names!`; | ||||
|        | ||||
|       setTimeout(() => { | ||||
|         button.textContent = originalText; | ||||
|       }, 2000); | ||||
|     }); | ||||
|   }; | ||||
|    | ||||
|   const searchIcons = (event: InputEvent) => { | ||||
|     const searchTerm = (event.target as HTMLInputElement).value.toLowerCase().trim(); | ||||
|     // Get the demo container first, then search within it | ||||
| @@ -111,6 +131,7 @@ export const demoFunc = () => { | ||||
|       width: 100%; | ||||
|       margin-bottom: 20px; | ||||
|       display: flex; | ||||
|       gap: 10px; | ||||
|     } | ||||
|      | ||||
|     #iconSearch { | ||||
| @@ -129,6 +150,27 @@ export const demoFunc = () => { | ||||
|       border-color: #e4002b; | ||||
|     } | ||||
|      | ||||
|     .copy-all-button { | ||||
|       padding: 12px 20px; | ||||
|       font-size: 16px; | ||||
|       border: none; | ||||
|       border-radius: 4px; | ||||
|       background: #e4002b; | ||||
|       color: #fff; | ||||
|       cursor: pointer; | ||||
|       transition: all 0.2s; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
|      | ||||
|     .copy-all-button:hover { | ||||
|       background: #c4001b; | ||||
|       transform: translateY(-1px); | ||||
|     } | ||||
|      | ||||
|     .copy-all-button:active { | ||||
|       transform: translateY(0); | ||||
|     } | ||||
|      | ||||
|     dees-icon { | ||||
|       transition: all 0.2s ease; | ||||
|       color: #ffffff; | ||||
| @@ -239,6 +281,7 @@ export const demoFunc = () => { | ||||
|   <div class="demoContainer"> | ||||
|     <div class="search-container"> | ||||
|       <input type="text" id="iconSearch" placeholder="Search icons..." @input=${searchIcons}> | ||||
|       <button class="copy-all-button" @click=${copyAllIconNames}>📋 Copy All Icon Names</button> | ||||
|     </div> | ||||
|      | ||||
|     <div class="api-note"> | ||||
| @@ -258,7 +301,7 @@ export const demoFunc = () => { | ||||
|             return html` | ||||
|               <div class="iconContainer fa-icon" data-name=${iconName.toLowerCase()} @click=${() => copyIconName(iconName, 'fa')}> | ||||
|                 <dees-icon .icon=${prefixedName as IconWithPrefix} iconSize="24"></dees-icon> | ||||
|                 <div class="iconName">${iconName}</div> | ||||
|                 <div class="iconName">fa:${iconName}</div> | ||||
|                 <span class="copy-tooltip">Click to copy</span> | ||||
|               </div> | ||||
|             `; | ||||
| @@ -279,7 +322,7 @@ export const demoFunc = () => { | ||||
|             return html` | ||||
|               <div class="iconContainer lucide-icon" data-name=${iconName.toLowerCase()} @click=${() => copyIconName(iconName, 'lucide')}> | ||||
|                 <dees-icon .icon=${prefixedName as IconWithPrefix} iconSize="24"></dees-icon> | ||||
|                 <div class="iconName">${iconName}</div> | ||||
|                 <div class="iconName">lucide:${iconName}</div> | ||||
|                 <span class="copy-tooltip">Click to copy</span> | ||||
|               </div> | ||||
|             `; | ||||
|   | ||||
| @@ -28,6 +28,9 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> { | ||||
|   }) | ||||
|   public value: boolean = false; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   public indeterminate: boolean = false; | ||||
|  | ||||
|  | ||||
|   constructor() { | ||||
|     super(); | ||||
| @@ -166,6 +169,14 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> { | ||||
|                     </svg> | ||||
|                   </span> | ||||
|                 ` | ||||
|               : this.indeterminate | ||||
|                 ? html` | ||||
|                     <span class="checkmark"> | ||||
|                       <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|                         <path d="M5 12H19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/> | ||||
|                       </svg> | ||||
|                     </span> | ||||
|                   ` | ||||
|                 : html``} | ||||
|           </div> | ||||
|           <div class="label-container"> | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										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 | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| import { html, css } from '@design.estate/dees-element'; | ||||
| import '@design.estate/dees-wcctools/demotools'; | ||||
| import './dees-panel.js'; | ||||
| import './dees-input-datepicker.js'; | ||||
| import type { DeesInputDatepicker } from './dees-input-datepicker.js'; | ||||
| import '../dees-panel.js'; | ||||
| import './component.js'; | ||||
| import type { DeesInputDatepicker } from './component.js'; | ||||
| 
 | ||||
| export const demoFunc = () => html` | ||||
|   <style> | ||||
							
								
								
									
										4
									
								
								ts_web/elements/dees-input-datepicker/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								ts_web/elements/dees-input-datepicker/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| export * from './component.js'; | ||||
| export { datepickerStyles } from './styles.js'; | ||||
| export { renderDatepicker } from './template.js'; | ||||
| export type { IDateEvent } from './types.js'; | ||||
							
								
								
									
										514
									
								
								ts_web/elements/dees-input-datepicker/styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										514
									
								
								ts_web/elements/dees-input-datepicker/styles.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,514 @@ | ||||
| import { css, cssManager } from '@design.estate/dees-element'; | ||||
| import { DeesInputBase } from '../dees-input-base.js'; | ||||
|  | ||||
| export const datepickerStyles = [ | ||||
|     ...DeesInputBase.baseStyles, | ||||
|     cssManager.defaultStyles, | ||||
|     css` | ||||
|       :host { | ||||
|         display: block; | ||||
|         position: relative; | ||||
|       } | ||||
|  | ||||
|       .input-container { | ||||
|         position: relative; | ||||
|         width: 100%; | ||||
|       } | ||||
|  | ||||
|       .date-input { | ||||
|         width: 100%; | ||||
|         height: 40px; | ||||
|         padding: 0 12px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         border-radius: 6px; | ||||
|         font-size: 14px; | ||||
|         line-height: 1.5; | ||||
|         color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; | ||||
|         cursor: pointer; | ||||
|         transition: all 0.2s ease; | ||||
|         outline: none; | ||||
|         font-family: inherit; | ||||
|       } | ||||
|  | ||||
|       .date-input::placeholder { | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .date-input:hover:not(:disabled) { | ||||
|         border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .date-input:focus, | ||||
|       .date-input.open { | ||||
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; | ||||
|         outline: 2px solid transparent; | ||||
|         outline-offset: 2px; | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}, | ||||
|                     0 0 0 4px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')}; | ||||
|       } | ||||
|  | ||||
|       .date-input:disabled { | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         cursor: not-allowed; | ||||
|         opacity: 0.5; | ||||
|       } | ||||
|  | ||||
|       /* Icon container using flexbox for better positioning */ | ||||
|       .icon-container { | ||||
|         position: absolute; | ||||
|         right: 0; | ||||
|         top: 0; | ||||
|         bottom: 0; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 4px; | ||||
|         padding: 0 12px; | ||||
|         pointer-events: none; | ||||
|       } | ||||
|  | ||||
|       .icon-container > * { | ||||
|         pointer-events: auto; | ||||
|       } | ||||
|  | ||||
|       .calendar-icon { | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         pointer-events: none; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|       } | ||||
|  | ||||
|       .clear-button { | ||||
|         width: 20px; | ||||
|         height: 20px; | ||||
|         border: none; | ||||
|         background: transparent; | ||||
|         cursor: pointer; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         border-radius: 4px; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         transition: opacity 0.2s ease, background-color 0.2s ease; | ||||
|         padding: 0; | ||||
|         flex-shrink: 0; | ||||
|       } | ||||
|  | ||||
|       .clear-button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; | ||||
|       } | ||||
|  | ||||
|       .clear-button:disabled { | ||||
|         display: none; | ||||
|       } | ||||
|  | ||||
|       /* Calendar Popup Styles */ | ||||
|       .calendar-popup { | ||||
|         will-change: transform, opacity; | ||||
|         pointer-events: none; | ||||
|         transition: all 0.2s ease; | ||||
|         opacity: 0; | ||||
|         transform: translateY(-4px); | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         box-shadow: ${cssManager.bdTheme( | ||||
|           '0 10px 15px -3px hsl(0 0% 0% / 0.1), 0 4px 6px -4px hsl(0 0% 0% / 0.1)', | ||||
|           '0 10px 15px -3px hsl(0 0% 0% / 0.2), 0 4px 6px -4px hsl(0 0% 0% / 0.2)' | ||||
|         )}; | ||||
|         border-radius: 6px; | ||||
|         padding: 12px; | ||||
|         position: absolute; | ||||
|         user-select: none; | ||||
|         margin-top: 4px; | ||||
|         z-index: 50; | ||||
|         left: 0; | ||||
|         min-width: 280px; | ||||
|       } | ||||
|  | ||||
|       .calendar-popup.top { | ||||
|         bottom: calc(100% + 4px); | ||||
|         top: auto; | ||||
|         margin-top: 0; | ||||
|         margin-bottom: 4px; | ||||
|         transform: translateY(4px); | ||||
|       } | ||||
|  | ||||
|       .calendar-popup.bottom { | ||||
|         top: 100%; | ||||
|       } | ||||
|  | ||||
|       .calendar-popup.show { | ||||
|         pointer-events: all; | ||||
|         transform: translateY(0); | ||||
|         opacity: 1; | ||||
|       } | ||||
|  | ||||
|       /* Calendar Header */ | ||||
|       .calendar-header { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: space-between; | ||||
|         margin-bottom: 16px; | ||||
|         gap: 8px; | ||||
|       } | ||||
|  | ||||
|       .month-year-display { | ||||
|         font-weight: 500; | ||||
|         font-size: 14px; | ||||
|         color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; | ||||
|         flex: 1; | ||||
|         text-align: center; | ||||
|       } | ||||
|  | ||||
|       .nav-button { | ||||
|         width: 28px; | ||||
|         height: 28px; | ||||
|         border: none; | ||||
|         background: transparent; | ||||
|         cursor: pointer; | ||||
|         border-radius: 6px; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         transition: all 0.2s ease; | ||||
|       } | ||||
|  | ||||
|       .nav-button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; | ||||
|       } | ||||
|  | ||||
|       .nav-button:active { | ||||
|         background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|       } | ||||
|  | ||||
|       /* Weekday headers */ | ||||
|       .weekdays { | ||||
|         display: grid; | ||||
|         grid-template-columns: repeat(7, 1fr); | ||||
|         gap: 0; | ||||
|         margin-bottom: 4px; | ||||
|       } | ||||
|  | ||||
|       .weekday { | ||||
|         text-align: center; | ||||
|         font-size: 12px; | ||||
|         font-weight: 400; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         padding: 0 0 8px 0; | ||||
|       } | ||||
|  | ||||
|       /* Days grid */ | ||||
|       .days-grid { | ||||
|         display: grid; | ||||
|         grid-template-columns: repeat(7, 1fr); | ||||
|         gap: 2px; | ||||
|       } | ||||
|  | ||||
|       .day { | ||||
|         aspect-ratio: 1; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         cursor: pointer; | ||||
|         border-radius: 6px; | ||||
|         font-size: 14px; | ||||
|         transition: all 0.2s ease; | ||||
|         color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; | ||||
|         border: none; | ||||
|         width: 36px; | ||||
|         height: 36px; | ||||
|         background: transparent; | ||||
|       } | ||||
|  | ||||
|       .day:hover:not(.disabled) { | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .day.other-month { | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         opacity: 0.5; | ||||
|       } | ||||
|  | ||||
|       .day.today { | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|         font-weight: 500; | ||||
|       } | ||||
|  | ||||
|       .day.selected { | ||||
|         background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')}; | ||||
|         font-weight: 500; | ||||
|       } | ||||
|  | ||||
|       .day.disabled { | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         cursor: not-allowed; | ||||
|         opacity: 0.3; | ||||
|       } | ||||
|  | ||||
|       /* Event indicators */ | ||||
|       .day.has-event { | ||||
|         position: relative; | ||||
|       } | ||||
|  | ||||
|       .event-indicator { | ||||
|         position: absolute; | ||||
|         bottom: 4px; | ||||
|         left: 50%; | ||||
|         transform: translateX(-50%); | ||||
|         display: flex; | ||||
|         gap: 2px; | ||||
|         justify-content: center; | ||||
|       } | ||||
|  | ||||
|       .event-dot { | ||||
|         width: 4px; | ||||
|         height: 4px; | ||||
|         border-radius: 50%; | ||||
|         background: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .event-dot.info { | ||||
|         background: ${cssManager.bdTheme('hsl(211 70% 52%)', 'hsl(211 70% 62%)')}; | ||||
|       } | ||||
|  | ||||
|       .event-dot.warning { | ||||
|         background: ${cssManager.bdTheme('hsl(45 90% 45%)', 'hsl(45 90% 55%)')}; | ||||
|       } | ||||
|  | ||||
|       .event-dot.success { | ||||
|         background: ${cssManager.bdTheme('hsl(142 69% 45%)', 'hsl(142 69% 55%)')}; | ||||
|       } | ||||
|  | ||||
|       .event-dot.error { | ||||
|         background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')}; | ||||
|       } | ||||
|  | ||||
|       .event-count { | ||||
|         position: absolute; | ||||
|         top: 2px; | ||||
|         right: 2px; | ||||
|         min-width: 16px; | ||||
|         height: 16px; | ||||
|         padding: 0 4px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')}; | ||||
|         color: white; | ||||
|         border-radius: 8px; | ||||
|         font-size: 10px; | ||||
|         font-weight: 600; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         line-height: 1; | ||||
|       } | ||||
|  | ||||
|       /* Tooltip for event details */ | ||||
|       .event-tooltip { | ||||
|         position: absolute; | ||||
|         bottom: calc(100% + 8px); | ||||
|         left: 50%; | ||||
|         transform: translateX(-50%); | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 0%)')}; | ||||
|         padding: 8px 12px; | ||||
|         border-radius: 6px; | ||||
|         font-size: 12px; | ||||
|         white-space: nowrap; | ||||
|         pointer-events: none; | ||||
|         opacity: 0; | ||||
|         transition: opacity 0.2s ease; | ||||
|         z-index: 10; | ||||
|         box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); | ||||
|       } | ||||
|  | ||||
|       .event-tooltip::after { | ||||
|         content: ''; | ||||
|         position: absolute; | ||||
|         top: 100%; | ||||
|         left: 50%; | ||||
|         transform: translateX(-50%); | ||||
|         border: 4px solid transparent; | ||||
|         border-top-color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')}; | ||||
|       } | ||||
|  | ||||
|       .day.has-event:hover .event-tooltip { | ||||
|         opacity: 1; | ||||
|       } | ||||
|  | ||||
|       /* Time selector */ | ||||
|       .time-selector { | ||||
|         margin-top: 12px; | ||||
|         padding-top: 12px; | ||||
|         border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|       } | ||||
|  | ||||
|       .time-selector-title { | ||||
|         font-size: 12px; | ||||
|         font-weight: 500; | ||||
|         margin-bottom: 8px; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .time-inputs { | ||||
|         display: flex; | ||||
|         gap: 8px; | ||||
|         align-items: center; | ||||
|       } | ||||
|  | ||||
|       .time-input { | ||||
|         width: 65px; | ||||
|         height: 36px; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         border-radius: 6px; | ||||
|         padding: 0 12px; | ||||
|         font-size: 14px; | ||||
|         text-align: center; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; | ||||
|         transition: all 0.2s ease; | ||||
|       } | ||||
|  | ||||
|       .time-input:hover { | ||||
|         border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .time-input:focus { | ||||
|         outline: none; | ||||
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')}; | ||||
|       } | ||||
|  | ||||
|       .time-separator { | ||||
|         font-size: 14px; | ||||
|         font-weight: 500; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .am-pm-selector { | ||||
|         display: flex; | ||||
|         gap: 4px; | ||||
|         margin-left: 8px; | ||||
|       } | ||||
|  | ||||
|       .am-pm-button { | ||||
|         padding: 6px 12px; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; | ||||
|         border-radius: 6px; | ||||
|         font-size: 12px; | ||||
|         font-weight: 500; | ||||
|         cursor: pointer; | ||||
|         transition: all 0.2s ease; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .am-pm-button.selected { | ||||
|         background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; | ||||
|       } | ||||
|  | ||||
|       .am-pm-button:hover:not(.selected) { | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|       } | ||||
|  | ||||
|       /* Action buttons */ | ||||
|       .calendar-actions { | ||||
|         display: flex; | ||||
|         gap: 8px; | ||||
|         margin-top: 12px; | ||||
|         padding-top: 12px; | ||||
|         border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button { | ||||
|         flex: 1; | ||||
|         height: 36px; | ||||
|         border: none; | ||||
|         border-radius: 6px; | ||||
|         font-size: 14px; | ||||
|         font-weight: 500; | ||||
|         cursor: pointer; | ||||
|         transition: all 0.2s ease; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|       } | ||||
|  | ||||
|       .today-button { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; | ||||
|       } | ||||
|  | ||||
|       .today-button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|       } | ||||
|  | ||||
|       .today-button:active { | ||||
|         background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|       } | ||||
|  | ||||
|       .clear-button { | ||||
|         background: transparent; | ||||
|         border: 1px solid transparent; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .clear-button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; | ||||
|       } | ||||
|  | ||||
|       .clear-button:active { | ||||
|         background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.2)', 'hsl(0 62.8% 30.6% / 0.2)')}; | ||||
|       } | ||||
|  | ||||
|       /* Timezone selector */ | ||||
|       .timezone-selector { | ||||
|         margin-top: 12px; | ||||
|         padding-top: 12px; | ||||
|         border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|       } | ||||
|  | ||||
|       .timezone-selector-title { | ||||
|         font-size: 12px; | ||||
|         font-weight: 500; | ||||
|         margin-bottom: 8px; | ||||
|         color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .timezone-select { | ||||
|         width: 100%; | ||||
|         height: 36px; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         border-radius: 6px; | ||||
|         padding: 0 12px; | ||||
|         font-size: 14px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; | ||||
|         cursor: pointer; | ||||
|         transition: all 0.2s ease; | ||||
|       } | ||||
|  | ||||
|       .timezone-select:hover { | ||||
|         border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; | ||||
|         background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .timezone-select:focus { | ||||
|         outline: none; | ||||
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')}; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
							
								
								
									
										179
									
								
								ts_web/elements/dees-input-datepicker/template.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								ts_web/elements/dees-input-datepicker/template.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| import { html, type TemplateResult } from '@design.estate/dees-element'; | ||||
| import type { DeesInputDatepicker } from './component.js'; | ||||
|  | ||||
| export const renderDatepicker = (component: DeesInputDatepicker): TemplateResult => { | ||||
|       const monthNames = [ | ||||
|         'January', 'February', 'March', 'April', 'May', 'June', | ||||
|         'July', 'August', 'September', 'October', 'November', 'December' | ||||
|       ]; | ||||
|  | ||||
|       const weekDays = component.weekStartsOn === 1  | ||||
|         ? ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] | ||||
|         : ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; | ||||
|  | ||||
|       const days = component.getDaysInMonth(); | ||||
|       const isAM = component.selectedHour < 12; | ||||
|       const timezones = component.getTimezones(); | ||||
|  | ||||
|       return html` | ||||
|         <div class="input-wrapper"> | ||||
|           <dees-label .label=${component.label} .description=${component.description} .required=${component.required}></dees-label> | ||||
|           <div class="input-container"> | ||||
|             <input | ||||
|               type="text" | ||||
|               class="date-input ${component.isOpened ? 'open' : ''}" | ||||
|               .value=${component.formatDate(component.value)} | ||||
|               .placeholder=${component.placeholder} | ||||
|               ?disabled=${component.disabled} | ||||
|               @click=${component.toggleCalendar} | ||||
|               @keydown=${component.handleKeydown} | ||||
|               @input=${component.handleManualInput} | ||||
|               @blur=${component.handleInputBlur} | ||||
|               style="padding-right: ${component.value ? '64px' : '40px'}" | ||||
|             /> | ||||
|             <div class="icon-container"> | ||||
|               ${component.value && !component.disabled ? html` | ||||
|                 <button class="clear-button" @click=${component.clearValue} title="Clear"> | ||||
|                   <dees-icon icon="lucide:x" iconSize="14"></dees-icon> | ||||
|                 </button> | ||||
|               ` : ''} | ||||
|               <dees-icon class="calendar-icon" icon="lucide:calendar" iconSize="16"></dees-icon> | ||||
|             </div> | ||||
|            | ||||
|             <!-- Calendar Popup --> | ||||
|             <div class="calendar-popup ${component.isOpened ? 'show' : ''} ${component.opensToTop ? 'top' : 'bottom'}"> | ||||
|               <!-- Month/Year Navigation --> | ||||
|               <div class="calendar-header"> | ||||
|                 <button class="nav-button" @click=${component.previousMonth}> | ||||
|                   <dees-icon icon="lucide:chevronLeft" iconSize="16"></dees-icon> | ||||
|                 </button> | ||||
|                 <div class="month-year-display"> | ||||
|                   ${monthNames[component.viewDate.getMonth()]} ${component.viewDate.getFullYear()} | ||||
|                 </div> | ||||
|                 <button class="nav-button" @click=${component.nextMonth}> | ||||
|                   <dees-icon icon="lucide:chevronRight" iconSize="16"></dees-icon> | ||||
|                 </button> | ||||
|               </div> | ||||
|  | ||||
|               <!-- Weekday Headers --> | ||||
|               <div class="weekdays"> | ||||
|                 ${weekDays.map(day => html`<div class="weekday">${day}</div>`)} | ||||
|               </div> | ||||
|  | ||||
|               <!-- Days Grid --> | ||||
|               <div class="days-grid"> | ||||
|                 ${days.map(day => { | ||||
|                   const isToday = component.isToday(day); | ||||
|                   const isSelected = component.isSelected(day); | ||||
|                   const isOtherMonth = day.getMonth() !== component.viewDate.getMonth(); | ||||
|                   const isDisabled = component.isDisabled(day); | ||||
|                   const dayEvents = component.getEventsForDate(day); | ||||
|                   const hasEvents = dayEvents.length > 0; | ||||
|                   const totalEventCount = dayEvents.reduce((sum, event) => sum + (event.count || 1), 0); | ||||
|  | ||||
|                   return html` | ||||
|                     <div  | ||||
|                       class="day ${isOtherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''} ${hasEvents ? 'has-event' : ''}" | ||||
|                       @click=${() => !isDisabled && component.selectDate(day)} | ||||
|                     > | ||||
|                       ${day.getDate()} | ||||
|                       ${hasEvents ? html` | ||||
|                         ${totalEventCount > 3 ? html` | ||||
|                           <div class="event-count">${totalEventCount}</div> | ||||
|                         ` : html` | ||||
|                           <div class="event-indicator"> | ||||
|                             ${dayEvents.slice(0, 3).map(event => html` | ||||
|                               <div class="event-dot ${event.type || 'info'}"></div> | ||||
|                             `)} | ||||
|                           </div> | ||||
|                         `} | ||||
|                         ${dayEvents[0].title ? html` | ||||
|                           <div class="event-tooltip"> | ||||
|                             ${dayEvents[0].title} | ||||
|                             ${totalEventCount > 1 ? html` (+${totalEventCount - 1} more)` : ''} | ||||
|                           </div> | ||||
|                         ` : ''} | ||||
|                       ` : ''} | ||||
|                     </div> | ||||
|                   `; | ||||
|                 })} | ||||
|               </div> | ||||
|  | ||||
|               <!-- Time Selector --> | ||||
|               ${component.enableTime ? html` | ||||
|                 <div class="time-selector"> | ||||
|                   <div class="time-selector-title">Time</div> | ||||
|                   <div class="time-inputs"> | ||||
|                     <input  | ||||
|                       type="number"  | ||||
|                       class="time-input"  | ||||
|                       .value=${component.timeFormat === '12h'  | ||||
|                         ? (component.selectedHour === 0 ? 12 : component.selectedHour > 12 ? component.selectedHour - 12 : component.selectedHour).toString().padStart(2, '0') | ||||
|                         : component.selectedHour.toString().padStart(2, '0')} | ||||
|                       @input=${(e: InputEvent) => component.handleHourInput(e)} | ||||
|                       min="${component.timeFormat === '12h' ? 1 : 0}" | ||||
|                       max="${component.timeFormat === '12h' ? 12 : 23}" | ||||
|                     /> | ||||
|                     <span class="time-separator">:</span> | ||||
|                     <input  | ||||
|                       type="number"  | ||||
|                       class="time-input"  | ||||
|                       .value=${component.selectedMinute.toString().padStart(2, '0')} | ||||
|                       @input=${(e: InputEvent) => component.handleMinuteInput(e)} | ||||
|                       min="0" | ||||
|                       max="59" | ||||
|                       step="${component.minuteIncrement || 1}" | ||||
|                     /> | ||||
|                     ${component.timeFormat === '12h' ? html` | ||||
|                       <div class="am-pm-selector"> | ||||
|                         <button  | ||||
|                           class="am-pm-button ${isAM ? 'selected' : ''}" | ||||
|                           @click=${() => component.setAMPM('am')} | ||||
|                         > | ||||
|                           AM | ||||
|                         </button> | ||||
|                         <button  | ||||
|                           class="am-pm-button ${!isAM ? 'selected' : ''}" | ||||
|                           @click=${() => component.setAMPM('pm')} | ||||
|                         > | ||||
|                           PM | ||||
|                         </button> | ||||
|                       </div> | ||||
|                     ` : ''} | ||||
|                   </div> | ||||
|                 </div> | ||||
|               ` : ''} | ||||
|  | ||||
|               <!-- Timezone Selector --> | ||||
|               ${component.enableTimezone ? html` | ||||
|                 <div class="timezone-selector"> | ||||
|                   <div class="timezone-selector-title">Timezone</div> | ||||
|                   <select  | ||||
|                     class="timezone-select"  | ||||
|                     .value=${component.timezone} | ||||
|                     @change=${(e: Event) => component.handleTimezoneChange(e)} | ||||
|                   > | ||||
|                     ${timezones.map(tz => html` | ||||
|                       <option value="${tz.value}" ?selected=${tz.value === component.timezone}> | ||||
|                         ${tz.label} | ||||
|                       </option> | ||||
|                     `)} | ||||
|                   </select> | ||||
|                 </div> | ||||
|               ` : ''} | ||||
|  | ||||
|               <!-- Action Buttons --> | ||||
|               <div class="calendar-actions"> | ||||
|                 <button class="action-button today-button" @click=${component.selectToday}> | ||||
|                   Today | ||||
|                 </button> | ||||
|                 <button class="action-button clear-button" @click=${component.clear}> | ||||
|                   Clear | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       `; | ||||
|    | ||||
| }; | ||||
							
								
								
									
										7
									
								
								ts_web/elements/dees-input-datepicker/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								ts_web/elements/dees-input-datepicker/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| export interface IDateEvent { | ||||
|   date: string; // ISO date string (YYYY-MM-DD) | ||||
|   title?: string; | ||||
|   description?: string; | ||||
|   type?: 'info' | 'warning' | 'success' | 'error'; | ||||
|   count?: number; // Number of events on this day | ||||
| } | ||||
| @@ -1,204 +0,0 @@ | ||||
| import { html, css, cssManager } from '@design.estate/dees-element'; | ||||
|  | ||||
| export const demoFunc = () => html` | ||||
|   <dees-demowrapper> | ||||
|     <style> | ||||
|       ${css` | ||||
|         .demo-container { | ||||
|           display: flex; | ||||
|           flex-direction: column; | ||||
|           gap: 24px; | ||||
|           padding: 24px; | ||||
|           max-width: 1200px; | ||||
|           margin: 0 auto; | ||||
|         } | ||||
|          | ||||
|         .upload-grid { | ||||
|           display: grid; | ||||
|           grid-template-columns: 1fr 1fr; | ||||
|           gap: 24px; | ||||
|         } | ||||
|          | ||||
|         @media (max-width: 768px) { | ||||
|           .upload-grid { | ||||
|             grid-template-columns: 1fr; | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         .upload-box { | ||||
|           padding: 16px; | ||||
|           background: ${cssManager.bdTheme('#fff', '#2a2a2a')}; | ||||
|           border-radius: 4px; | ||||
|           border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444')}; | ||||
|         } | ||||
|          | ||||
|         .upload-box h4 { | ||||
|           margin-top: 0; | ||||
|           margin-bottom: 16px; | ||||
|           color: ${cssManager.bdTheme('#333', '#fff')}; | ||||
|           font-size: 16px; | ||||
|         } | ||||
|          | ||||
|         .info-section { | ||||
|           margin-top: 32px; | ||||
|           padding: 16px; | ||||
|           background: ${cssManager.bdTheme('#fff3cd', '#332701')}; | ||||
|           border: 1px solid ${cssManager.bdTheme('#ffeaa7', '#664400')}; | ||||
|           border-radius: 4px; | ||||
|           color: ${cssManager.bdTheme('#856404', '#ffecb5')}; | ||||
|         } | ||||
|       `} | ||||
|     </style> | ||||
|      | ||||
|     <div class="demo-container"> | ||||
|       <dees-panel .title=${'1. Basic File Upload'} .subtitle=${'Simple file upload with drag and drop support'}> | ||||
|         <dees-input-fileupload | ||||
|           .label=${'Attachments'} | ||||
|           .description=${'Upload any files by clicking or dragging them here'} | ||||
|         ></dees-input-fileupload> | ||||
|          | ||||
|         <dees-input-fileupload | ||||
|           .label=${'Single File Only'} | ||||
|           .description=${'Only one file can be uploaded at a time'} | ||||
|           .multiple=${false} | ||||
|           .buttonText=${'Choose File'} | ||||
|         ></dees-input-fileupload> | ||||
|       </dees-panel> | ||||
|        | ||||
|       <dees-panel .title=${'2. File Type Restrictions'} .subtitle=${'Upload areas with specific file type requirements'}> | ||||
|         <div class="upload-grid"> | ||||
|           <div class="upload-box"> | ||||
|             <h4>Images Only</h4> | ||||
|             <dees-input-fileupload | ||||
|               .label=${'Profile Picture'} | ||||
|               .description=${'JPG, PNG or GIF (max 5MB)'} | ||||
|               .accept=${'image/jpeg,image/png,image/gif'} | ||||
|               .maxSize=${5 * 1024 * 1024} | ||||
|               .multiple=${false} | ||||
|               .buttonText=${'Select Image'} | ||||
|             ></dees-input-fileupload> | ||||
|           </div> | ||||
|            | ||||
|           <div class="upload-box"> | ||||
|             <h4>Documents Only</h4> | ||||
|             <dees-input-fileupload | ||||
|               .label=${'Resume'} | ||||
|               .description=${'PDF or Word documents only'} | ||||
|               .accept=${".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"} | ||||
|               .buttonText=${'Select Document'} | ||||
|             ></dees-input-fileupload> | ||||
|           </div> | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|        | ||||
|       <dees-panel .title=${'3. Validation & Limits'} .subtitle=${'File size limits and validation examples'}> | ||||
|         <dees-input-fileupload | ||||
|           .label=${'Small Files Only'} | ||||
|           .description=${'Maximum file size: 1MB'} | ||||
|           .maxSize=${1024 * 1024} | ||||
|           .buttonText=${'Upload Small File'} | ||||
|         ></dees-input-fileupload> | ||||
|          | ||||
|         <dees-input-fileupload | ||||
|           .label=${'Limited Upload'} | ||||
|           .description=${'Maximum 3 files, each up to 2MB'} | ||||
|           .maxFiles=${3} | ||||
|           .maxSize=${2 * 1024 * 1024} | ||||
|         ></dees-input-fileupload> | ||||
|          | ||||
|         <dees-input-fileupload | ||||
|           .label=${'Required Upload'} | ||||
|           .description=${'This field is required'} | ||||
|           .required=${true} | ||||
|         ></dees-input-fileupload> | ||||
|       </dees-panel> | ||||
|        | ||||
|       <dees-panel .title=${'4. States & Styling'} .subtitle=${'Different states and validation feedback'}> | ||||
|         <dees-input-fileupload | ||||
|           .label=${'Disabled Upload'} | ||||
|           .description=${'File upload is currently disabled'} | ||||
|           .disabled=${true} | ||||
|         ></dees-input-fileupload> | ||||
|          | ||||
|         <dees-input-fileupload | ||||
|           .label=${'Pre-filled Example'} | ||||
|           .description=${'Component with pre-loaded files'} | ||||
|           .value=${[ | ||||
|             new File(['Hello World'], 'example.txt', { type: 'text/plain' }), | ||||
|             new File(['Test Data'], 'data.json', { type: 'application/json' }) | ||||
|           ]} | ||||
|         ></dees-input-fileupload> | ||||
|       </dees-panel> | ||||
|        | ||||
|       <dees-panel .title=${'5. Form Integration'} .subtitle=${'Complete form with various file upload scenarios'}> | ||||
|         <dees-form> | ||||
|           <h3 style="margin-top: 0; margin-bottom: 24px; color: ${cssManager.bdTheme('#333', '#fff')};">Job Application Form</h3> | ||||
|            | ||||
|           <dees-input-text  | ||||
|             .label=${'Full Name'}  | ||||
|             .required=${true} | ||||
|             .key=${'fullName'} | ||||
|           ></dees-input-text> | ||||
|            | ||||
|           <dees-input-text  | ||||
|             .label=${'Email'}  | ||||
|             .inputType=${'email'}  | ||||
|             .required=${true} | ||||
|             .key=${'email'} | ||||
|           ></dees-input-text> | ||||
|            | ||||
|           <dees-input-fileupload | ||||
|             .label=${'Resume'} | ||||
|             .description=${'Required: PDF format only (max 10MB)'} | ||||
|             .required=${true} | ||||
|             .accept=${'application/pdf'} | ||||
|             .maxSize=${10 * 1024 * 1024} | ||||
|             .multiple=${false} | ||||
|             .key=${'resume'} | ||||
|           ></dees-input-fileupload> | ||||
|            | ||||
|           <dees-input-fileupload | ||||
|             .label=${'Portfolio'} | ||||
|             .description=${'Optional: Upload up to 5 work samples (images or PDFs, max 5MB each)'} | ||||
|             .accept=${'image/*,application/pdf'} | ||||
|             .maxFiles=${5} | ||||
|             .maxSize=${5 * 1024 * 1024} | ||||
|             .key=${'portfolio'} | ||||
|           ></dees-input-fileupload> | ||||
|            | ||||
|           <dees-input-fileupload | ||||
|             .label=${'References'} | ||||
|             .description=${'Upload reference letters (optional)'} | ||||
|             .accept=${".pdf,.doc,.docx"} | ||||
|             .key=${'references'} | ||||
|           ></dees-input-fileupload> | ||||
|            | ||||
|           <dees-input-text | ||||
|             .label=${'Additional Comments'} | ||||
|             .inputType=${'textarea'} | ||||
|             .description=${'Any additional information you would like to share'} | ||||
|             .key=${'comments'} | ||||
|           ></dees-input-text> | ||||
|            | ||||
|           <dees-form-submit .text=${'Submit Application'}></dees-form-submit> | ||||
|         </dees-form> | ||||
|          | ||||
|         <div class="info-section"> | ||||
|           <h4 style="margin-top: 0;">Enhanced Features:</h4> | ||||
|           <ul style="margin: 0; padding-left: 20px;"> | ||||
|             <li>Drag & drop with visual feedback</li> | ||||
|             <li>File type restrictions via accept attribute</li> | ||||
|             <li>File size validation with custom limits</li> | ||||
|             <li>Maximum file count restrictions</li> | ||||
|             <li>Image preview thumbnails</li> | ||||
|             <li>File type-specific icons</li> | ||||
|             <li>Clear all button for multiple files</li> | ||||
|             <li>Proper validation states and messages</li> | ||||
|             <li>Keyboard accessible</li> | ||||
|             <li>Single or multiple file modes</li> | ||||
|           </ul> | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|     </div> | ||||
|   </dees-demowrapper> | ||||
| `; | ||||
| @@ -1,721 +0,0 @@ | ||||
| import * as colors from './00colors.js'; | ||||
| import * as plugins from './00plugins.js'; | ||||
|  | ||||
| import { DeesContextmenu } from './dees-contextmenu.js'; | ||||
| import { DeesInputBase } from './dees-input-base.js'; | ||||
| import { demoFunc } from './dees-input-fileupload.demo.js'; | ||||
|  | ||||
| import { | ||||
|   customElement, | ||||
|   DeesElement, | ||||
|   type TemplateResult, | ||||
|   property, | ||||
|   html, | ||||
|   css, | ||||
|   unsafeCSS, | ||||
|   cssManager, | ||||
|   type CSSResult, | ||||
|   domtools, | ||||
| } from '@design.estate/dees-element'; | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     'dees-input-fileupload': DeesInputFileupload; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @customElement('dees-input-fileupload') | ||||
| export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> { | ||||
|   public static demo = demoFunc; | ||||
|  | ||||
|  | ||||
|   @property({ | ||||
|     attribute: false, | ||||
|   }) | ||||
|   public value: File[] = []; | ||||
|  | ||||
|   @property() | ||||
|   public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle'; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   private isLoading: boolean = false; | ||||
|  | ||||
|   @property({ | ||||
|     type: String, | ||||
|   }) | ||||
|   public buttonText: string = 'Upload File...'; | ||||
|  | ||||
|   @property({ type: String }) | ||||
|   public accept: string = ''; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   public multiple: boolean = true; | ||||
|  | ||||
|   @property({ type: Number }) | ||||
|   public maxSize: number = 0; // 0 means no limit | ||||
|  | ||||
|   @property({ type: Number }) | ||||
|   public maxFiles: number = 0; // 0 means no limit | ||||
|  | ||||
|   @property({ type: String, reflect: true }) | ||||
|   public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null; | ||||
|  | ||||
|   constructor() { | ||||
|     super(); | ||||
|   } | ||||
|  | ||||
|   public static styles = [ | ||||
|     ...DeesInputBase.baseStyles, | ||||
|     cssManager.defaultStyles, | ||||
|     css` | ||||
|       :host { | ||||
|         position: relative; | ||||
|         display: block; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')}; | ||||
|       } | ||||
|  | ||||
|       .hidden { | ||||
|         display: none; | ||||
|       } | ||||
|  | ||||
|       .input-wrapper { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 8px; | ||||
|       } | ||||
|  | ||||
|       .maincontainer { | ||||
|         position: relative; | ||||
|         border-radius: 6px; | ||||
|         padding: 16px; | ||||
|         background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')}; | ||||
|         transition: all 0.15s ease; | ||||
|       } | ||||
|  | ||||
|       .maincontainer:hover { | ||||
|         border-color: ${cssManager.bdTheme('hsl(215 20.2% 55.1%)', 'hsl(215 20.2% 45.1%)')}; | ||||
|       } | ||||
|  | ||||
|       :host([disabled]) .maincontainer { | ||||
|         opacity: 0.5; | ||||
|         cursor: not-allowed; | ||||
|         pointer-events: none; | ||||
|       } | ||||
|  | ||||
|       :host([validationState="invalid"]) .maincontainer { | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; | ||||
|       } | ||||
|  | ||||
|       :host([validationState="valid"]) .maincontainer { | ||||
|         border-color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')}; | ||||
|       } | ||||
|  | ||||
|       :host([validationState="warn"]) .maincontainer { | ||||
|         border-color: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')}; | ||||
|       } | ||||
|  | ||||
|       .maincontainer::after { | ||||
|         top: 1px; | ||||
|         right: 1px; | ||||
|         left: 1px; | ||||
|         bottom: 1px; | ||||
|         transform: scale3d(0.98, 0.95, 1); | ||||
|         position: absolute; | ||||
|         content: ''; | ||||
|         display: block; | ||||
|         border: 2px dashed transparent; | ||||
|         border-radius: 5px; | ||||
|         transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | ||||
|         pointer-events: none; | ||||
|         background: transparent; | ||||
|       } | ||||
|        | ||||
|       .maincontainer.dragOver { | ||||
|         border-color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')}; | ||||
|         background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.05)', 'hsl(213.1 93.9% 67.8% / 0.05)')}; | ||||
|       } | ||||
|        | ||||
|       .maincontainer.dragOver::after { | ||||
|         transform: scale3d(1, 1, 1); | ||||
|         border: 2px dashed ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')}; | ||||
|       } | ||||
|  | ||||
|       .uploadButton { | ||||
|         position: relative; | ||||
|         padding: 10px 20px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 7.8%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         text-align: center; | ||||
|         font-size: 14px; | ||||
|         font-weight: 500; | ||||
|         cursor: pointer; | ||||
|         transition: all 0.15s ease; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         gap: 8px; | ||||
|         line-height: 20px; | ||||
|       } | ||||
|  | ||||
|       .uploadButton:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .uploadButton:active { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 11%)')}; | ||||
|       } | ||||
|  | ||||
|       .uploadButton dees-icon { | ||||
|         font-size: 16px; | ||||
|       } | ||||
|  | ||||
|       .files-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 8px; | ||||
|         margin-bottom: 12px; | ||||
|       } | ||||
|  | ||||
|       .uploadCandidate { | ||||
|         display: grid; | ||||
|         grid-template-columns: 40px 1fr auto; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20.2% 16.8%)')}; | ||||
|         padding: 12px; | ||||
|         text-align: left; | ||||
|         border-radius: 6px; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|         cursor: default; | ||||
|         transition: all 0.15s ease; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         position: relative; | ||||
|         overflow: hidden; | ||||
|       } | ||||
|  | ||||
|       .uploadCandidate:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(215 20.2% 20.8%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .uploadCandidate .icon { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         font-size: 20px; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .uploadCandidate.image-file .icon { | ||||
|         color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')}; | ||||
|       } | ||||
|        | ||||
|       .uploadCandidate.pdf-file .icon { | ||||
|         color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; | ||||
|       } | ||||
|        | ||||
|       .uploadCandidate.doc-file .icon { | ||||
|         color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')}; | ||||
|       } | ||||
|  | ||||
|       .uploadCandidate .info { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 2px; | ||||
|         min-width: 0; | ||||
|       } | ||||
|        | ||||
|       .uploadCandidate .filename { | ||||
|         font-weight: 500; | ||||
|         font-size: 14px; | ||||
|         white-space: nowrap; | ||||
|         overflow: hidden; | ||||
|         text-overflow: ellipsis; | ||||
|       } | ||||
|        | ||||
|       .uploadCandidate .filesize { | ||||
|         font-size: 12px; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .uploadCandidate .actions { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 8px; | ||||
|       } | ||||
|  | ||||
|       .remove-button { | ||||
|         width: 32px; | ||||
|         height: 32px; | ||||
|         border-radius: 4px; | ||||
|         background: transparent; | ||||
|         border: none; | ||||
|         cursor: pointer; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         transition: all 0.15s ease; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .remove-button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; | ||||
|       } | ||||
|  | ||||
|       .clear-all-button { | ||||
|         margin-bottom: 8px; | ||||
|         text-align: right; | ||||
|       } | ||||
|  | ||||
|       .clear-all-button button { | ||||
|         background: none; | ||||
|         border: none; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; | ||||
|         cursor: pointer; | ||||
|         font-size: 12px; | ||||
|         padding: 4px 8px; | ||||
|         border-radius: 4px; | ||||
|         transition: all 0.15s ease; | ||||
|       } | ||||
|  | ||||
|       .clear-all-button button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; | ||||
|       } | ||||
|  | ||||
|       .validation-message { | ||||
|         font-size: 13px; | ||||
|         margin-top: 6px; | ||||
|         color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; | ||||
|         line-height: 1.5; | ||||
|       } | ||||
|  | ||||
|       .drop-hint { | ||||
|         text-align: center; | ||||
|         padding: 40px 20px; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; | ||||
|         font-size: 14px; | ||||
|       } | ||||
|  | ||||
|       .drop-hint dees-icon { | ||||
|         font-size: 48px; | ||||
|         margin-bottom: 16px; | ||||
|         opacity: 0.2; | ||||
|       } | ||||
|  | ||||
|       .image-preview { | ||||
|         width: 40px; | ||||
|         height: 40px; | ||||
|         object-fit: cover; | ||||
|         border-radius: 4px; | ||||
|       } | ||||
|  | ||||
|       .description-text { | ||||
|         font-size: 13px; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; | ||||
|         margin-top: 6px; | ||||
|         line-height: 1.5; | ||||
|       } | ||||
|  | ||||
|       /* Loading state styles */ | ||||
|       .uploadButton.loading { | ||||
|         pointer-events: none; | ||||
|         opacity: 0.8; | ||||
|       } | ||||
|  | ||||
|       .uploadButton .button-content { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         gap: 8px; | ||||
|       } | ||||
|  | ||||
|       .loading-spinner { | ||||
|         width: 16px; | ||||
|         height: 16px; | ||||
|         border: 2px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')}; | ||||
|         border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')}; | ||||
|         border-radius: 50%; | ||||
|         animation: spin 0.6s linear infinite; | ||||
|       } | ||||
|  | ||||
|       @keyframes spin { | ||||
|         to { | ||||
|           transform: rotate(360deg); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       @keyframes pulse { | ||||
|         0% { | ||||
|           transform: scale(1); | ||||
|           opacity: 1; | ||||
|         } | ||||
|         50% { | ||||
|           transform: scale(1.02); | ||||
|           opacity: 0.9; | ||||
|         } | ||||
|         100% { | ||||
|           transform: scale(1); | ||||
|           opacity: 1; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .uploadButton.loading { | ||||
|         animation: pulse 1s ease-in-out infinite; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|  | ||||
|   public render(): TemplateResult { | ||||
|     const hasFiles = this.value.length > 0; | ||||
|     const showClearAll = hasFiles && this.value.length > 1; | ||||
|      | ||||
|     return html` | ||||
|       <div class="input-wrapper"> | ||||
|         ${this.label ? html` | ||||
|           <dees-label .label=${this.label}></dees-label> | ||||
|         ` : ''} | ||||
|         <div class="hidden"> | ||||
|           <input  | ||||
|             type="file"  | ||||
|             ?multiple=${this.multiple} | ||||
|             accept="${this.accept}" | ||||
|           > | ||||
|         </div> | ||||
|         <div class="maincontainer ${this.state === 'dragOver' ? 'dragOver' : ''}"> | ||||
|           ${hasFiles ? html` | ||||
|             ${showClearAll ? html` | ||||
|               <div class="clear-all-button"> | ||||
|                 <button @click=${this.clearAll}>Clear All</button> | ||||
|               </div> | ||||
|             ` : ''} | ||||
|             <div class="files-container"> | ||||
|               ${this.value.map((fileArg) => { | ||||
|                 const fileType = this.getFileType(fileArg); | ||||
|                 const isImage = fileType === 'image'; | ||||
|                 return html` | ||||
|                   <div class="uploadCandidate ${fileType}-file"> | ||||
|                     <div class="icon"> | ||||
|                       ${isImage && this.canShowPreview(fileArg) ? html` | ||||
|                         <img class="image-preview" src="${URL.createObjectURL(fileArg)}" alt="${fileArg.name}"> | ||||
|                       ` : html` | ||||
|                         <dees-icon .icon=${this.getFileIcon(fileArg)}></dees-icon> | ||||
|                       `} | ||||
|                     </div> | ||||
|                     <div class="info"> | ||||
|                       <div class="filename" title="${fileArg.name}">${fileArg.name}</div> | ||||
|                       <div class="filesize">${this.formatFileSize(fileArg.size)}</div> | ||||
|                     </div> | ||||
|                     <div class="actions"> | ||||
|                       <button  | ||||
|                         class="remove-button"  | ||||
|                         @click=${() => this.removeFile(fileArg)} | ||||
|                         title="Remove file" | ||||
|                       > | ||||
|                         <dees-icon .icon=${'lucide:x'}></dees-icon> | ||||
|                       </button> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 `; | ||||
|               })} | ||||
|             </div> | ||||
|           ` : html` | ||||
|             <div class="drop-hint"> | ||||
|               <dees-icon .icon=${'lucide:cloud-upload'}></dees-icon> | ||||
|               <div>Drag files here or click to browse</div> | ||||
|             </div> | ||||
|           `} | ||||
|           <div class="uploadButton ${this.isLoading ? 'loading' : ''}" @click=${this.openFileSelector}> | ||||
|             <div class="button-content"> | ||||
|               ${this.isLoading ? html` | ||||
|                 <div class="loading-spinner"></div> | ||||
|                 <span>Opening...</span> | ||||
|               ` : html` | ||||
|                 <dees-icon .icon=${'lucide:upload'}></dees-icon> | ||||
|                 ${this.buttonText} | ||||
|               `} | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         ${this.description ? html` | ||||
|           <div class="description-text">${this.description}</div> | ||||
|         ` : ''} | ||||
|         ${this.validationState === 'invalid' && this.validationMessage ? html` | ||||
|           <div class="validation-message">${this.validationMessage}</div> | ||||
|         ` : ''} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private validationMessage: string = ''; | ||||
|  | ||||
|   // Utility methods | ||||
|   private formatFileSize(bytes: number): string { | ||||
|     const sizes = ['Bytes', 'KB', 'MB', 'GB']; | ||||
|     if (bytes === 0) return '0 Bytes'; | ||||
|     const i = Math.floor(Math.log(bytes) / Math.log(1024)); | ||||
|     return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; | ||||
|   } | ||||
|  | ||||
|   private getFileType(file: File): string { | ||||
|     const type = file.type.toLowerCase(); | ||||
|     if (type.startsWith('image/')) return 'image'; | ||||
|     if (type === 'application/pdf') return 'pdf'; | ||||
|     if (type.includes('word') || type.includes('document')) return 'doc'; | ||||
|     if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet'; | ||||
|     if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation'; | ||||
|     if (type.startsWith('video/')) return 'video'; | ||||
|     if (type.startsWith('audio/')) return 'audio'; | ||||
|     if (type.includes('zip') || type.includes('compressed')) return 'archive'; | ||||
|     return 'file'; | ||||
|   } | ||||
|  | ||||
|   private getFileIcon(file: File): string { | ||||
|     const type = this.getFileType(file); | ||||
|     const iconMap = { | ||||
|       'image': 'lucide:image', | ||||
|       'pdf': 'lucide:file-text', | ||||
|       'doc': 'lucide:file-text', | ||||
|       'spreadsheet': 'lucide:table', | ||||
|       'presentation': 'lucide:presentation', | ||||
|       'video': 'lucide:video', | ||||
|       'audio': 'lucide:music', | ||||
|       'archive': 'lucide:archive', | ||||
|       'file': 'lucide:file' | ||||
|     }; | ||||
|     return iconMap[type] || 'lucide:file'; | ||||
|   } | ||||
|  | ||||
|   private canShowPreview(file: File): boolean { | ||||
|     return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; // 5MB limit for previews | ||||
|   } | ||||
|  | ||||
|   private validateFile(file: File): boolean { | ||||
|     // Check file size | ||||
|     if (this.maxSize > 0 && file.size > this.maxSize) { | ||||
|       this.validationMessage = `File "${file.name}" exceeds maximum size of ${this.formatFileSize(this.maxSize)}`; | ||||
|       this.validationState = 'invalid'; | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     // Check file type | ||||
|     if (this.accept) { | ||||
|       const acceptedTypes = this.accept.split(',').map(s => s.trim()); | ||||
|       let isAccepted = false; | ||||
|        | ||||
|       for (const acceptType of acceptedTypes) { | ||||
|         if (acceptType.startsWith('.')) { | ||||
|           // Extension check | ||||
|           if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) { | ||||
|             isAccepted = true; | ||||
|             break; | ||||
|           } | ||||
|         } else if (acceptType.endsWith('/*')) { | ||||
|           // MIME type wildcard check | ||||
|           const mimePrefix = acceptType.slice(0, -2); | ||||
|           if (file.type.startsWith(mimePrefix)) { | ||||
|             isAccepted = true; | ||||
|             break; | ||||
|           } | ||||
|         } else if (file.type === acceptType) { | ||||
|           // Exact MIME type check | ||||
|           isAccepted = true; | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       if (!isAccepted) { | ||||
|         this.validationMessage = `File type not accepted. Please upload: ${this.accept}`; | ||||
|         this.validationState = 'invalid'; | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   public async openFileSelector() { | ||||
|     if (this.disabled || this.isLoading) return; | ||||
|      | ||||
|     // Set loading state | ||||
|     this.isLoading = true; | ||||
|      | ||||
|     const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]'); | ||||
|      | ||||
|     // Set up a focus handler to detect when the dialog is closed without selection | ||||
|     const handleFocus = () => { | ||||
|       setTimeout(() => { | ||||
|         // Check if no file was selected | ||||
|         if (!inputFile.files || inputFile.files.length === 0) { | ||||
|           this.isLoading = false; | ||||
|         } | ||||
|         window.removeEventListener('focus', handleFocus); | ||||
|       }, 300); | ||||
|     }; | ||||
|      | ||||
|     window.addEventListener('focus', handleFocus); | ||||
|     inputFile.click(); | ||||
|   } | ||||
|  | ||||
|   private removeFile(file: File) { | ||||
|     const index = this.value.indexOf(file); | ||||
|     if (index > -1) { | ||||
|       this.value.splice(index, 1); | ||||
|       this.requestUpdate(); | ||||
|       this.validate(); | ||||
|       this.changeSubject.next(this); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private clearAll() { | ||||
|     this.value = []; | ||||
|     this.requestUpdate(); | ||||
|     this.validate(); | ||||
|     this.changeSubject.next(this); | ||||
|   } | ||||
|  | ||||
|   public async updateValue(eventArg: Event) { | ||||
|     const target: any = eventArg.target; | ||||
|     this.value = target.value; | ||||
|     this.changeSubject.next(this); | ||||
|   } | ||||
|  | ||||
|   public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) { | ||||
|     super.firstUpdated(_changedProperties); | ||||
|     const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]'); | ||||
|     inputFile.addEventListener('change', async (event: Event) => { | ||||
|       const target = event.target as HTMLInputElement; | ||||
|       const newFiles = Array.from(target.files); | ||||
|        | ||||
|       // Always reset loading state when file dialog interaction completes | ||||
|       this.isLoading = false; | ||||
|        | ||||
|       await this.addFiles(newFiles); | ||||
|       // Reset the input value to allow selecting the same file again if needed | ||||
|       target.value = ''; | ||||
|     }); | ||||
|  | ||||
|     // Handle drag and drop | ||||
|     const dropArea = this.shadowRoot.querySelector('.maincontainer'); | ||||
|     const handlerFunction = async (eventArg: DragEvent) => { | ||||
|       eventArg.preventDefault(); | ||||
|       eventArg.stopPropagation(); | ||||
|        | ||||
|       switch (eventArg.type) { | ||||
|         case 'dragenter': | ||||
|         case 'dragover': | ||||
|           this.state = 'dragOver'; | ||||
|           break; | ||||
|         case 'dragleave': | ||||
|           // Check if we're actually leaving the drop area | ||||
|           const rect = dropArea.getBoundingClientRect(); | ||||
|           const x = eventArg.clientX; | ||||
|           const y = eventArg.clientY; | ||||
|           if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { | ||||
|             this.state = 'idle'; | ||||
|           } | ||||
|           break; | ||||
|         case 'drop': | ||||
|           this.state = 'idle'; | ||||
|           const files = Array.from(eventArg.dataTransfer.files); | ||||
|           await this.addFiles(files); | ||||
|           break; | ||||
|       } | ||||
|     }; | ||||
|      | ||||
|     dropArea.addEventListener('dragenter', handlerFunction, false); | ||||
|     dropArea.addEventListener('dragleave', handlerFunction, false); | ||||
|     dropArea.addEventListener('dragover', handlerFunction, false); | ||||
|     dropArea.addEventListener('drop', handlerFunction, false); | ||||
|   } | ||||
|  | ||||
|   private async addFiles(files: File[]) { | ||||
|     const filesToAdd: File[] = []; | ||||
|      | ||||
|     for (const file of files) { | ||||
|       if (this.validateFile(file)) { | ||||
|         filesToAdd.push(file); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (filesToAdd.length === 0) return; | ||||
|  | ||||
|     // Check max files limit | ||||
|     if (this.maxFiles > 0) { | ||||
|       const totalFiles = this.value.length + filesToAdd.length; | ||||
|       if (totalFiles > this.maxFiles) { | ||||
|         const allowedCount = this.maxFiles - this.value.length; | ||||
|         if (allowedCount <= 0) { | ||||
|           this.validationMessage = `Maximum ${this.maxFiles} files allowed`; | ||||
|           this.validationState = 'invalid'; | ||||
|           return; | ||||
|         } | ||||
|         filesToAdd.splice(allowedCount); | ||||
|         this.validationMessage = `Only ${allowedCount} more file(s) can be added`; | ||||
|         this.validationState = 'warn'; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Add files | ||||
|     if (!this.multiple && filesToAdd.length > 0) { | ||||
|       this.value = [filesToAdd[0]]; | ||||
|     } else { | ||||
|       this.value.push(...filesToAdd); | ||||
|     } | ||||
|  | ||||
|     this.requestUpdate(); | ||||
|     this.validate(); | ||||
|     this.changeSubject.next(this); | ||||
|      | ||||
|     // Update button text | ||||
|     if (this.value.length > 0) { | ||||
|       this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async validate(): Promise<boolean> { | ||||
|     this.validationMessage = ''; | ||||
|      | ||||
|     if (this.required && this.value.length === 0) { | ||||
|       this.validationState = 'invalid'; | ||||
|       this.validationMessage = 'Please select at least one file'; | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Validate all files | ||||
|     for (const file of this.value) { | ||||
|       if (!this.validateFile(file)) { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     this.validationState = 'valid'; | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   public getValue(): File[] { | ||||
|     return this.value; | ||||
|   } | ||||
|  | ||||
|   public setValue(value: File[]): void { | ||||
|     this.value = value; | ||||
|     this.requestUpdate(); | ||||
|     if (value.length > 0) { | ||||
|       this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; | ||||
|     } else { | ||||
|       this.buttonText = 'Upload File...'; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public updated(changedProperties: Map<string, any>) { | ||||
|     super.updated(changedProperties); | ||||
|      | ||||
|     if (changedProperties.has('value')) { | ||||
|       this.validate(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										619
									
								
								ts_web/elements/dees-input-fileupload/component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										619
									
								
								ts_web/elements/dees-input-fileupload/component.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,619 @@ | ||||
| import { DeesInputBase } from '../dees-input-base.js'; | ||||
| import { demoFunc } from './demo.js'; | ||||
| import { fileuploadStyles } from './styles.js'; | ||||
| import '../dees-icon.js'; | ||||
| import '../dees-label.js'; | ||||
|  | ||||
| import { | ||||
|   customElement, | ||||
|   html, | ||||
|   property, | ||||
|   state, | ||||
|   type TemplateResult, | ||||
| } from '@design.estate/dees-element'; | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     'dees-input-fileupload': DeesInputFileupload; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @customElement('dees-input-fileupload') | ||||
| export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> { | ||||
|   public static demo = demoFunc; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public value: File[] = []; | ||||
|  | ||||
|   @state() | ||||
|   public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle'; | ||||
|  | ||||
|   @state() | ||||
|   public isLoading: boolean = false; | ||||
|  | ||||
|   @property({ type: String }) | ||||
|   public buttonText: string = 'Select files'; | ||||
|  | ||||
|   @property({ type: String }) | ||||
|   public accept: string = ''; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   public multiple: boolean = true; | ||||
|  | ||||
|   @property({ type: Number }) | ||||
|   public maxSize: number = 0; // 0 means no limit | ||||
|  | ||||
|   @property({ type: Number }) | ||||
|   public maxFiles: number = 0; // 0 means no limit | ||||
|  | ||||
|   @property({ type: String, reflect: true }) | ||||
|   public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null; | ||||
|  | ||||
|   public validationMessage: string = ''; | ||||
|  | ||||
|   private previewUrlMap: WeakMap<File, string> = new WeakMap(); | ||||
|   private dropArea: HTMLElement | null = null; | ||||
|  | ||||
|   public static styles = fileuploadStyles; | ||||
|  | ||||
|   public render(): TemplateResult { | ||||
|     const acceptedSummary = this.getAcceptedSummary(); | ||||
|     const metaEntries: string[] = [ | ||||
|       this.multiple ? 'Multiple files supported' : 'Single file only', | ||||
|       this.maxSize > 0 ? `Max ${this.formatFileSize(this.maxSize)}` : 'No size limit', | ||||
|     ]; | ||||
|  | ||||
|     if (acceptedSummary) { | ||||
|       metaEntries.push(`Accepts ${acceptedSummary}`); | ||||
|     } | ||||
|  | ||||
|     return html` | ||||
|       <div class="input-wrapper"> | ||||
|         <dees-label | ||||
|           .label=${this.label} | ||||
|           .description=${this.description} | ||||
|           .required=${this.required} | ||||
|         ></dees-label> | ||||
|         <div | ||||
|           class="dropzone ${this.state === 'dragOver' ? 'dropzone--active' : ''} ${this.disabled ? 'dropzone--disabled' : ''} ${this.value.length > 0 ? 'dropzone--has-files' : ''}" | ||||
|           role="button" | ||||
|           tabindex=${this.disabled ? -1 : 0} | ||||
|           aria-disabled=${this.disabled} | ||||
|           aria-label=${`Select files${acceptedSummary ? ` (${acceptedSummary})` : ''}`} | ||||
|           @click=${this.handleDropzoneClick} | ||||
|           @keydown=${this.handleDropzoneKeydown} | ||||
|         > | ||||
|           <input | ||||
|             class="file-input" | ||||
|             style="position: absolute; opacity: 0; pointer-events: none; width: 1px; height: 1px; top: 0; left: 0; overflow: hidden;" | ||||
|             type="file" | ||||
|             ?multiple=${this.multiple} | ||||
|             accept=${this.accept || ''} | ||||
|             ?disabled=${this.disabled} | ||||
|             @change=${this.handleFileInputChange} | ||||
|             tabindex="-1" | ||||
|           /> | ||||
|           <div class="dropzone__body"> | ||||
|             <div class="dropzone__icon"> | ||||
|               ${this.isLoading | ||||
|                 ? html`<span class="dropzone__loader" aria-hidden="true"></span>` | ||||
|                 : html`<dees-icon icon="lucide:FolderOpen"></dees-icon>`} | ||||
|             </div> | ||||
|             <div class="dropzone__content"> | ||||
|               <span class="dropzone__headline">${this.buttonText || 'Select files'}</span> | ||||
|               <span class="dropzone__subline"> | ||||
|                 Drag and drop files here or | ||||
|                 <button | ||||
|                   type="button" | ||||
|                   class="dropzone__browse" | ||||
|                   @click=${this.handleBrowseClick} | ||||
|                   ?disabled=${this.disabled} | ||||
|                 > | ||||
|                   browse | ||||
|                 </button> | ||||
|               </span> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="dropzone__meta"> | ||||
|             ${metaEntries.map((entry) => html`<span>${entry}</span>`)} | ||||
|           </div> | ||||
|           ${this.renderFileList()} | ||||
|         </div> | ||||
|         ${this.validationMessage | ||||
|           ? html`<div class="validation-message" aria-live="polite">${this.validationMessage}</div>` | ||||
|           : html``} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private renderFileList(): TemplateResult { | ||||
|     if (this.value.length === 0) { | ||||
|       return html``; | ||||
|     } | ||||
|  | ||||
|     return html` | ||||
|       <div class="file-list"> | ||||
|         <div class="file-list__header"> | ||||
|           <span>${this.value.length} file${this.value.length === 1 ? '' : 's'} selected</span> | ||||
|           ${this.value.length > 0 | ||||
|             ? html`<button type="button" class="file-list__clear" @click=${this.handleClearAll}>Clear ${this.value.length > 1 ? 'all' : ''}</button>` | ||||
|             : html``} | ||||
|         </div> | ||||
|         <div class="file-list__items"> | ||||
|           ${this.value.map((file) => this.renderFileRow(file))} | ||||
|         </div> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private renderFileRow(file: File): TemplateResult { | ||||
|     const fileType = this.getFileType(file); | ||||
|     const previewUrl = this.canShowPreview(file) ? this.getPreviewUrl(file) : null; | ||||
|  | ||||
|     return html` | ||||
|       <div class="file-row ${fileType}-file"> | ||||
|         <div class="file-thumb" aria-hidden="true"> | ||||
|           ${previewUrl | ||||
|             ? html`<img class="thumb-image" src=${previewUrl} alt=${`Preview of ${file.name}`}>` | ||||
|             : html`<dees-icon icon=${this.getFileIcon(file)}></dees-icon>`} | ||||
|         </div> | ||||
|         <div class="file-meta"> | ||||
|           <div class="file-name" title=${file.name}>${file.name}</div> | ||||
|           <div class="file-details"> | ||||
|             <span class="file-size">${this.formatFileSize(file.size)}</span> | ||||
|             ${fileType !== 'file' ? html`<span class="file-type">${fileType}</span>` : html``} | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="file-actions"> | ||||
|           <button | ||||
|             type="button" | ||||
|             class="remove-button" | ||||
|             @click=${() => this.removeFile(file)} | ||||
|             aria-label=${`Remove ${file.name}`} | ||||
|           > | ||||
|             <dees-icon icon="lucide:X"></dees-icon> | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private handleFileInputChange = async (event: Event) => { | ||||
|     this.isLoading = false; | ||||
|     const target = event.target as HTMLInputElement; | ||||
|     const files = Array.from(target.files ?? []); | ||||
|     if (files.length > 0) { | ||||
|       await this.addFiles(files); | ||||
|     } | ||||
|     target.value = ''; | ||||
|   }; | ||||
|  | ||||
|   private handleDropzoneClick = (event: MouseEvent) => { | ||||
|     if (this.disabled) { | ||||
|       return; | ||||
|     } | ||||
|     // Don't open file selector if clicking on the browse button or file list | ||||
|     if ((event.target as HTMLElement).closest('.dropzone__browse, .file-list')) { | ||||
|       return; | ||||
|     } | ||||
|     this.openFileSelector(); | ||||
|   }; | ||||
|  | ||||
|   private handleBrowseClick = (event: MouseEvent) => { | ||||
|     if (this.disabled) { | ||||
|       return; | ||||
|     } | ||||
|     event.stopPropagation(); // Stop propagation to prevent double trigger | ||||
|     this.openFileSelector(); | ||||
|   }; | ||||
|  | ||||
|   private handleDropzoneKeydown = (event: KeyboardEvent) => { | ||||
|     if (this.disabled) { | ||||
|       return; | ||||
|     } | ||||
|     if (event.key === 'Enter' || event.key === ' ') { | ||||
|       event.preventDefault(); | ||||
|       this.openFileSelector(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private handleClearAll = (event: MouseEvent) => { | ||||
|     event.preventDefault(); | ||||
|     this.clearAll(); | ||||
|   }; | ||||
|  | ||||
|   private handleDragEvent = async (event: DragEvent) => { | ||||
|     event.preventDefault(); | ||||
|     event.stopPropagation(); | ||||
|  | ||||
|     if (this.disabled) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (event.type === 'dragenter' || event.type === 'dragover') { | ||||
|       if (event.dataTransfer) { | ||||
|         event.dataTransfer.dropEffect = 'copy'; | ||||
|       } | ||||
|       this.state = 'dragOver'; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (event.type === 'dragleave') { | ||||
|       if (!this.dropArea) { | ||||
|         this.state = 'idle'; | ||||
|         return; | ||||
|       } | ||||
|       const rect = this.dropArea.getBoundingClientRect(); | ||||
|       const { clientX = 0, clientY = 0 } = event; | ||||
|       if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) { | ||||
|         this.state = 'idle'; | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (event.type === 'drop') { | ||||
|       this.state = 'idle'; | ||||
|       const files = Array.from(event.dataTransfer?.files ?? []); | ||||
|       if (files.length > 0) { | ||||
|         await this.addFiles(files); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private attachDropListeners(): void { | ||||
|     if (!this.dropArea) { | ||||
|       return; | ||||
|     } | ||||
|     ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => { | ||||
|       this.dropArea!.addEventListener(eventName, this.handleDragEvent); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private detachDropListeners(): void { | ||||
|     if (!this.dropArea) { | ||||
|       return; | ||||
|     } | ||||
|     ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => { | ||||
|       this.dropArea!.removeEventListener(eventName, this.handleDragEvent); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private rebindInteractiveElements(): void { | ||||
|     const newDropArea = this.shadowRoot?.querySelector('.dropzone') as HTMLElement | null; | ||||
|  | ||||
|     if (newDropArea !== this.dropArea) { | ||||
|       this.detachDropListeners(); | ||||
|       this.dropArea = newDropArea; | ||||
|       this.attachDropListeners(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public formatFileSize(bytes: number): string { | ||||
|     const units = ['Bytes', 'KB', 'MB', 'GB']; | ||||
|     if (bytes === 0) return '0 Bytes'; | ||||
|     const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); | ||||
|     const size = bytes / Math.pow(1024, exponent); | ||||
|     return `${Math.round(size * 100) / 100} ${units[exponent]}`; | ||||
|   } | ||||
|  | ||||
|   public getFileType(file: File): string { | ||||
|     const type = file.type.toLowerCase(); | ||||
|     if (type.startsWith('image/')) return 'image'; | ||||
|     if (type === 'application/pdf') return 'pdf'; | ||||
|     if (type.includes('word') || type.includes('document')) return 'doc'; | ||||
|     if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet'; | ||||
|     if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation'; | ||||
|     if (type.startsWith('video/')) return 'video'; | ||||
|     if (type.startsWith('audio/')) return 'audio'; | ||||
|     if (type.includes('zip') || type.includes('compressed')) return 'archive'; | ||||
|     return 'file'; | ||||
|   } | ||||
|  | ||||
|   public getFileIcon(file: File): string { | ||||
|     const fileType = this.getFileType(file); | ||||
|     const iconMap: Record<string, string> = { | ||||
|       image: 'lucide:FileImage', | ||||
|       pdf: 'lucide:FileText', | ||||
|       doc: 'lucide:FileText', | ||||
|       spreadsheet: 'lucide:FileSpreadsheet', | ||||
|       presentation: 'lucide:FileBarChart', | ||||
|       video: 'lucide:FileVideo', | ||||
|       audio: 'lucide:FileAudio', | ||||
|       archive: 'lucide:FileArchive', | ||||
|       file: 'lucide:File', | ||||
|     }; | ||||
|     return iconMap[fileType] ?? 'lucide:File'; | ||||
|   } | ||||
|  | ||||
|   public canShowPreview(file: File): boolean { | ||||
|     return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; | ||||
|   } | ||||
|  | ||||
|   private validateFile(file: File): boolean { | ||||
|     if (this.maxSize > 0 && file.size > this.maxSize) { | ||||
|       this.validationMessage = `File "${file.name}" exceeds the maximum size of ${this.formatFileSize(this.maxSize)}`; | ||||
|       this.validationState = 'invalid'; | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     if (this.accept) { | ||||
|       const acceptedTypes = this.accept | ||||
|         .split(',') | ||||
|         .map((entry) => entry.trim()) | ||||
|         .filter((entry) => entry.length > 0); | ||||
|  | ||||
|       if (acceptedTypes.length > 0) { | ||||
|         let isAccepted = false; | ||||
|         for (const acceptType of acceptedTypes) { | ||||
|           if (acceptType.startsWith('.')) { | ||||
|             if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) { | ||||
|               isAccepted = true; | ||||
|               break; | ||||
|             } | ||||
|           } else if (acceptType.endsWith('/*')) { | ||||
|             const prefix = acceptType.slice(0, -2); | ||||
|             if (file.type.startsWith(prefix)) { | ||||
|               isAccepted = true; | ||||
|               break; | ||||
|             } | ||||
|           } else if (file.type === acceptType) { | ||||
|             isAccepted = true; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         if (!isAccepted) { | ||||
|           this.validationMessage = `File type not accepted. Allowed: ${acceptedTypes.join(', ')}`; | ||||
|           this.validationState = 'invalid'; | ||||
|           return false; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   private getPreviewUrl(file: File): string { | ||||
|     let url = this.previewUrlMap.get(file); | ||||
|     if (!url) { | ||||
|       url = URL.createObjectURL(file); | ||||
|       this.previewUrlMap.set(file, url); | ||||
|     } | ||||
|     return url; | ||||
|   } | ||||
|  | ||||
|   private releasePreview(file: File): void { | ||||
|     const url = this.previewUrlMap.get(file); | ||||
|     if (url) { | ||||
|       URL.revokeObjectURL(url); | ||||
|       this.previewUrlMap.delete(file); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private getAcceptedSummary(): string | null { | ||||
|     if (!this.accept) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const formatted = Array.from( | ||||
|       new Set( | ||||
|         this.accept | ||||
|           .split(',') | ||||
|           .map((token) => token.trim()) | ||||
|           .filter((token) => token.length > 0) | ||||
|           .map((token) => this.formatAcceptToken(token)) | ||||
|       ) | ||||
|     ).filter(Boolean); | ||||
|  | ||||
|     if (formatted.length === 0) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     if (formatted.length === 1) { | ||||
|       return formatted[0]; | ||||
|     } | ||||
|  | ||||
|     if (formatted.length === 2) { | ||||
|       return `${formatted[0]}, ${formatted[1]}`; | ||||
|     } | ||||
|  | ||||
|     return `${formatted.slice(0, 2).join(', ')}…`; | ||||
|   } | ||||
|  | ||||
|   private formatAcceptToken(token: string): string { | ||||
|     if (token === '*/*') { | ||||
|       return 'All files'; | ||||
|     } | ||||
|  | ||||
|     if (token.endsWith('/*')) { | ||||
|       const family = token.split('/')[0]; | ||||
|       if (!family) { | ||||
|         return 'All files'; | ||||
|       } | ||||
|       return `${family.charAt(0).toUpperCase()}${family.slice(1)} files`; | ||||
|     } | ||||
|  | ||||
|     if (token.startsWith('.')) { | ||||
|       return token.slice(1).toUpperCase(); | ||||
|     } | ||||
|  | ||||
|     if (token.includes('pdf')) return 'PDF'; | ||||
|     if (token.includes('zip')) return 'ZIP'; | ||||
|     if (token.includes('json')) return 'JSON'; | ||||
|     if (token.includes('msword')) return 'DOC'; | ||||
|     if (token.includes('wordprocessingml')) return 'DOCX'; | ||||
|     if (token.includes('excel')) return 'XLS'; | ||||
|     if (token.includes('presentation')) return 'PPT'; | ||||
|  | ||||
|     const segments = token.split('/'); | ||||
|     const lastSegment = segments.pop() ?? token; | ||||
|     return lastSegment.toUpperCase(); | ||||
|   } | ||||
|  | ||||
|   private attachLifecycleListeners(): void { | ||||
|     this.rebindInteractiveElements(); | ||||
|   } | ||||
|  | ||||
|   public firstUpdated(changedProperties: Map<string, unknown>) { | ||||
|     super.firstUpdated(changedProperties); | ||||
|     this.attachLifecycleListeners(); | ||||
|   } | ||||
|  | ||||
|   public updated(changedProperties: Map<string, unknown>) { | ||||
|     super.updated(changedProperties); | ||||
|     if (changedProperties.has('value')) { | ||||
|       void this.validate(); | ||||
|     } | ||||
|     this.rebindInteractiveElements(); | ||||
|   } | ||||
|  | ||||
|   public async disconnectedCallback(): Promise<void> { | ||||
|     this.detachDropListeners(); | ||||
|     this.value.forEach((file) => this.releasePreview(file)); | ||||
|     this.previewUrlMap = new WeakMap(); | ||||
|     await super.disconnectedCallback(); | ||||
|   } | ||||
|  | ||||
|   public async openFileSelector() { | ||||
|     if (this.disabled || this.isLoading) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.isLoading = true; | ||||
|  | ||||
|     // Ensure we have the latest reference to the file input | ||||
|     const inputFile = this.shadowRoot?.querySelector('.file-input') as HTMLInputElement | null; | ||||
|  | ||||
|     if (!inputFile) { | ||||
|       this.isLoading = false; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const handleFocus = () => { | ||||
|       setTimeout(() => { | ||||
|         if (!inputFile.files || inputFile.files.length === 0) { | ||||
|           this.isLoading = false; | ||||
|         } | ||||
|         window.removeEventListener('focus', handleFocus); | ||||
|       }, 300); | ||||
|     }; | ||||
|  | ||||
|     window.addEventListener('focus', handleFocus); | ||||
|  | ||||
|     // Click the input to open file selector | ||||
|     inputFile.click(); | ||||
|   } | ||||
|  | ||||
|   public removeFile(file: File) { | ||||
|     const index = this.value.indexOf(file); | ||||
|     if (index > -1) { | ||||
|       this.releasePreview(file); | ||||
|       this.value.splice(index, 1); | ||||
|       this.requestUpdate('value'); | ||||
|       void this.validate(); | ||||
|       this.changeSubject.next(this); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public clearAll() { | ||||
|     const existingFiles = [...this.value]; | ||||
|     this.value = []; | ||||
|     existingFiles.forEach((file) => this.releasePreview(file)); | ||||
|     this.requestUpdate('value'); | ||||
|     void this.validate(); | ||||
|     this.changeSubject.next(this); | ||||
|     this.buttonText = 'Select files'; | ||||
|   } | ||||
|  | ||||
|   public async updateValue(eventArg: Event) { | ||||
|     const target = eventArg.target as HTMLInputElement; | ||||
|     this.value = Array.from(target.files ?? []); | ||||
|     this.changeSubject.next(this); | ||||
|   } | ||||
|  | ||||
|   public setValue(value: File[]): void { | ||||
|     this.value.forEach((file) => this.releasePreview(file)); | ||||
|     this.value = value; | ||||
|     if (value.length > 0) { | ||||
|       this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; | ||||
|     } else { | ||||
|       this.buttonText = 'Select files'; | ||||
|     } | ||||
|     this.requestUpdate('value'); | ||||
|     void this.validate(); | ||||
|   } | ||||
|  | ||||
|   public getValue(): File[] { | ||||
|     return this.value; | ||||
|   } | ||||
|  | ||||
|   private async addFiles(files: File[]) { | ||||
|     const filesToAdd: File[] = []; | ||||
|  | ||||
|     for (const file of files) { | ||||
|       if (this.validateFile(file)) { | ||||
|         filesToAdd.push(file); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (filesToAdd.length === 0) { | ||||
|       this.isLoading = false; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (this.maxFiles > 0) { | ||||
|       const totalFiles = this.value.length + filesToAdd.length; | ||||
|       if (totalFiles > this.maxFiles) { | ||||
|         const allowedCount = this.maxFiles - this.value.length; | ||||
|         if (allowedCount <= 0) { | ||||
|           this.validationMessage = `Maximum ${this.maxFiles} files allowed`; | ||||
|           this.validationState = 'invalid'; | ||||
|           this.isLoading = false; | ||||
|           return; | ||||
|         } | ||||
|         filesToAdd.splice(allowedCount); | ||||
|         this.validationMessage = `Only ${allowedCount} more file(s) can be added`; | ||||
|         this.validationState = 'warn'; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (!this.multiple && filesToAdd.length > 0) { | ||||
|       this.value.forEach((file) => this.releasePreview(file)); | ||||
|       this.value = [filesToAdd[0]]; | ||||
|     } else { | ||||
|       this.value.push(...filesToAdd); | ||||
|     } | ||||
|  | ||||
|     this.validationMessage = ''; | ||||
|     this.validationState = null; | ||||
|     this.requestUpdate('value'); | ||||
|     await this.validate(); | ||||
|     this.changeSubject.next(this); | ||||
|     this.isLoading = false; | ||||
|  | ||||
|     if (this.value.length > 0) { | ||||
|       this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; | ||||
|     } else { | ||||
|       this.buttonText = 'Select files'; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async validate(): Promise<boolean> { | ||||
|     this.validationMessage = ''; | ||||
|  | ||||
|     if (this.required && this.value.length === 0) { | ||||
|       this.validationState = 'invalid'; | ||||
|       this.validationMessage = 'Please select at least one file'; | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     for (const file of this.value) { | ||||
|       if (!this.validateFile(file)) { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     this.validationState = this.value.length > 0 ? 'valid' : null; | ||||
|     return true; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										159
									
								
								ts_web/elements/dees-input-fileupload/demo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								ts_web/elements/dees-input-fileupload/demo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| import { css, cssManager, html } from '@design.estate/dees-element'; | ||||
| import './component.js'; | ||||
| import '../dees-panel.js'; | ||||
|  | ||||
| export const demoFunc = () => html` | ||||
|   <dees-demowrapper> | ||||
|     <style> | ||||
|       ${css` | ||||
|         .demo-shell { | ||||
|           display: flex; | ||||
|           flex-direction: column; | ||||
|           gap: 32px; | ||||
|           padding: 24px; | ||||
|           max-width: 1160px; | ||||
|           margin: 0 auto; | ||||
|         } | ||||
|  | ||||
|         .demo-grid { | ||||
|           display: grid; | ||||
|           gap: 24px; | ||||
|         } | ||||
|  | ||||
|         @media (min-width: 960px) { | ||||
|           .demo-grid--two { | ||||
|             grid-template-columns: repeat(2, minmax(0, 1fr)); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         .demo-stack { | ||||
|           display: flex; | ||||
|           flex-direction: column; | ||||
|           gap: 18px; | ||||
|         } | ||||
|  | ||||
|         .demo-note { | ||||
|           margin-top: 16px; | ||||
|           padding: 16px; | ||||
|           border-radius: 12px; | ||||
|           border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(215 20% 26%)')}; | ||||
|           background: ${cssManager.bdTheme('hsl(213 100% 97%)', 'hsl(215 20% 12%)')}; | ||||
|           color: ${cssManager.bdTheme('hsl(215 25% 32%)', 'hsl(215 20% 82%)')}; | ||||
|           font-size: 13px; | ||||
|           line-height: 1.55; | ||||
|         } | ||||
|  | ||||
|         .demo-note strong { | ||||
|           color: ${cssManager.bdTheme('hsl(217 91% 45%)', 'hsl(213 93% 68%)')}; | ||||
|           font-weight: 600; | ||||
|         } | ||||
|       `} | ||||
|     </style> | ||||
|  | ||||
|     <div class="demo-shell"> | ||||
|       <dees-panel | ||||
|         .title=${'Modern file uploader'} | ||||
|         .subtitle=${'Shadcn-inspired layout with drag & drop, previews and validation'} | ||||
|       > | ||||
|         <div class="demo-grid demo-grid--two"> | ||||
|           <div class="demo-stack"> | ||||
|             <dees-input-fileupload | ||||
|               .label=${'Attachments'} | ||||
|               .description=${'Upload supporting documents for your request'} | ||||
|               .accept=${'image/*,.pdf,.zip'} | ||||
|               .maxSize=${10 * 1024 * 1024} | ||||
|             ></dees-input-fileupload> | ||||
|  | ||||
|             <dees-input-fileupload | ||||
|               .label=${'Brand assets'} | ||||
|               .description=${'Upload high-resolution imagery (JPG/PNG)'} | ||||
|               .accept=${'image/jpeg,image/png'} | ||||
|               .multiple=${false} | ||||
|               .maxSize=${5 * 1024 * 1024} | ||||
|               .buttonText=${'Select cover image'} | ||||
|             ></dees-input-fileupload> | ||||
|           </div> | ||||
|  | ||||
|           <div class="demo-stack"> | ||||
|             <dees-input-fileupload | ||||
|               .label=${'Audio uploads'} | ||||
|               .description=${'Share podcast drafts (MP3/WAV, max 25MB each)'} | ||||
|               .accept=${'audio/*'} | ||||
|               .maxSize=${25 * 1024 * 1024} | ||||
|             ></dees-input-fileupload> | ||||
|  | ||||
|             <dees-input-fileupload | ||||
|               .label=${'Disabled example'} | ||||
|               .description=${'Uploader is disabled while moderation is pending'} | ||||
|               .disabled=${true} | ||||
|             ></dees-input-fileupload> | ||||
|           </div> | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel | ||||
|         .title=${'Form integration'} | ||||
|         .subtitle=${'Combine file uploads with the rest of the DEES form ecosystem'} | ||||
|       > | ||||
|         <div class="demo-grid"> | ||||
|           <dees-form> | ||||
|             <div class="demo-stack"> | ||||
|               <dees-input-text | ||||
|                 .label=${'Project name'} | ||||
|                 .description=${'How should we refer to this project internally?'} | ||||
|                 .required=${true} | ||||
|                 .key=${'projectName'} | ||||
|               ></dees-input-text> | ||||
|  | ||||
|               <dees-input-text | ||||
|                 .label=${'Contact email'} | ||||
|                 .inputType=${'email'} | ||||
|                 .required=${true} | ||||
|                 .key=${'contactEmail'} | ||||
|               ></dees-input-text> | ||||
|  | ||||
|               <dees-input-fileupload | ||||
|                 .label=${'Statement of work'} | ||||
|                 .description=${'Upload a signed statement of work (PDF, max 15MB)'} | ||||
|                 .required=${true} | ||||
|                 .accept=${'application/pdf'} | ||||
|                 .maxSize=${15 * 1024 * 1024} | ||||
|                 .multiple=${false} | ||||
|                 .key=${'sow'} | ||||
|               ></dees-input-fileupload> | ||||
|  | ||||
|               <dees-input-fileupload | ||||
|                 .label=${'Creative references'} | ||||
|                 .description=${'Optional. Upload up to five visual references'} | ||||
|                 .accept=${'image/*'} | ||||
|                 .maxFiles=${5} | ||||
|                 .maxSize=${8 * 1024 * 1024} | ||||
|                 .key=${'references'} | ||||
|               ></dees-input-fileupload> | ||||
|  | ||||
|               <dees-input-text | ||||
|                 .label=${'Notes'} | ||||
|                 .description=${'Add optional context for reviewers'} | ||||
|                 .inputType=${'textarea'} | ||||
|                 .key=${'notes'} | ||||
|               ></dees-input-text> | ||||
|  | ||||
|               <dees-form-submit .text=${'Submit briefing'}></dees-form-submit> | ||||
|             </div> | ||||
|           </dees-form> | ||||
|  | ||||
|           <div class="demo-note"> | ||||
|             <strong>Good to know:</strong> | ||||
|             <ul> | ||||
|               <li>Drag & drop highlights the dropzone and supports keyboard activation.</li> | ||||
|               <li>Accepted file types are summarised automatically from the <code>accept</code> attribute.</li> | ||||
|               <li>Image uploads show live previews generated via <code>URL.createObjectURL</code>.</li> | ||||
|               <li>File size and file-count limits surface inline validation messages.</li> | ||||
|               <li>The component stays compatible with <code>dees-form</code> value accessors.</li> | ||||
|             </ul> | ||||
|           </div> | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|     </div> | ||||
|   </dees-demowrapper> | ||||
| `; | ||||
							
								
								
									
										2
									
								
								ts_web/elements/dees-input-fileupload/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								ts_web/elements/dees-input-fileupload/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| export * from './component.js'; | ||||
| export { fileuploadStyles } from './styles.js'; | ||||
							
								
								
									
										313
									
								
								ts_web/elements/dees-input-fileupload/styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										313
									
								
								ts_web/elements/dees-input-fileupload/styles.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,313 @@ | ||||
| import { css, cssManager } from '@design.estate/dees-element'; | ||||
| import { DeesInputBase } from '../dees-input-base.js'; | ||||
|  | ||||
| export const fileuploadStyles = [ | ||||
|   cssManager.defaultStyles, | ||||
|   ...DeesInputBase.baseStyles, | ||||
|   css` | ||||
|     :host { | ||||
|       position: relative; | ||||
|       display: block; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     .input-wrapper { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 12px; | ||||
|     } | ||||
|  | ||||
|     .dropzone { | ||||
|       position: relative; | ||||
|       padding: 20px; | ||||
|       border-radius: 12px; | ||||
|       border: 1.5px dashed ${cssManager.bdTheme('hsl(215 16% 80%)', 'hsl(217 20% 25%)')}; | ||||
|       background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')}; | ||||
|       transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; | ||||
|       cursor: pointer; | ||||
|       outline: none; | ||||
|     } | ||||
|  | ||||
|     .dropzone:focus-visible { | ||||
|       box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')}, | ||||
|         0 0 0 4px ${cssManager.bdTheme('hsl(217 91% 60% / 0.5)', 'hsl(213 93% 68% / 0.4)')}; | ||||
|       border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; | ||||
|     } | ||||
|  | ||||
|     .dropzone--active { | ||||
|       border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; | ||||
|       box-shadow: 0 12px 32px ${cssManager.bdTheme('rgba(15, 23, 42, 0.12)', 'rgba(0, 0, 0, 0.35)')}; | ||||
|       background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.06)', 'hsl(213 93% 68% / 0.12)')}; | ||||
|     } | ||||
|  | ||||
|     .dropzone--has-files { | ||||
|       background: ${cssManager.bdTheme('hsl(0 0% 99%)', 'hsl(215 20% 11%)')}; | ||||
|     } | ||||
|  | ||||
|     .dropzone--disabled { | ||||
|       opacity: 0.6; | ||||
|       pointer-events: none; | ||||
|       cursor: not-allowed; | ||||
|     } | ||||
|  | ||||
|     .dropzone__body { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       gap: 16px; | ||||
|     } | ||||
|  | ||||
|     .dropzone__icon { | ||||
|       width: 48px; | ||||
|       height: 48px; | ||||
|       border-radius: 16px; | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|       color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; | ||||
|       background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.12)', 'hsl(213 93% 68% / 0.12)')}; | ||||
|       position: relative; | ||||
|       flex-shrink: 0; | ||||
|     } | ||||
|  | ||||
|     .dropzone__icon dees-icon { | ||||
|       font-size: 22px; | ||||
|     } | ||||
|  | ||||
|     .dropzone__loader { | ||||
|       width: 20px; | ||||
|       height: 20px; | ||||
|       border-radius: 999px; | ||||
|       border: 2px solid ${cssManager.bdTheme('rgba(15, 23, 42, 0.15)', 'rgba(255, 255, 255, 0.15)')}; | ||||
|       border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; | ||||
|       animation: loader-spin 0.6s linear infinite; | ||||
|     } | ||||
|  | ||||
|     .dropzone__content { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 4px; | ||||
|       min-width: 0; | ||||
|     } | ||||
|  | ||||
|     .dropzone__headline { | ||||
|       font-size: 15px; | ||||
|       font-weight: 600; | ||||
|       color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')}; | ||||
|     } | ||||
|  | ||||
|     .dropzone__subline { | ||||
|       font-size: 13px; | ||||
|       color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')}; | ||||
|     } | ||||
|  | ||||
|     .dropzone__browse { | ||||
|       appearance: none; | ||||
|       border: none; | ||||
|       background: none; | ||||
|       padding: 0; | ||||
|       margin-left: 4px; | ||||
|       color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; | ||||
|       font-weight: 600; | ||||
|       cursor: pointer; | ||||
|       text-decoration: none; | ||||
|     } | ||||
|  | ||||
|     .dropzone__browse:hover { | ||||
|       text-decoration: underline; | ||||
|     } | ||||
|  | ||||
|     .dropzone__browse:disabled { | ||||
|       cursor: not-allowed; | ||||
|       opacity: 0.6; | ||||
|     } | ||||
|  | ||||
|     .dropzone__meta { | ||||
|       margin-top: 14px; | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; | ||||
|       gap: 8px; | ||||
|       font-size: 12px; | ||||
|       color: ${cssManager.bdTheme('hsl(215 16% 50%)', 'hsl(215 16% 72%)')}; | ||||
|     } | ||||
|  | ||||
|     .dropzone__meta span { | ||||
|       padding: 4px 10px; | ||||
|       border-radius: 999px; | ||||
|       background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(213 93% 18%)')}; | ||||
|       border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')}; | ||||
|     } | ||||
|  | ||||
|     .file-list { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 12px; | ||||
|       margin-top: 20px; | ||||
|       padding-top: 20px; | ||||
|       border-top: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')}; | ||||
|     } | ||||
|  | ||||
|     .file-list__header { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: space-between; | ||||
|       font-size: 13px; | ||||
|       font-weight: 500; | ||||
|       color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 68%)')}; | ||||
|     } | ||||
|  | ||||
|     .file-list__clear { | ||||
|       appearance: none; | ||||
|       border: none; | ||||
|       background: none; | ||||
|       color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')}; | ||||
|       cursor: pointer; | ||||
|       font-weight: 500; | ||||
|       font-size: 13px; | ||||
|       padding: 0; | ||||
|     } | ||||
|  | ||||
|     .file-list__clear:hover { | ||||
|       text-decoration: underline; | ||||
|     } | ||||
|  | ||||
|     .file-list__items { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 12px; | ||||
|     } | ||||
|  | ||||
|     .file-row { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       gap: 12px; | ||||
|       padding: 10px 12px; | ||||
|       background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.5)', 'hsl(215 20% 16% / 0.5)')}; | ||||
|       border: 1px solid ${cssManager.bdTheme('hsl(213 27% 92%)', 'hsl(217 25% 26%)')}; | ||||
|       border-radius: 8px; | ||||
|       transition: background 0.15s ease; | ||||
|     } | ||||
|  | ||||
|     .file-row:hover { | ||||
|       background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.8)', 'hsl(215 20% 16% / 0.8)')}; | ||||
|     } | ||||
|  | ||||
|     .file-thumb { | ||||
|       width: 36px; | ||||
|       height: 36px; | ||||
|       border-radius: 8px; | ||||
|       background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 32% 18%)')}; | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|       overflow: hidden; | ||||
|       flex-shrink: 0; | ||||
|     } | ||||
|  | ||||
|     .file-thumb dees-icon { | ||||
|       font-size: 18px; | ||||
|       color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 70%)')}; | ||||
|       display: block; | ||||
|       width: 18px; | ||||
|       height: 18px; | ||||
|       line-height: 1; | ||||
|       flex-shrink: 0; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     .thumb-image { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       object-fit: cover; | ||||
|     } | ||||
|  | ||||
|     .file-meta { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 4px; | ||||
|       min-width: 0; | ||||
|     } | ||||
|  | ||||
|     .file-name { | ||||
|       font-weight: 600; | ||||
|       font-size: 14px; | ||||
|       color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')}; | ||||
|       white-space: nowrap; | ||||
|       overflow: hidden; | ||||
|       text-overflow: ellipsis; | ||||
|     } | ||||
|  | ||||
|     .file-details { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       gap: 8px; | ||||
|       flex-wrap: wrap; | ||||
|       font-size: 12px; | ||||
|       color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')}; | ||||
|     } | ||||
|  | ||||
|     .file-size { | ||||
|       font-variant-numeric: tabular-nums; | ||||
|     } | ||||
|  | ||||
|     .file-type { | ||||
|       padding: 2px 8px; | ||||
|       border-radius: 999px; | ||||
|       border: 1px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 32% 28%)')}; | ||||
|       color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')}; | ||||
|       text-transform: uppercase; | ||||
|       letter-spacing: 0.08em; | ||||
|       line-height: 1; | ||||
|     } | ||||
|  | ||||
|     .file-actions { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       gap: 8px; | ||||
|       margin-left: auto; | ||||
|     } | ||||
|  | ||||
|     .remove-button { | ||||
|       width: 28px; | ||||
|       height: 28px; | ||||
|       border-radius: 6px; | ||||
|       background: transparent; | ||||
|       border: none; | ||||
|       cursor: pointer; | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|       transition: background 0.15s ease, transform 0.15s ease, color 0.15s ease; | ||||
|       color: ${cssManager.bdTheme('hsl(215 16% 52%)', 'hsl(215 16% 68%)')}; | ||||
|     } | ||||
|  | ||||
|     .remove-button:hover { | ||||
|       background: ${cssManager.bdTheme('hsl(0 72% 50% / 0.08)', 'hsl(0 62% 32% / 0.15)')}; | ||||
|       color: ${cssManager.bdTheme('hsl(0 72% 46%)', 'hsl(0 70% 70%)')}; | ||||
|     } | ||||
|  | ||||
|     .remove-button:active { | ||||
|       transform: scale(0.96); | ||||
|     } | ||||
|  | ||||
|     .remove-button dees-icon { | ||||
|       display: block; | ||||
|       width: 14px; | ||||
|       height: 14px; | ||||
|       font-size: 14px; | ||||
|       line-height: 1; | ||||
|       flex-shrink: 0; | ||||
|     } | ||||
|  | ||||
|     .validation-message { | ||||
|       font-size: 13px; | ||||
|       color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')}; | ||||
|       line-height: 1.5; | ||||
|     } | ||||
|  | ||||
|     @keyframes loader-spin { | ||||
|       to { | ||||
|         transform: rotate(360deg); | ||||
|       } | ||||
|     } | ||||
|   `, | ||||
| ]; | ||||
							
								
								
									
										275
									
								
								ts_web/elements/dees-input-list.demo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										275
									
								
								ts_web/elements/dees-input-list.demo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,275 @@ | ||||
| import { html, css } from '@design.estate/dees-element'; | ||||
| import '@design.estate/dees-wcctools/demotools'; | ||||
| import './dees-panel.js'; | ||||
| import './dees-form.js'; | ||||
| import './dees-input-text.js'; | ||||
| import './dees-form-submit.js'; | ||||
|  | ||||
| export const demoFunc = () => html` | ||||
|   <dees-demowrapper> | ||||
|     <style> | ||||
|       ${css` | ||||
|         .demo-container { | ||||
|           display: flex; | ||||
|           flex-direction: column; | ||||
|           gap: 24px; | ||||
|           padding: 24px; | ||||
|           max-width: 1200px; | ||||
|           margin: 0 auto; | ||||
|         } | ||||
|          | ||||
|         dees-panel { | ||||
|           margin-bottom: 24px; | ||||
|         } | ||||
|          | ||||
|         dees-panel:last-child { | ||||
|           margin-bottom: 0; | ||||
|         } | ||||
|          | ||||
|         .grid-layout { | ||||
|           display: grid; | ||||
|           grid-template-columns: 1fr 1fr; | ||||
|           gap: 16px; | ||||
|         } | ||||
|          | ||||
|         @media (max-width: 768px) { | ||||
|           .grid-layout { | ||||
|             grid-template-columns: 1fr; | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         .output-preview { | ||||
|           margin-top: 16px; | ||||
|           padding: 16px; | ||||
|           background: #f3f4f6; | ||||
|           border-radius: 4px; | ||||
|           font-size: 12px; | ||||
|           color: #374151; | ||||
|           word-break: break-all; | ||||
|           max-height: 200px; | ||||
|           overflow-y: auto; | ||||
|         } | ||||
|          | ||||
|         @media (prefers-color-scheme: dark) { | ||||
|           .output-preview { | ||||
|             background: #2c2c2c; | ||||
|             color: #e4e4e7; | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         .feature-note { | ||||
|           margin-top: 12px; | ||||
|           padding: 12px; | ||||
|           background: #eff6ff; | ||||
|           border-left: 3px solid #3b82f6; | ||||
|           border-radius: 4px; | ||||
|           font-size: 13px; | ||||
|           color: #1e40af; | ||||
|         } | ||||
|          | ||||
|         @media (prefers-color-scheme: dark) { | ||||
|           .feature-note { | ||||
|             background: #1e3a5f; | ||||
|             color: #93c5fd; | ||||
|           } | ||||
|         } | ||||
|       `} | ||||
|     </style> | ||||
|      | ||||
|     <div class="demo-container"> | ||||
|       <dees-panel .title=${'1. Basic List Input'} .subtitle=${'Simple list management with add, edit, and delete'}> | ||||
|         <dees-input-list | ||||
|           .label=${'Shopping List'} | ||||
|           .placeholder=${'Add item to your list...'} | ||||
|           .value=${['Milk', 'Bread', 'Eggs', 'Cheese']} | ||||
|           .description=${'Double-click to edit items, or use the edit button'} | ||||
|         ></dees-input-list> | ||||
|         <div class="feature-note"> | ||||
|           💡 Double-click any item to quickly edit it inline | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel .title=${'2. Sortable List'} .subtitle=${'Drag and drop to reorder items'}> | ||||
|         <dees-input-list | ||||
|           .label=${'Task Priority'} | ||||
|           .placeholder=${'Add a task...'} | ||||
|           .sortable=${true} | ||||
|           .value=${[ | ||||
|             'Review pull requests', | ||||
|             'Fix critical bug', | ||||
|             'Update documentation', | ||||
|             'Deploy to production', | ||||
|             'Team standup meeting' | ||||
|           ]} | ||||
|           .description=${'Drag items using the handle to reorder them'} | ||||
|         ></dees-input-list> | ||||
|         <div class="feature-note"> | ||||
|           🔄 Drag the grip handle to reorder tasks by priority | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel .title=${'3. Validation & Constraints'} .subtitle=${'Lists with minimum/maximum items and duplicate prevention'}> | ||||
|         <div class="grid-layout"> | ||||
|           <dees-input-list | ||||
|             .label=${'Team Members (Min 2, Max 5)'} | ||||
|             .placeholder=${'Add team member...'} | ||||
|             .minItems=${2} | ||||
|             .maxItems=${5} | ||||
|             .value=${['Alice', 'Bob']} | ||||
|             .required=${true} | ||||
|             .description=${'Add 2-5 team members'} | ||||
|           ></dees-input-list> | ||||
|            | ||||
|           <dees-input-list | ||||
|             .label=${'Unique Tags (No Duplicates)'} | ||||
|             .placeholder=${'Add unique tag...'} | ||||
|             .allowDuplicates=${false} | ||||
|             .value=${['frontend', 'backend', 'database']} | ||||
|             .description=${'Duplicate items are not allowed'} | ||||
|           ></dees-input-list> | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel .title=${'4. Delete Confirmation'} .subtitle=${'Require confirmation before deleting items'}> | ||||
|         <dees-input-list | ||||
|           .label=${'Important Documents'} | ||||
|           .placeholder=${'Add document name...'} | ||||
|           .confirmDelete=${true} | ||||
|           .value=${[ | ||||
|             'Contract_2024.pdf', | ||||
|             'Financial_Report_Q3.xlsx', | ||||
|             'Project_Proposal.docx', | ||||
|             'Meeting_Notes.txt' | ||||
|           ]} | ||||
|           .description=${'Deletion requires confirmation for safety'} | ||||
|         ></dees-input-list> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only list display'}> | ||||
|         <dees-input-list | ||||
|           .label=${'System Defaults'} | ||||
|           .value=${['Default Setting 1', 'Default Setting 2', 'Default Setting 3']} | ||||
|           .disabled=${true} | ||||
|           .description=${'These items cannot be modified'} | ||||
|         ></dees-input-list> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel .title=${'6. Form Integration'} .subtitle=${'List input working within a form context'}> | ||||
|         <dees-form> | ||||
|           <dees-input-text | ||||
|             .label=${'Recipe Name'} | ||||
|             .placeholder=${'My Amazing Recipe'} | ||||
|             .required=${true} | ||||
|             .key=${'name'} | ||||
|           ></dees-input-text> | ||||
|            | ||||
|           <div class="grid-layout"> | ||||
|             <dees-input-list | ||||
|               .label=${'Ingredients'} | ||||
|               .placeholder=${'Add ingredient...'} | ||||
|               .required=${true} | ||||
|               .minItems=${3} | ||||
|               .key=${'ingredients'} | ||||
|               .sortable=${true} | ||||
|               .value=${[ | ||||
|                 '2 cups flour', | ||||
|                 '1 cup sugar', | ||||
|                 '3 eggs' | ||||
|               ]} | ||||
|               .description=${'Add at least 3 ingredients'} | ||||
|             ></dees-input-list> | ||||
|              | ||||
|             <dees-input-list | ||||
|               .label=${'Instructions'} | ||||
|               .placeholder=${'Add instruction step...'} | ||||
|               .required=${true} | ||||
|               .minItems=${2} | ||||
|               .key=${'instructions'} | ||||
|               .sortable=${true} | ||||
|               .value=${[ | ||||
|                 'Preheat oven to 350°F', | ||||
|                 'Mix dry ingredients' | ||||
|               ]} | ||||
|               .description=${'Add cooking instructions in order'} | ||||
|             ></dees-input-list> | ||||
|           </div> | ||||
|            | ||||
|           <dees-input-text | ||||
|             .label=${'Notes'} | ||||
|             .inputType=${'textarea'} | ||||
|             .placeholder=${'Any special notes or tips...'} | ||||
|             .key=${'notes'} | ||||
|           ></dees-input-text> | ||||
|            | ||||
|           <dees-form-submit .text=${'Save Recipe'}></dees-form-submit> | ||||
|         </dees-form> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel .title=${'7. Interactive Demo'} .subtitle=${'Build your own feature list and see the data'}> | ||||
|         <dees-input-list | ||||
|           id="interactive-list" | ||||
|           .label=${'Product Features'} | ||||
|           .placeholder=${'Add a feature...'} | ||||
|           .sortable=${true} | ||||
|           .confirmDelete=${false} | ||||
|           .allowDuplicates=${false} | ||||
|           .maxItems=${10} | ||||
|           @change=${(e: CustomEvent) => { | ||||
|             const preview = document.querySelector('#list-json'); | ||||
|             if (preview) { | ||||
|               const data = { | ||||
|                 items: e.detail.value, | ||||
|                 count: e.detail.value.length, | ||||
|                 timestamp: new Date().toISOString() | ||||
|               }; | ||||
|               preview.textContent = JSON.stringify(data, null, 2); | ||||
|             } | ||||
|           }} | ||||
|         ></dees-input-list> | ||||
|          | ||||
|         <div class="output-preview" id="list-json"> | ||||
|           { | ||||
|             "items": [], | ||||
|             "count": 0, | ||||
|             "timestamp": "${new Date().toISOString()}" | ||||
|           } | ||||
|         </div> | ||||
|          | ||||
|         <div class="feature-note"> | ||||
|           ✨ Add, edit, remove, and reorder items to see the JSON output update in real-time | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel .title=${'8. Advanced Configuration'} .subtitle=${'Combine all features for complex use cases'}> | ||||
|         <dees-input-list | ||||
|           .label=${'Project Milestones'} | ||||
|           .placeholder=${'Add milestone...'} | ||||
|           .value=${[ | ||||
|             'Project Kickoff - Week 1', | ||||
|             'Requirements Gathering - Week 2-3', | ||||
|             'Design Phase - Week 4-6', | ||||
|             'Development Sprint 1 - Week 7-9', | ||||
|             'Testing & QA - Week 10-11', | ||||
|             'Deployment - Week 12' | ||||
|           ]} | ||||
|           .sortable=${true} | ||||
|           .confirmDelete=${true} | ||||
|           .allowDuplicates=${false} | ||||
|           .minItems=${3} | ||||
|           .maxItems=${12} | ||||
|           .required=${true} | ||||
|           .description=${'Manage project milestones (3-12 items, sortable, no duplicates)'} | ||||
|         ></dees-input-list> | ||||
|       </dees-panel> | ||||
|  | ||||
|       <dees-panel .title=${'9. Empty State'} .subtitle=${'How the component looks with no items'}> | ||||
|         <dees-input-list | ||||
|           .label=${'Your Ideas'} | ||||
|           .placeholder=${'Share your ideas...'} | ||||
|           .value=${[]} | ||||
|           .description=${'Start adding items to build your list'} | ||||
|         ></dees-input-list> | ||||
|       </dees-panel> | ||||
|     </div> | ||||
|   </dees-demowrapper> | ||||
| `; | ||||
							
								
								
									
										622
									
								
								ts_web/elements/dees-input-list.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										622
									
								
								ts_web/elements/dees-input-list.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,622 @@ | ||||
| import { | ||||
|   customElement, | ||||
|   html, | ||||
|   css, | ||||
|   cssManager, | ||||
|   property, | ||||
|   state, | ||||
|   type TemplateResult, | ||||
| } from '@design.estate/dees-element'; | ||||
| import { DeesInputBase } from './dees-input-base.js'; | ||||
| import './dees-icon.js'; | ||||
| import './dees-button.js'; | ||||
| import { demoFunc } from './dees-input-list.demo.js'; | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     'dees-input-list': DeesInputList; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @customElement('dees-input-list') | ||||
| export class DeesInputList extends DeesInputBase<DeesInputList> { | ||||
|   // STATIC | ||||
|   public static demo = demoFunc; | ||||
|  | ||||
|   // INSTANCE | ||||
|   @property({ type: Array }) | ||||
|   public value: string[] = []; | ||||
|  | ||||
|   @property({ type: String }) | ||||
|   public placeholder: string = 'Add new item...'; | ||||
|  | ||||
|   @property({ type: Number }) | ||||
|   public maxItems: number = 0; // 0 means unlimited | ||||
|  | ||||
|   @property({ type: Number }) | ||||
|   public minItems: number = 0; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   public allowDuplicates: boolean = false; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   public sortable: boolean = false; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   public confirmDelete: boolean = false; | ||||
|  | ||||
|   @property({ type: String }) | ||||
|   public validationText: string = ''; | ||||
|  | ||||
|   @state() | ||||
|   private inputValue: string = ''; | ||||
|  | ||||
|   @state() | ||||
|   private editingIndex: number = -1; | ||||
|  | ||||
|   @state() | ||||
|   private editingValue: string = ''; | ||||
|  | ||||
|   @state() | ||||
|   private draggedIndex: number = -1; | ||||
|  | ||||
|   @state() | ||||
|   private dragOverIndex: number = -1; | ||||
|  | ||||
|   public static styles = [ | ||||
|     ...DeesInputBase.baseStyles, | ||||
|     cssManager.defaultStyles, | ||||
|     css` | ||||
|       :host { | ||||
|         display: block; | ||||
|         font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; | ||||
|       } | ||||
|  | ||||
|       .input-wrapper { | ||||
|         width: 100%; | ||||
|       } | ||||
|  | ||||
|       .list-container { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         overflow: hidden; | ||||
|         transition: all 0.15s ease; | ||||
|       } | ||||
|  | ||||
|       .list-container:hover:not(.disabled) { | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .list-container:focus-within { | ||||
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; | ||||
|         box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')}; | ||||
|       } | ||||
|  | ||||
|       .list-container.disabled { | ||||
|         opacity: 0.6; | ||||
|         cursor: not-allowed; | ||||
|       } | ||||
|  | ||||
|       .list-items { | ||||
|         max-height: 400px; | ||||
|         overflow-y: auto; | ||||
|       } | ||||
|  | ||||
|       .list-item { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 8px; | ||||
|         padding: 12px 16px; | ||||
|         border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; | ||||
|         transition: all 0.15s ease; | ||||
|         position: relative; | ||||
|         overflow: hidden; /* Prevent animation from affecting scroll bounds */ | ||||
|       } | ||||
|  | ||||
|       .list-item:last-of-type { | ||||
|         border-bottom: none; | ||||
|       } | ||||
|  | ||||
|       .list-item:hover:not(.disabled) { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .list-item.dragging { | ||||
|         opacity: 0.4; | ||||
|         background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 10.8%)')}; | ||||
|       } | ||||
|  | ||||
|       .list-item.drag-over { | ||||
|         background: ${cssManager.bdTheme('hsl(210 40% 93.1%)', 'hsl(215 20.2% 13.8%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; | ||||
|       } | ||||
|  | ||||
|       .drag-handle { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         cursor: move; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; | ||||
|         transition: color 0.15s ease; | ||||
|       } | ||||
|  | ||||
|       .drag-handle:hover { | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .drag-handle dees-icon { | ||||
|         width: 16px; | ||||
|         height: 16px; | ||||
|       } | ||||
|  | ||||
|       .item-content { | ||||
|         flex: 1; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         min-width: 0; | ||||
|       } | ||||
|  | ||||
|       .item-text { | ||||
|         flex: 1; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|         font-size: 14px; | ||||
|         line-height: 20px; | ||||
|         word-break: break-word; | ||||
|       } | ||||
|  | ||||
|       .item-edit-input { | ||||
|         flex: 1; | ||||
|         padding: 4px 8px; | ||||
|         font-size: 14px; | ||||
|         font-family: inherit; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; | ||||
|         border-radius: 4px; | ||||
|         outline: none; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|       } | ||||
|  | ||||
|       .item-actions { | ||||
|         display: flex; | ||||
|         gap: 4px; | ||||
|         align-items: center; | ||||
|       } | ||||
|  | ||||
|       .action-button { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         width: 28px; | ||||
|         height: 28px; | ||||
|         border-radius: 4px; | ||||
|         background: transparent; | ||||
|         border: none; | ||||
|         cursor: pointer; | ||||
|         transition: all 0.15s ease; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button.save { | ||||
|         color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button.save:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button.cancel { | ||||
|         color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 50.6%)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button.cancel:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 50.6% / 0.1)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button.delete { | ||||
|         color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 50.6%)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button.delete:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 50.6% / 0.1)')}; | ||||
|       } | ||||
|  | ||||
|       .action-button dees-icon { | ||||
|         width: 14px; | ||||
|         height: 14px; | ||||
|       } | ||||
|  | ||||
|       .add-item-container { | ||||
|         display: flex; | ||||
|         gap: 8px; | ||||
|         padding: 12px 16px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')}; | ||||
|         border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .add-input { | ||||
|         flex: 1; | ||||
|         padding: 8px 12px; | ||||
|         font-size: 14px; | ||||
|         font-family: inherit; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 4px; | ||||
|         outline: none; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|         transition: all 0.15s ease; | ||||
|       } | ||||
|  | ||||
|       .add-input:focus { | ||||
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; | ||||
|         box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')}; | ||||
|       } | ||||
|  | ||||
|       .add-input::placeholder { | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; | ||||
|       } | ||||
|  | ||||
|       .add-input:disabled { | ||||
|         cursor: not-allowed; | ||||
|         opacity: 0.5; | ||||
|       } | ||||
|  | ||||
|       .add-button { | ||||
|         padding: 8px 16px; | ||||
|       } | ||||
|  | ||||
|       .empty-state { | ||||
|         padding: 32px 16px; | ||||
|         text-align: center; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')}; | ||||
|         font-size: 14px; | ||||
|         font-style: italic; | ||||
|       } | ||||
|  | ||||
|       .validation-message { | ||||
|         color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; | ||||
|         font-size: 13px; | ||||
|         margin-top: 6px; | ||||
|         line-height: 1.5; | ||||
|       } | ||||
|  | ||||
|       .description { | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; | ||||
|         font-size: 13px; | ||||
|         margin-top: 6px; | ||||
|         line-height: 1.5; | ||||
|       } | ||||
|  | ||||
|       /* Scrollbar styling */ | ||||
|       .list-items::-webkit-scrollbar { | ||||
|         width: 8px; | ||||
|       } | ||||
|  | ||||
|       .list-items::-webkit-scrollbar-track { | ||||
|         background: transparent; | ||||
|       } | ||||
|  | ||||
|       .list-items::-webkit-scrollbar-thumb { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 24.9%)')}; | ||||
|         border-radius: 4px; | ||||
|       } | ||||
|  | ||||
|       .list-items::-webkit-scrollbar-thumb:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 34.9%)')}; | ||||
|       } | ||||
|  | ||||
|       /* Animation for adding/removing items */ | ||||
|       @keyframes slideIn { | ||||
|         from { | ||||
|           opacity: 0; | ||||
|           transform: translateY(-10px); | ||||
|         } | ||||
|         to { | ||||
|           opacity: 1; | ||||
|           transform: translateY(0); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .list-item { | ||||
|         animation: slideIn 0.2s ease; | ||||
|       } | ||||
|  | ||||
|       /* Override any inherited contain/content-visibility that might cause scrolling issues */ | ||||
|       .list-items, .list-item { | ||||
|         content-visibility: visible !important; | ||||
|         contain: none !important; | ||||
|         contain-intrinsic-size: auto !important; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|  | ||||
|   public render(): TemplateResult { | ||||
|     return html` | ||||
|       <div class="input-wrapper"> | ||||
|         ${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''} | ||||
|          | ||||
|         <div class="list-container ${this.disabled ? 'disabled' : ''}"> | ||||
|           <div class="list-items"> | ||||
|             ${this.value.length > 0 ? this.value.map((item, index) => html` | ||||
|               <div | ||||
|                 class="list-item ${this.draggedIndex === index ? 'dragging' : ''} ${this.dragOverIndex === index ? 'drag-over' : ''}" | ||||
|                 draggable="${this.sortable && !this.disabled}" | ||||
|                 @dragstart=${(e: DragEvent) => this.handleDragStart(e, index)} | ||||
|                 @dragend=${this.handleDragEnd} | ||||
|                 @dragover=${(e: DragEvent) => this.handleDragOver(e, index)} | ||||
|                 @dragleave=${this.handleDragLeave} | ||||
|                 @drop=${(e: DragEvent) => this.handleDrop(e, index)} | ||||
|               > | ||||
|                 ${this.sortable && !this.disabled ? html` | ||||
|                   <div class="drag-handle"> | ||||
|                     <dees-icon .icon=${'lucide:gripVertical'}></dees-icon> | ||||
|                   </div> | ||||
|                 ` : ''} | ||||
|                  | ||||
|                 <div class="item-content"> | ||||
|                   ${this.editingIndex === index ? html` | ||||
|                     <input | ||||
|                       type="text" | ||||
|                       class="item-edit-input" | ||||
|                       .value=${this.editingValue} | ||||
|                       @input=${(e: InputEvent) => this.editingValue = (e.target as HTMLInputElement).value} | ||||
|                       @keydown=${(e: KeyboardEvent) => this.handleEditKeyDown(e, index)} | ||||
|                       @blur=${() => this.saveEdit(index)} | ||||
|                     /> | ||||
|                   ` : html` | ||||
|                     <div class="item-text" @dblclick=${() => !this.disabled && this.startEdit(index)}> | ||||
|                       ${item} | ||||
|                     </div> | ||||
|                   `} | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="item-actions"> | ||||
|                   ${this.editingIndex === index ? html` | ||||
|                     <button class="action-button save" @click=${() => this.saveEdit(index)}> | ||||
|                       <dees-icon .icon=${'lucide:check'}></dees-icon> | ||||
|                     </button> | ||||
|                     <button class="action-button cancel" @click=${() => this.cancelEdit()}> | ||||
|                       <dees-icon .icon=${'lucide:x'}></dees-icon> | ||||
|                     </button> | ||||
|                   ` : html` | ||||
|                     ${!this.disabled ? html` | ||||
|                       <button class="action-button" @click=${() => this.startEdit(index)}> | ||||
|                         <dees-icon .icon=${'lucide:pencil'}></dees-icon> | ||||
|                       </button> | ||||
|                       <button class="action-button delete" @click=${() => this.removeItem(index)}> | ||||
|                         <dees-icon .icon=${'lucide:trash2'}></dees-icon> | ||||
|                       </button> | ||||
|                     ` : ''} | ||||
|                   `} | ||||
|                 </div> | ||||
|               </div> | ||||
|             `) : html` | ||||
|               <div class="empty-state"> | ||||
|                 No items added yet | ||||
|               </div> | ||||
|             `} | ||||
|           </div> | ||||
|            | ||||
|           ${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html` | ||||
|             <div class="add-item-container"> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 class="add-input" | ||||
|                 .placeholder=${this.placeholder} | ||||
|                 .value=${this.inputValue} | ||||
|                 @input=${this.handleInput} | ||||
|                 @keydown=${this.handleAddKeyDown} | ||||
|                 ?disabled=${this.disabled} | ||||
|               /> | ||||
|               <dees-button | ||||
|                 class="add-button" | ||||
|                 @click=${this.addItem} | ||||
|                 ?disabled=${!this.inputValue.trim()} | ||||
|               > | ||||
|                 <dees-icon .icon=${'lucide:plus'}></dees-icon> Add | ||||
|               </dees-button> | ||||
|             </div> | ||||
|           ` : ''} | ||||
|         </div> | ||||
|  | ||||
|         ${this.validationText ? html` | ||||
|           <div class="validation-message">${this.validationText}</div> | ||||
|         ` : ''} | ||||
|          | ||||
|         ${this.description ? html` | ||||
|           <div class="description">${this.description}</div> | ||||
|         ` : ''} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private handleInput(e: InputEvent) { | ||||
|     this.inputValue = (e.target as HTMLInputElement).value; | ||||
|   } | ||||
|  | ||||
|   private handleAddKeyDown(e: KeyboardEvent) { | ||||
|     if (e.key === 'Enter' && this.inputValue.trim()) { | ||||
|       e.preventDefault(); | ||||
|       this.addItem(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private handleEditKeyDown(e: KeyboardEvent, index: number) { | ||||
|     if (e.key === 'Enter') { | ||||
|       e.preventDefault(); | ||||
|       this.saveEdit(index); | ||||
|     } else if (e.key === 'Escape') { | ||||
|       e.preventDefault(); | ||||
|       this.cancelEdit(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private addItem() { | ||||
|     const trimmedValue = this.inputValue.trim(); | ||||
|     if (!trimmedValue) return; | ||||
|  | ||||
|     if (!this.allowDuplicates && this.value.includes(trimmedValue)) { | ||||
|       this.validationText = 'This item already exists in the list'; | ||||
|       setTimeout(() => this.validationText = '', 3000); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (this.maxItems && this.value.length >= this.maxItems) { | ||||
|       this.validationText = `Maximum ${this.maxItems} items allowed`; | ||||
|       setTimeout(() => this.validationText = '', 3000); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.value = [...this.value, trimmedValue]; | ||||
|     this.inputValue = ''; | ||||
|     this.validationText = ''; | ||||
|      | ||||
|     // Clear the input | ||||
|     const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement; | ||||
|     if (input) { | ||||
|       input.value = ''; | ||||
|       input.focus(); | ||||
|     } | ||||
|  | ||||
|     this.emitChange(); | ||||
|   } | ||||
|  | ||||
|   private startEdit(index: number) { | ||||
|     this.editingIndex = index; | ||||
|     this.editingValue = this.value[index]; | ||||
|      | ||||
|     // Focus the input after render | ||||
|     this.updateComplete.then(() => { | ||||
|       const input = this.shadowRoot?.querySelector('.item-edit-input') as HTMLInputElement; | ||||
|       if (input) { | ||||
|         input.focus(); | ||||
|         input.select(); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private saveEdit(index: number) { | ||||
|     const trimmedValue = this.editingValue.trim(); | ||||
|      | ||||
|     if (!trimmedValue) { | ||||
|       this.cancelEdit(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!this.allowDuplicates && trimmedValue !== this.value[index] && this.value.includes(trimmedValue)) { | ||||
|       this.validationText = 'This item already exists in the list'; | ||||
|       setTimeout(() => this.validationText = '', 3000); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const newValue = [...this.value]; | ||||
|     newValue[index] = trimmedValue; | ||||
|     this.value = newValue; | ||||
|      | ||||
|     this.editingIndex = -1; | ||||
|     this.editingValue = ''; | ||||
|     this.validationText = ''; | ||||
|     this.emitChange(); | ||||
|   } | ||||
|  | ||||
|   private cancelEdit() { | ||||
|     this.editingIndex = -1; | ||||
|     this.editingValue = ''; | ||||
|   } | ||||
|  | ||||
|   private async removeItem(index: number) { | ||||
|     if (this.confirmDelete) { | ||||
|       const confirmed = await this.showConfirmDialog(`Delete "${this.value[index]}"?`); | ||||
|       if (!confirmed) return; | ||||
|     } | ||||
|  | ||||
|     this.value = this.value.filter((_, i) => i !== index); | ||||
|     this.emitChange(); | ||||
|   } | ||||
|  | ||||
|   private async showConfirmDialog(message: string): Promise<boolean> { | ||||
|     // For now, use native confirm. In production, this should use a proper modal | ||||
|     return confirm(message); | ||||
|   } | ||||
|  | ||||
|   // Drag and drop handlers | ||||
|   private handleDragStart(e: DragEvent, index: number) { | ||||
|     if (!this.sortable || this.disabled) return; | ||||
|      | ||||
|     this.draggedIndex = index; | ||||
|     e.dataTransfer!.effectAllowed = 'move'; | ||||
|     e.dataTransfer!.setData('text/plain', index.toString()); | ||||
|   } | ||||
|  | ||||
|   private handleDragEnd() { | ||||
|     this.draggedIndex = -1; | ||||
|     this.dragOverIndex = -1; | ||||
|   } | ||||
|  | ||||
|   private handleDragOver(e: DragEvent, index: number) { | ||||
|     if (!this.sortable || this.disabled) return; | ||||
|      | ||||
|     e.preventDefault(); | ||||
|     e.dataTransfer!.dropEffect = 'move'; | ||||
|     this.dragOverIndex = index; | ||||
|   } | ||||
|  | ||||
|   private handleDragLeave() { | ||||
|     this.dragOverIndex = -1; | ||||
|   } | ||||
|  | ||||
|   private handleDrop(e: DragEvent, dropIndex: number) { | ||||
|     if (!this.sortable || this.disabled) return; | ||||
|      | ||||
|     e.preventDefault(); | ||||
|     const draggedIndex = parseInt(e.dataTransfer!.getData('text/plain')); | ||||
|      | ||||
|     if (draggedIndex !== dropIndex) { | ||||
|       const newValue = [...this.value]; | ||||
|       const [draggedItem] = newValue.splice(draggedIndex, 1); | ||||
|       newValue.splice(dropIndex, 0, draggedItem); | ||||
|       this.value = newValue; | ||||
|       this.emitChange(); | ||||
|     } | ||||
|      | ||||
|     this.draggedIndex = -1; | ||||
|     this.dragOverIndex = -1; | ||||
|   } | ||||
|  | ||||
|   private emitChange() { | ||||
|     this.dispatchEvent(new CustomEvent('change', { | ||||
|       detail: { value: this.value }, | ||||
|       bubbles: true, | ||||
|       composed: true | ||||
|     })); | ||||
|     this.changeSubject.next(this); | ||||
|   } | ||||
|  | ||||
|   public getValue(): string[] { | ||||
|     return this.value; | ||||
|   } | ||||
|  | ||||
|   public setValue(value: string[]): void { | ||||
|     this.value = value || []; | ||||
|   } | ||||
|  | ||||
|   public async validate(): Promise<boolean> { | ||||
|     if (this.required && (!this.value || this.value.length === 0)) { | ||||
|       this.validationText = 'At least one item is required'; | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     if (this.minItems && this.value.length < this.minItems) { | ||||
|       this.validationText = `At least ${this.minItems} items required`; | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     this.validationText = ''; | ||||
|     return true; | ||||
|   } | ||||
| } | ||||
| @@ -1,714 +0,0 @@ | ||||
| import * as colors from './00colors.js'; | ||||
| import { DeesInputBase } from './dees-input-base.js'; | ||||
| import { demoFunc } from './dees-input-richtext.demo.js'; | ||||
| import './dees-icon.js'; | ||||
|  | ||||
| import { | ||||
|   customElement, | ||||
|   type TemplateResult, | ||||
|   property, | ||||
|   html, | ||||
|   css, | ||||
|   cssManager, | ||||
|   state, | ||||
|   query, | ||||
| } from '@design.estate/dees-element'; | ||||
| import * as domtools from '@design.estate/dees-domtools'; | ||||
|  | ||||
| import { Editor } from '@tiptap/core'; | ||||
| import StarterKit from '@tiptap/starter-kit'; | ||||
| import Underline from '@tiptap/extension-underline'; | ||||
| import TextAlign from '@tiptap/extension-text-align'; | ||||
| import Link from '@tiptap/extension-link'; | ||||
| import Typography from '@tiptap/extension-typography'; | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     'dees-input-richtext': DeesInputRichtext; | ||||
|   } | ||||
| } | ||||
|  | ||||
| interface IToolbarButton { | ||||
|   name: string; | ||||
|   icon?: string; | ||||
|   action?: () => void; | ||||
|   isActive?: () => boolean; | ||||
|   title: string; | ||||
|   isDivider?: boolean; | ||||
| } | ||||
|  | ||||
| @customElement('dees-input-richtext') | ||||
| export class DeesInputRichtext extends DeesInputBase<string> { | ||||
|   public static demo = demoFunc; | ||||
|  | ||||
|   // INSTANCE | ||||
|   @property({ | ||||
|     type: String, | ||||
|     reflect: true, | ||||
|   }) | ||||
|   public value: string = ''; | ||||
|  | ||||
|   @property({ | ||||
|     type: String, | ||||
|   }) | ||||
|   public placeholder: string = ''; | ||||
|  | ||||
|   @property({ | ||||
|     type: Boolean, | ||||
|   }) | ||||
|   public showWordCount: boolean = true; | ||||
|  | ||||
|   @property({ | ||||
|     type: Number, | ||||
|   }) | ||||
|   public minHeight: number = 200; | ||||
|  | ||||
|   @state() | ||||
|   private showLinkInput: boolean = false; | ||||
|  | ||||
|   @state() | ||||
|   private wordCount: number = 0; | ||||
|  | ||||
|   @query('.editor-content') | ||||
|   private editorElement: HTMLElement; | ||||
|  | ||||
|   @query('.link-input input') | ||||
|   private linkInputElement: HTMLInputElement; | ||||
|  | ||||
|   private editor: Editor; | ||||
|  | ||||
|   public static styles = [ | ||||
|     ...DeesInputBase.baseStyles, | ||||
|     cssManager.defaultStyles, | ||||
|     css` | ||||
|       :host { | ||||
|         display: block; | ||||
|         position: relative; | ||||
|         font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | ||||
|       } | ||||
|  | ||||
|       .input-wrapper { | ||||
|         position: relative; | ||||
|       } | ||||
|  | ||||
|       .label { | ||||
|         display: block; | ||||
|         margin-bottom: 8px; | ||||
|         font-size: 14px; | ||||
|         font-weight: 500; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         min-height: ${cssManager.bdTheme('200px', '200px')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         overflow: hidden; | ||||
|         transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | ||||
|       } | ||||
|  | ||||
|       .editor-container:hover { | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-container.focused { | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-toolbar { | ||||
|         display: flex; | ||||
|         flex-wrap: wrap; | ||||
|         gap: 4px; | ||||
|         padding: 8px 12px; | ||||
|         background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         align-items: center; | ||||
|         position: relative; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         width: 32px; | ||||
|         height: 32px; | ||||
|         border: none; | ||||
|         border-radius: 4px; | ||||
|         background: transparent; | ||||
|         cursor: pointer; | ||||
|         font-size: 14px; | ||||
|         font-weight: 500; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         transition: all 0.15s ease; | ||||
|         user-select: none; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button dees-icon { | ||||
|         width: 16px; | ||||
|         height: 16px; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button.active { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button:disabled { | ||||
|         opacity: 0.5; | ||||
|         cursor: not-allowed; | ||||
|       } | ||||
|  | ||||
|       .toolbar-divider { | ||||
|         width: 1px; | ||||
|         height: 24px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         margin: 0 4px; | ||||
|       } | ||||
|  | ||||
|       .editor-content { | ||||
|         flex: 1; | ||||
|         padding: 16px; | ||||
|         overflow-y: auto; | ||||
|         min-height: var(--min-height, 200px); | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror { | ||||
|         outline: none; | ||||
|         line-height: 1.6; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; | ||||
|         min-height: 100%; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror p { | ||||
|         margin: 0.5em 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror p:first-child { | ||||
|         margin-top: 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror p:last-child { | ||||
|         margin-bottom: 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror h1 { | ||||
|         font-size: 2em; | ||||
|         font-weight: bold; | ||||
|         margin: 1em 0 0.5em 0; | ||||
|         line-height: 1.2; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror h2 { | ||||
|         font-size: 1.5em; | ||||
|         font-weight: bold; | ||||
|         margin: 1em 0 0.5em 0; | ||||
|         line-height: 1.3; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror h3 { | ||||
|         font-size: 1.25em; | ||||
|         font-weight: bold; | ||||
|         margin: 1em 0 0.5em 0; | ||||
|         line-height: 1.4; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror ul, | ||||
|       .editor-content .ProseMirror ol { | ||||
|         padding-left: 1.5em; | ||||
|         margin: 0.5em 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror li { | ||||
|         margin: 0.25em 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror blockquote { | ||||
|         border-left: 4px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         margin: 1em 0; | ||||
|         padding-left: 1em; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         font-style: italic; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror code { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 3px; | ||||
|         padding: 0.2em 0.4em; | ||||
|         font-family: 'Intel One Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; | ||||
|         font-size: 0.9em; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror pre { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         padding: 1em; | ||||
|         margin: 1em 0; | ||||
|         overflow-x: auto; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror pre code { | ||||
|         background: none; | ||||
|         color: inherit; | ||||
|         padding: 0; | ||||
|         border-radius: 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror a { | ||||
|         color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; | ||||
|         text-decoration: underline; | ||||
|         cursor: pointer; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror a:hover { | ||||
|         color: ${cssManager.bdTheme('hsl(222.2 47.4% 41.2%)', 'hsl(217.2 91.2% 69.8%)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-footer { | ||||
|         padding: 8px 12px; | ||||
|         background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         font-size: 12px; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|         align-items: center; | ||||
|       } | ||||
|  | ||||
|       .word-count { | ||||
|         font-weight: 500; | ||||
|       } | ||||
|  | ||||
|       .link-input { | ||||
|         display: none; | ||||
|         position: absolute; | ||||
|         top: 100%; | ||||
|         left: 0; | ||||
|         right: 0; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | ||||
|         padding: 12px; | ||||
|         z-index: 1000; | ||||
|       } | ||||
|  | ||||
|       .link-input.show { | ||||
|         display: block; | ||||
|       } | ||||
|  | ||||
|       .link-input input { | ||||
|         width: 100%; | ||||
|         padding: 8px 12px; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         outline: none; | ||||
|         font-size: 14px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; | ||||
|         transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | ||||
|       } | ||||
|  | ||||
|       .link-input input:focus { | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')}; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons { | ||||
|         display: flex; | ||||
|         gap: 8px; | ||||
|         margin-top: 8px; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons button { | ||||
|         padding: 6px 12px; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 4px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         cursor: pointer; | ||||
|         font-size: 12px; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; | ||||
|         transition: all 0.15s ease; | ||||
|         font-weight: 500; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons button.primary { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons button.primary:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .description { | ||||
|         margin-top: 8px; | ||||
|         font-size: 12px; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         line-height: 1.4; | ||||
|       } | ||||
|  | ||||
|       :host([disabled]) .editor-container { | ||||
|         opacity: 0.6; | ||||
|         cursor: not-allowed; | ||||
|       } | ||||
|  | ||||
|       :host([disabled]) .toolbar-button, | ||||
|       :host([disabled]) .editor-content { | ||||
|         pointer-events: none; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|  | ||||
|   public render(): TemplateResult { | ||||
|     return html` | ||||
|       <div class="input-wrapper"> | ||||
|         ${this.label ? html`<label class="label">${this.label}</label>` : ''} | ||||
|         <div class="editor-container ${this.editor?.isFocused ? 'focused' : ''}" style="--min-height: ${this.minHeight}px"> | ||||
|           <div class="editor-toolbar"> | ||||
|             ${this.renderToolbar()} | ||||
|             <div class="link-input ${this.showLinkInput ? 'show' : ''}"> | ||||
|               <input type="url" placeholder="Enter URL..." @keydown=${this.handleLinkInputKeydown} /> | ||||
|               <div class="link-input-buttons"> | ||||
|                 <button class="primary" @click=${this.saveLink}>Save</button> | ||||
|                 <button @click=${this.removeLink}>Remove</button> | ||||
|                 <button @click=${this.hideLinkInput}>Cancel</button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="editor-content"></div> | ||||
|           ${this.showWordCount | ||||
|             ? html` | ||||
|                 <div class="editor-footer"> | ||||
|                   <span class="word-count">${this.wordCount} word${this.wordCount !== 1 ? 's' : ''}</span> | ||||
|                 </div> | ||||
|               ` | ||||
|             : ''} | ||||
|         </div> | ||||
|         ${this.description ? html`<div class="description">${this.description}</div>` : ''} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private renderToolbar(): TemplateResult { | ||||
|     const buttons: IToolbarButton[] = this.getToolbarButtons(); | ||||
|  | ||||
|     return html` | ||||
|       ${buttons.map((button) => { | ||||
|         if (button.isDivider) { | ||||
|           return html`<div class="toolbar-divider"></div>`; | ||||
|         } | ||||
|         return html` | ||||
|           <button | ||||
|             class="toolbar-button ${button.isActive?.() ? 'active' : ''}" | ||||
|             @click=${button.action} | ||||
|             title=${button.title} | ||||
|             ?disabled=${this.disabled || !this.editor} | ||||
|           > | ||||
|             <dees-icon .icon=${button.icon}></dees-icon> | ||||
|           </button> | ||||
|         `; | ||||
|       })} | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private getToolbarButtons(): IToolbarButton[] { | ||||
|     if (!this.editor) return []; | ||||
|  | ||||
|     return [ | ||||
|       { | ||||
|         name: 'bold', | ||||
|         icon: 'lucide:bold', | ||||
|         title: 'Bold (Ctrl+B)', | ||||
|         action: () => this.editor.chain().focus().toggleBold().run(), | ||||
|         isActive: () => this.editor.isActive('bold'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'italic', | ||||
|         icon: 'lucide:italic', | ||||
|         title: 'Italic (Ctrl+I)', | ||||
|         action: () => this.editor.chain().focus().toggleItalic().run(), | ||||
|         isActive: () => this.editor.isActive('italic'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'underline', | ||||
|         icon: 'lucide:underline', | ||||
|         title: 'Underline (Ctrl+U)', | ||||
|         action: () => this.editor.chain().focus().toggleUnderline().run(), | ||||
|         isActive: () => this.editor.isActive('underline'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'strike', | ||||
|         icon: 'lucide:strikethrough', | ||||
|         title: 'Strikethrough', | ||||
|         action: () => this.editor.chain().focus().toggleStrike().run(), | ||||
|         isActive: () => this.editor.isActive('strike'), | ||||
|       }, | ||||
|       { name: 'divider1', title: '', isDivider: true }, | ||||
|       { | ||||
|         name: 'h1', | ||||
|         icon: 'lucide:heading1', | ||||
|         title: 'Heading 1', | ||||
|         action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(), | ||||
|         isActive: () => this.editor.isActive('heading', { level: 1 }), | ||||
|       }, | ||||
|       { | ||||
|         name: 'h2', | ||||
|         icon: 'lucide:heading2', | ||||
|         title: 'Heading 2', | ||||
|         action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(), | ||||
|         isActive: () => this.editor.isActive('heading', { level: 2 }), | ||||
|       }, | ||||
|       { | ||||
|         name: 'h3', | ||||
|         icon: 'lucide:heading3', | ||||
|         title: 'Heading 3', | ||||
|         action: () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(), | ||||
|         isActive: () => this.editor.isActive('heading', { level: 3 }), | ||||
|       }, | ||||
|       { name: 'divider2', title: '', isDivider: true }, | ||||
|       { | ||||
|         name: 'bulletList', | ||||
|         icon: 'lucide:list', | ||||
|         title: 'Bullet List', | ||||
|         action: () => this.editor.chain().focus().toggleBulletList().run(), | ||||
|         isActive: () => this.editor.isActive('bulletList'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'orderedList', | ||||
|         icon: 'lucide:listOrdered', | ||||
|         title: 'Numbered List', | ||||
|         action: () => this.editor.chain().focus().toggleOrderedList().run(), | ||||
|         isActive: () => this.editor.isActive('orderedList'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'blockquote', | ||||
|         icon: 'lucide:quote', | ||||
|         title: 'Quote', | ||||
|         action: () => this.editor.chain().focus().toggleBlockquote().run(), | ||||
|         isActive: () => this.editor.isActive('blockquote'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'code', | ||||
|         icon: 'lucide:code', | ||||
|         title: 'Code', | ||||
|         action: () => this.editor.chain().focus().toggleCode().run(), | ||||
|         isActive: () => this.editor.isActive('code'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'codeBlock', | ||||
|         icon: 'lucide:fileCode', | ||||
|         title: 'Code Block', | ||||
|         action: () => this.editor.chain().focus().toggleCodeBlock().run(), | ||||
|         isActive: () => this.editor.isActive('codeBlock'), | ||||
|       }, | ||||
|       { name: 'divider3', title: '', isDivider: true }, | ||||
|       { | ||||
|         name: 'link', | ||||
|         icon: 'lucide:link', | ||||
|         title: 'Add Link', | ||||
|         action: () => this.toggleLink(), | ||||
|         isActive: () => this.editor.isActive('link'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'alignLeft', | ||||
|         icon: 'lucide:alignLeft', | ||||
|         title: 'Align Left', | ||||
|         action: () => this.editor.chain().focus().setTextAlign('left').run(), | ||||
|         isActive: () => this.editor.isActive({ textAlign: 'left' }), | ||||
|       }, | ||||
|       { | ||||
|         name: 'alignCenter', | ||||
|         icon: 'lucide:alignCenter', | ||||
|         title: 'Align Center', | ||||
|         action: () => this.editor.chain().focus().setTextAlign('center').run(), | ||||
|         isActive: () => this.editor.isActive({ textAlign: 'center' }), | ||||
|       }, | ||||
|       { | ||||
|         name: 'alignRight', | ||||
|         icon: 'lucide:alignRight', | ||||
|         title: 'Align Right', | ||||
|         action: () => this.editor.chain().focus().setTextAlign('right').run(), | ||||
|         isActive: () => this.editor.isActive({ textAlign: 'right' }), | ||||
|       }, | ||||
|       { name: 'divider4', title: '', isDivider: true }, | ||||
|       { | ||||
|         name: 'undo', | ||||
|         icon: 'lucide:undo', | ||||
|         title: 'Undo (Ctrl+Z)', | ||||
|         action: () => this.editor.chain().focus().undo().run(), | ||||
|       }, | ||||
|       { | ||||
|         name: 'redo', | ||||
|         icon: 'lucide:redo', | ||||
|         title: 'Redo (Ctrl+Y)', | ||||
|         action: () => this.editor.chain().focus().redo().run(), | ||||
|       }, | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   public async firstUpdated() { | ||||
|     await this.updateComplete; | ||||
|     this.initializeEditor(); | ||||
|   } | ||||
|  | ||||
|   private initializeEditor(): void { | ||||
|     if (this.disabled) return; | ||||
|  | ||||
|     this.editor = new Editor({ | ||||
|       element: this.editorElement, | ||||
|       extensions: [ | ||||
|         StarterKit.configure({ | ||||
|           heading: { | ||||
|             levels: [1, 2, 3], | ||||
|           }, | ||||
|         }), | ||||
|         Underline, | ||||
|         TextAlign.configure({ | ||||
|           types: ['heading', 'paragraph'], | ||||
|         }), | ||||
|         Link.configure({ | ||||
|           openOnClick: false, | ||||
|           HTMLAttributes: { | ||||
|             class: 'editor-link', | ||||
|           }, | ||||
|         }), | ||||
|         Typography, | ||||
|       ], | ||||
|       content: this.value || (this.placeholder ? `<p>${this.placeholder}</p>` : ''), | ||||
|       onUpdate: ({ editor }) => { | ||||
|         this.value = editor.getHTML(); | ||||
|         this.updateWordCount(); | ||||
|         this.dispatchEvent( | ||||
|           new CustomEvent('input', { | ||||
|             detail: { value: this.value }, | ||||
|             bubbles: true, | ||||
|             composed: true, | ||||
|           }) | ||||
|         ); | ||||
|         this.dispatchEvent( | ||||
|           new CustomEvent('change', { | ||||
|             detail: { value: this.value }, | ||||
|             bubbles: true, | ||||
|             composed: true, | ||||
|           }) | ||||
|         ); | ||||
|       }, | ||||
|       onSelectionUpdate: () => { | ||||
|         this.requestUpdate(); | ||||
|       }, | ||||
|       onFocus: () => { | ||||
|         this.requestUpdate(); | ||||
|       }, | ||||
|       onBlur: () => { | ||||
|         this.requestUpdate(); | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     this.updateWordCount(); | ||||
|   } | ||||
|  | ||||
|   private updateWordCount(): void { | ||||
|     if (!this.editor) return; | ||||
|     const text = this.editor.getText(); | ||||
|     this.wordCount = text.trim() ? text.trim().split(/\s+/).length : 0; | ||||
|   } | ||||
|  | ||||
|   private toggleLink(): void { | ||||
|     if (!this.editor) return; | ||||
|  | ||||
|     if (this.editor.isActive('link')) { | ||||
|       const href = this.editor.getAttributes('link').href; | ||||
|       this.showLinkInput = true; | ||||
|       requestAnimationFrame(() => { | ||||
|         if (this.linkInputElement) { | ||||
|           this.linkInputElement.value = href || ''; | ||||
|           this.linkInputElement.focus(); | ||||
|           this.linkInputElement.select(); | ||||
|         } | ||||
|       }); | ||||
|     } else { | ||||
|       this.showLinkInput = true; | ||||
|       requestAnimationFrame(() => { | ||||
|         if (this.linkInputElement) { | ||||
|           this.linkInputElement.value = ''; | ||||
|           this.linkInputElement.focus(); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private saveLink(): void { | ||||
|     if (!this.editor || !this.linkInputElement) return; | ||||
|  | ||||
|     const url = this.linkInputElement.value; | ||||
|     if (url) { | ||||
|       this.editor.chain().focus().setLink({ href: url }).run(); | ||||
|     } | ||||
|     this.hideLinkInput(); | ||||
|   } | ||||
|  | ||||
|   private removeLink(): void { | ||||
|     if (!this.editor) return; | ||||
|     this.editor.chain().focus().unsetLink().run(); | ||||
|     this.hideLinkInput(); | ||||
|   } | ||||
|  | ||||
|   private hideLinkInput(): void { | ||||
|     this.showLinkInput = false; | ||||
|     this.editor?.commands.focus(); | ||||
|   } | ||||
|  | ||||
|   private handleLinkInputKeydown(e: KeyboardEvent): void { | ||||
|     if (e.key === 'Enter') { | ||||
|       e.preventDefault(); | ||||
|       this.saveLink(); | ||||
|     } else if (e.key === 'Escape') { | ||||
|       e.preventDefault(); | ||||
|       this.hideLinkInput(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public setValue(value: string): void { | ||||
|     this.value = value; | ||||
|     if (this.editor && value !== this.editor.getHTML()) { | ||||
|       this.editor.commands.setContent(value); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public getValue(): string { | ||||
|     return this.value; | ||||
|   } | ||||
|  | ||||
|   public clear(): void { | ||||
|     this.setValue(''); | ||||
|   } | ||||
|  | ||||
|   public focus(): void { | ||||
|     this.editor?.commands.focus(); | ||||
|   } | ||||
|  | ||||
|   public async disconnectedCallback(): Promise<void> { | ||||
|     await super.disconnectedCallback(); | ||||
|     if (this.editor) { | ||||
|       this.editor.destroy(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										384
									
								
								ts_web/elements/dees-input-richtext/component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										384
									
								
								ts_web/elements/dees-input-richtext/component.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,384 @@ | ||||
| import { DeesInputBase } from '../dees-input-base.js'; | ||||
| import { demoFunc } from './demo.js'; | ||||
| import { richtextStyles } from './styles.js'; | ||||
| import { renderRichtext } from './template.js'; | ||||
| import type { IToolbarButton } from './types.js'; | ||||
| import '../dees-icon.js'; | ||||
|  | ||||
| import { | ||||
|   customElement, | ||||
|   type TemplateResult, | ||||
|   property, | ||||
|   html, | ||||
|   state, | ||||
|   query, | ||||
| } from '@design.estate/dees-element'; | ||||
|  | ||||
| import { Editor } from '@tiptap/core'; | ||||
| import StarterKit from '@tiptap/starter-kit'; | ||||
| import Underline from '@tiptap/extension-underline'; | ||||
| import TextAlign from '@tiptap/extension-text-align'; | ||||
| import Link from '@tiptap/extension-link'; | ||||
| import Typography from '@tiptap/extension-typography'; | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     'dees-input-richtext': DeesInputRichtext; | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| @customElement('dees-input-richtext') | ||||
| export class DeesInputRichtext extends DeesInputBase<string> { | ||||
|   public static demo = demoFunc; | ||||
|  | ||||
|   // INSTANCE | ||||
|   @property({ | ||||
|     type: String, | ||||
|     reflect: true, | ||||
|   }) | ||||
|   public value: string = ''; | ||||
|  | ||||
|   @property({ | ||||
|     type: String, | ||||
|   }) | ||||
|   public placeholder: string = ''; | ||||
|  | ||||
|   @property({ | ||||
|     type: Boolean, | ||||
|   }) | ||||
|   public showWordCount: boolean = true; | ||||
|  | ||||
|   @property({ | ||||
|     type: Number, | ||||
|   }) | ||||
|   public minHeight: number = 200; | ||||
|  | ||||
|   @state() | ||||
|   public showLinkInput: boolean = false; | ||||
|  | ||||
|   @state() | ||||
|   public wordCount: number = 0; | ||||
|  | ||||
|   @query('.editor-content') | ||||
|   private editorElement: HTMLElement; | ||||
|  | ||||
|   @query('.link-input input') | ||||
|   private linkInputElement: HTMLInputElement; | ||||
|  | ||||
|   public editor: Editor; | ||||
|  | ||||
|   public static styles = richtextStyles; | ||||
|  | ||||
|   public render(): TemplateResult { | ||||
|     return renderRichtext(this); | ||||
|   } | ||||
|  | ||||
|  | ||||
|  | ||||
|   public renderToolbar(): TemplateResult { | ||||
|     const buttons: IToolbarButton[] = this.getToolbarButtons(); | ||||
|  | ||||
|     return html` | ||||
|       ${buttons.map((button) => { | ||||
|         if (button.isDivider) { | ||||
|           return html`<div class="toolbar-divider"></div>`; | ||||
|         } | ||||
|         return html` | ||||
|           <button | ||||
|             class="toolbar-button ${button.isActive?.() ? 'active' : ''}" | ||||
|             @click=${button.action} | ||||
|             title=${button.title} | ||||
|             ?disabled=${this.disabled || !this.editor} | ||||
|           > | ||||
|             <dees-icon .icon=${button.icon}></dees-icon> | ||||
|           </button> | ||||
|         `; | ||||
|       })} | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private getToolbarButtons(): IToolbarButton[] { | ||||
|     if (!this.editor) return []; | ||||
|  | ||||
|     return [ | ||||
|       { | ||||
|         name: 'bold', | ||||
|         icon: 'lucide:bold', | ||||
|         title: 'Bold (Ctrl+B)', | ||||
|         action: () => this.editor.chain().focus().toggleBold().run(), | ||||
|         isActive: () => this.editor.isActive('bold'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'italic', | ||||
|         icon: 'lucide:italic', | ||||
|         title: 'Italic (Ctrl+I)', | ||||
|         action: () => this.editor.chain().focus().toggleItalic().run(), | ||||
|         isActive: () => this.editor.isActive('italic'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'underline', | ||||
|         icon: 'lucide:underline', | ||||
|         title: 'Underline (Ctrl+U)', | ||||
|         action: () => this.editor.chain().focus().toggleUnderline().run(), | ||||
|         isActive: () => this.editor.isActive('underline'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'strike', | ||||
|         icon: 'lucide:strikethrough', | ||||
|         title: 'Strikethrough', | ||||
|         action: () => this.editor.chain().focus().toggleStrike().run(), | ||||
|         isActive: () => this.editor.isActive('strike'), | ||||
|       }, | ||||
|       { name: 'divider1', title: '', isDivider: true }, | ||||
|       { | ||||
|         name: 'h1', | ||||
|         icon: 'lucide:heading1', | ||||
|         title: 'Heading 1', | ||||
|         action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(), | ||||
|         isActive: () => this.editor.isActive('heading', { level: 1 }), | ||||
|       }, | ||||
|       { | ||||
|         name: 'h2', | ||||
|         icon: 'lucide:heading2', | ||||
|         title: 'Heading 2', | ||||
|         action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(), | ||||
|         isActive: () => this.editor.isActive('heading', { level: 2 }), | ||||
|       }, | ||||
|       { | ||||
|         name: 'h3', | ||||
|         icon: 'lucide:heading3', | ||||
|         title: 'Heading 3', | ||||
|         action: () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(), | ||||
|         isActive: () => this.editor.isActive('heading', { level: 3 }), | ||||
|       }, | ||||
|       { name: 'divider2', title: '', isDivider: true }, | ||||
|       { | ||||
|         name: 'bulletList', | ||||
|         icon: 'lucide:list', | ||||
|         title: 'Bullet List', | ||||
|         action: () => this.editor.chain().focus().toggleBulletList().run(), | ||||
|         isActive: () => this.editor.isActive('bulletList'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'orderedList', | ||||
|         icon: 'lucide:listOrdered', | ||||
|         title: 'Numbered List', | ||||
|         action: () => this.editor.chain().focus().toggleOrderedList().run(), | ||||
|         isActive: () => this.editor.isActive('orderedList'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'blockquote', | ||||
|         icon: 'lucide:quote', | ||||
|         title: 'Quote', | ||||
|         action: () => this.editor.chain().focus().toggleBlockquote().run(), | ||||
|         isActive: () => this.editor.isActive('blockquote'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'code', | ||||
|         icon: 'lucide:code', | ||||
|         title: 'Code', | ||||
|         action: () => this.editor.chain().focus().toggleCode().run(), | ||||
|         isActive: () => this.editor.isActive('code'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'codeBlock', | ||||
|         icon: 'lucide:fileCode', | ||||
|         title: 'Code Block', | ||||
|         action: () => this.editor.chain().focus().toggleCodeBlock().run(), | ||||
|         isActive: () => this.editor.isActive('codeBlock'), | ||||
|       }, | ||||
|       { name: 'divider3', title: '', isDivider: true }, | ||||
|       { | ||||
|         name: 'link', | ||||
|         icon: 'lucide:link', | ||||
|         title: 'Add Link', | ||||
|         action: () => this.toggleLink(), | ||||
|         isActive: () => this.editor.isActive('link'), | ||||
|       }, | ||||
|       { | ||||
|         name: 'alignLeft', | ||||
|         icon: 'lucide:alignLeft', | ||||
|         title: 'Align Left', | ||||
|         action: () => this.editor.chain().focus().setTextAlign('left').run(), | ||||
|         isActive: () => this.editor.isActive({ textAlign: 'left' }), | ||||
|       }, | ||||
|       { | ||||
|         name: 'alignCenter', | ||||
|         icon: 'lucide:alignCenter', | ||||
|         title: 'Align Center', | ||||
|         action: () => this.editor.chain().focus().setTextAlign('center').run(), | ||||
|         isActive: () => this.editor.isActive({ textAlign: 'center' }), | ||||
|       }, | ||||
|       { | ||||
|         name: 'alignRight', | ||||
|         icon: 'lucide:alignRight', | ||||
|         title: 'Align Right', | ||||
|         action: () => this.editor.chain().focus().setTextAlign('right').run(), | ||||
|         isActive: () => this.editor.isActive({ textAlign: 'right' }), | ||||
|       }, | ||||
|       { name: 'divider4', title: '', isDivider: true }, | ||||
|       { | ||||
|         name: 'undo', | ||||
|         icon: 'lucide:undo', | ||||
|         title: 'Undo (Ctrl+Z)', | ||||
|         action: () => this.editor.chain().focus().undo().run(), | ||||
|       }, | ||||
|       { | ||||
|         name: 'redo', | ||||
|         icon: 'lucide:redo', | ||||
|         title: 'Redo (Ctrl+Y)', | ||||
|         action: () => this.editor.chain().focus().redo().run(), | ||||
|       }, | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   public async firstUpdated() { | ||||
|     await this.updateComplete; | ||||
|     this.initializeEditor(); | ||||
|   } | ||||
|  | ||||
|   private initializeEditor(): void { | ||||
|     if (this.disabled) return; | ||||
|  | ||||
|     this.editor = new Editor({ | ||||
|       element: this.editorElement, | ||||
|       extensions: [ | ||||
|         StarterKit.configure({ | ||||
|           heading: { | ||||
|             levels: [1, 2, 3], | ||||
|           }, | ||||
|         }), | ||||
|         Underline, | ||||
|         TextAlign.configure({ | ||||
|           types: ['heading', 'paragraph'], | ||||
|         }), | ||||
|         Link.configure({ | ||||
|           openOnClick: false, | ||||
|           HTMLAttributes: { | ||||
|             class: 'editor-link', | ||||
|           }, | ||||
|         }), | ||||
|         Typography, | ||||
|       ], | ||||
|       content: this.value || (this.placeholder ? `<p>${this.placeholder}</p>` : ''), | ||||
|       onUpdate: ({ editor }) => { | ||||
|         this.value = editor.getHTML(); | ||||
|         this.updateWordCount(); | ||||
|         this.dispatchEvent( | ||||
|           new CustomEvent('input', { | ||||
|             detail: { value: this.value }, | ||||
|             bubbles: true, | ||||
|             composed: true, | ||||
|           }) | ||||
|         ); | ||||
|         this.dispatchEvent( | ||||
|           new CustomEvent('change', { | ||||
|             detail: { value: this.value }, | ||||
|             bubbles: true, | ||||
|             composed: true, | ||||
|           }) | ||||
|         ); | ||||
|       }, | ||||
|       onSelectionUpdate: () => { | ||||
|         this.requestUpdate(); | ||||
|       }, | ||||
|       onFocus: () => { | ||||
|         this.requestUpdate(); | ||||
|       }, | ||||
|       onBlur: () => { | ||||
|         this.requestUpdate(); | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     this.updateWordCount(); | ||||
|   } | ||||
|  | ||||
|   private updateWordCount(): void { | ||||
|     if (!this.editor) return; | ||||
|     const text = this.editor.getText(); | ||||
|     this.wordCount = text.trim() ? text.trim().split(/\s+/).length : 0; | ||||
|   } | ||||
|  | ||||
|   private toggleLink(): void { | ||||
|     if (!this.editor) return; | ||||
|  | ||||
|     if (this.editor.isActive('link')) { | ||||
|       const href = this.editor.getAttributes('link').href; | ||||
|       this.showLinkInput = true; | ||||
|       requestAnimationFrame(() => { | ||||
|         if (this.linkInputElement) { | ||||
|           this.linkInputElement.value = href || ''; | ||||
|           this.linkInputElement.focus(); | ||||
|           this.linkInputElement.select(); | ||||
|         } | ||||
|       }); | ||||
|     } else { | ||||
|       this.showLinkInput = true; | ||||
|       requestAnimationFrame(() => { | ||||
|         if (this.linkInputElement) { | ||||
|           this.linkInputElement.value = ''; | ||||
|           this.linkInputElement.focus(); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public saveLink(): void { | ||||
|     if (!this.editor || !this.linkInputElement) return; | ||||
|  | ||||
|     const url = this.linkInputElement.value; | ||||
|     if (url) { | ||||
|       this.editor.chain().focus().setLink({ href: url }).run(); | ||||
|     } | ||||
|     this.hideLinkInput(); | ||||
|   } | ||||
|  | ||||
|   public removeLink(): void { | ||||
|     if (!this.editor) return; | ||||
|     this.editor.chain().focus().unsetLink().run(); | ||||
|     this.hideLinkInput(); | ||||
|   } | ||||
|  | ||||
|   public hideLinkInput(): void { | ||||
|     this.showLinkInput = false; | ||||
|     this.editor?.commands.focus(); | ||||
|   } | ||||
|  | ||||
|   public handleLinkInputKeydown(e: KeyboardEvent): void { | ||||
|     if (e.key === 'Enter') { | ||||
|       e.preventDefault(); | ||||
|       this.saveLink(); | ||||
|     } else if (e.key === 'Escape') { | ||||
|       e.preventDefault(); | ||||
|       this.hideLinkInput(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public setValue(value: string): void { | ||||
|     this.value = value; | ||||
|     if (this.editor && value !== this.editor.getHTML()) { | ||||
|       this.editor.commands.setContent(value); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public getValue(): string { | ||||
|     return this.value; | ||||
|   } | ||||
|  | ||||
|   public clear(): void { | ||||
|     this.setValue(''); | ||||
|   } | ||||
|  | ||||
|   public focus(): void { | ||||
|     this.editor?.commands.focus(); | ||||
|   } | ||||
|  | ||||
|   public async disconnectedCallback(): Promise<void> { | ||||
|     await super.disconnectedCallback(); | ||||
|     if (this.editor) { | ||||
|       this.editor.destroy(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { html, css } from '@design.estate/dees-element'; | ||||
| import '@design.estate/dees-wcctools/demotools'; | ||||
| import './dees-panel.js'; | ||||
| import './component.js'; | ||||
| import '../dees-panel.js'; | ||||
| 
 | ||||
| export const demoFunc = () => html` | ||||
|   <dees-demowrapper> | ||||
							
								
								
									
										4
									
								
								ts_web/elements/dees-input-richtext/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								ts_web/elements/dees-input-richtext/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| export * from './component.js'; | ||||
| export { richtextStyles } from './styles.js'; | ||||
| export { renderRichtext } from './template.js'; | ||||
| export type { IToolbarButton } from './types.js'; | ||||
							
								
								
									
										303
									
								
								ts_web/elements/dees-input-richtext/styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										303
									
								
								ts_web/elements/dees-input-richtext/styles.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,303 @@ | ||||
| import { css, cssManager } from '@design.estate/dees-element'; | ||||
| import { DeesInputBase } from '../dees-input-base.js'; | ||||
|  | ||||
| export const richtextStyles = [ | ||||
|     ...DeesInputBase.baseStyles, | ||||
|     cssManager.defaultStyles, | ||||
|     css` | ||||
|       :host { | ||||
|         display: block; | ||||
|         position: relative; | ||||
|         font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | ||||
|       } | ||||
|  | ||||
|       .input-wrapper { | ||||
|         position: relative; | ||||
|       } | ||||
|  | ||||
|       .label { | ||||
|         display: block; | ||||
|         margin-bottom: 8px; | ||||
|         font-size: 14px; | ||||
|         font-weight: 500; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         min-height: ${cssManager.bdTheme('200px', '200px')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         overflow: hidden; | ||||
|         transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | ||||
|       } | ||||
|  | ||||
|       .editor-container:hover { | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-container.focused { | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-toolbar { | ||||
|         display: flex; | ||||
|         flex-wrap: wrap; | ||||
|         gap: 4px; | ||||
|         padding: 8px 12px; | ||||
|         background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         align-items: center; | ||||
|         position: relative; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         width: 32px; | ||||
|         height: 32px; | ||||
|         border: none; | ||||
|         border-radius: 4px; | ||||
|         background: transparent; | ||||
|         cursor: pointer; | ||||
|         font-size: 14px; | ||||
|         font-weight: 500; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         transition: all 0.15s ease; | ||||
|         user-select: none; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button dees-icon { | ||||
|         width: 16px; | ||||
|         height: 16px; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button.active { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .toolbar-button:disabled { | ||||
|         opacity: 0.5; | ||||
|         cursor: not-allowed; | ||||
|       } | ||||
|  | ||||
|       .toolbar-divider { | ||||
|         width: 1px; | ||||
|         height: 24px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         margin: 0 4px; | ||||
|       } | ||||
|  | ||||
|       .editor-content { | ||||
|         flex: 1; | ||||
|         padding: 16px; | ||||
|         overflow-y: auto; | ||||
|         min-height: var(--min-height, 200px); | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror { | ||||
|         outline: none; | ||||
|         line-height: 1.6; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; | ||||
|         min-height: 100%; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror p { | ||||
|         margin: 0.5em 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror p:first-child { | ||||
|         margin-top: 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror p:last-child { | ||||
|         margin-bottom: 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror h1 { | ||||
|         font-size: 2em; | ||||
|         font-weight: bold; | ||||
|         margin: 1em 0 0.5em 0; | ||||
|         line-height: 1.2; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror h2 { | ||||
|         font-size: 1.5em; | ||||
|         font-weight: bold; | ||||
|         margin: 1em 0 0.5em 0; | ||||
|         line-height: 1.3; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror h3 { | ||||
|         font-size: 1.25em; | ||||
|         font-weight: bold; | ||||
|         margin: 1em 0 0.5em 0; | ||||
|         line-height: 1.4; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror ul, | ||||
|       .editor-content .ProseMirror ol { | ||||
|         padding-left: 1.5em; | ||||
|         margin: 0.5em 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror li { | ||||
|         margin: 0.25em 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror blockquote { | ||||
|         border-left: 4px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         margin: 1em 0; | ||||
|         padding-left: 1em; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         font-style: italic; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror code { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 3px; | ||||
|         padding: 0.2em 0.4em; | ||||
|         font-family: 'Intel One Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; | ||||
|         font-size: 0.9em; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror pre { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         padding: 1em; | ||||
|         margin: 1em 0; | ||||
|         overflow-x: auto; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror pre code { | ||||
|         background: none; | ||||
|         color: inherit; | ||||
|         padding: 0; | ||||
|         border-radius: 0; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror a { | ||||
|         color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; | ||||
|         text-decoration: underline; | ||||
|         cursor: pointer; | ||||
|       } | ||||
|  | ||||
|       .editor-content .ProseMirror a:hover { | ||||
|         color: ${cssManager.bdTheme('hsl(222.2 47.4% 41.2%)', 'hsl(217.2 91.2% 69.8%)')}; | ||||
|       } | ||||
|  | ||||
|       .editor-footer { | ||||
|         padding: 8px 12px; | ||||
|         background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         font-size: 12px; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|         align-items: center; | ||||
|       } | ||||
|  | ||||
|       .word-count { | ||||
|         font-weight: 500; | ||||
|       } | ||||
|  | ||||
|       .link-input { | ||||
|         display: none; | ||||
|         position: absolute; | ||||
|         top: 100%; | ||||
|         left: 0; | ||||
|         right: 0; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | ||||
|         padding: 12px; | ||||
|         z-index: 1000; | ||||
|       } | ||||
|  | ||||
|       .link-input.show { | ||||
|         display: block; | ||||
|       } | ||||
|  | ||||
|       .link-input input { | ||||
|         width: 100%; | ||||
|         padding: 8px 12px; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 6px; | ||||
|         outline: none; | ||||
|         font-size: 14px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; | ||||
|         transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | ||||
|       } | ||||
|  | ||||
|       .link-input input:focus { | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')}; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons { | ||||
|         display: flex; | ||||
|         gap: 8px; | ||||
|         margin-top: 8px; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons button { | ||||
|         padding: 6px 12px; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         border-radius: 4px; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         cursor: pointer; | ||||
|         font-size: 12px; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; | ||||
|         transition: all 0.15s ease; | ||||
|         font-weight: 500; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons button:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons button.primary { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')}; | ||||
|       } | ||||
|  | ||||
|       .link-input-buttons button.primary:hover { | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; | ||||
|         border-color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')}; | ||||
|       } | ||||
|  | ||||
|       .description { | ||||
|         margin-top: 8px; | ||||
|         font-size: 12px; | ||||
|         color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; | ||||
|         line-height: 1.4; | ||||
|       } | ||||
|  | ||||
|       :host([disabled]) .editor-container { | ||||
|         opacity: 0.6; | ||||
|         cursor: not-allowed; | ||||
|       } | ||||
|  | ||||
|       :host([disabled]) .toolbar-button, | ||||
|       :host([disabled]) .editor-content { | ||||
|         pointer-events: none; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|  | ||||
							
								
								
									
										33
									
								
								ts_web/elements/dees-input-richtext/template.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								ts_web/elements/dees-input-richtext/template.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import { html, type TemplateResult } from '@design.estate/dees-element'; | ||||
| import type { DeesInputRichtext } from './component.js'; | ||||
|  | ||||
| export const renderRichtext = (component: DeesInputRichtext): TemplateResult => { | ||||
|       return html` | ||||
|         <div class="input-wrapper"> | ||||
|           ${component.label ? html`<label class="label">${component.label}</label>` : ''} | ||||
|           <div class="editor-container ${component.editor?.isFocused ? 'focused' : ''}" style="--min-height: ${component.minHeight}px"> | ||||
|             <div class="editor-toolbar"> | ||||
|               ${component.renderToolbar()} | ||||
|               <div class="link-input ${component.showLinkInput ? 'show' : ''}"> | ||||
|                 <input type="url" placeholder="Enter URL..." @keydown=${component.handleLinkInputKeydown} /> | ||||
|                 <div class="link-input-buttons"> | ||||
|                   <button class="primary" @click=${component.saveLink}>Save</button> | ||||
|                   <button @click=${component.removeLink}>Remove</button> | ||||
|                   <button @click=${component.hideLinkInput}>Cancel</button> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="editor-content"></div> | ||||
|             ${component.showWordCount | ||||
|               ? html` | ||||
|                   <div class="editor-footer"> | ||||
|                     <span class="word-count">${component.wordCount} word${component.wordCount !== 1 ? 's' : ''}</span> | ||||
|                   </div> | ||||
|                 ` | ||||
|               : ''} | ||||
|           </div> | ||||
|           ${component.description ? html`<div class="description">${component.description}</div>` : ''} | ||||
|         </div> | ||||
|       `; | ||||
|    | ||||
| }; | ||||
							
								
								
									
										8
									
								
								ts_web/elements/dees-input-richtext/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								ts_web/elements/dees-input-richtext/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| export interface IToolbarButton { | ||||
|   name: string; | ||||
|   icon?: string; | ||||
|   action?: () => void; | ||||
|   isActive?: () => boolean; | ||||
|   title: string; | ||||
|   isDivider?: boolean; | ||||
| } | ||||
| @@ -231,7 +231,7 @@ export class DeesInputText extends DeesInputBase { | ||||
|           ${this.isPasswordBool | ||||
|             ? html` | ||||
|                 <div class="showPassword" @click=${this.togglePasswordView}> | ||||
|                   <dees-icon .icon=${this.showPasswordBool ? 'lucide:eye' : 'lucide:eye-off'}></dees-icon> | ||||
|                   <dees-icon .icon=${this.showPasswordBool ? 'lucide:Eye' : 'lucide:EyeOff'}></dees-icon> | ||||
|                 </div> | ||||
|               ` | ||||
|             : html``} | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import { html, css, type TemplateResult } from '@design.estate/dees-element'; | ||||
| import '@design.estate/dees-wcctools/demotools'; | ||||
| import './dees-panel.js'; | ||||
| import type { DeesInputWysiwyg } from './dees-input-wysiwyg.js'; | ||||
| import type { IBlock } from './wysiwyg/wysiwyg.types.js'; | ||||
| import type { DeesInputWysiwyg } from './dees-input-wysiwyg/dees-input-wysiwyg.js'; | ||||
| import type { IBlock } from './dees-input-wysiwyg/wysiwyg.types.js'; | ||||
|  | ||||
| interface IDemoEditor { | ||||
|   basic: DeesInputWysiwyg; | ||||
| @@ -250,6 +250,30 @@ const setupExportDemo = (container: HTMLElement, editor: DeesInputWysiwyg) => { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const setupOutputFormatDemo = ( | ||||
|   container: HTMLElement, | ||||
|   htmlEditor?: DeesInputWysiwyg, | ||||
|   markdownEditor?: DeesInputWysiwyg, | ||||
| ) => { | ||||
|   const htmlBtn = container.querySelector('#btn-show-html-output') as HTMLButtonElement | null; | ||||
|   const htmlPreview = container.querySelector('#output-preview-html') as HTMLElement | null; | ||||
|   if (htmlBtn && htmlPreview && htmlEditor) { | ||||
|     htmlBtn.addEventListener('click', () => { | ||||
|       htmlPreview.textContent = htmlEditor.getValue(); | ||||
|       htmlPreview.classList.add('visible'); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   const markdownBtn = container.querySelector('#btn-show-markdown-output') as HTMLButtonElement | null; | ||||
|   const markdownPreview = container.querySelector('#output-preview-markdown') as HTMLElement | null; | ||||
|   if (markdownBtn && markdownPreview && markdownEditor) { | ||||
|     markdownBtn.addEventListener('click', () => { | ||||
|       markdownPreview.textContent = markdownEditor.getValue(); | ||||
|       markdownPreview.classList.add('visible'); | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const populateInitialContent = (editors: IDemoEditor) => { | ||||
|   // Article editor content | ||||
|   if (editors.article) { | ||||
| @@ -360,6 +384,9 @@ export const demoFunc = (): TemplateResult => html` | ||||
|       setupExportDemo(elementArg, editors.exportDemo); | ||||
|     } | ||||
|  | ||||
|     // Setup output format preview buttons | ||||
|     setupOutputFormatDemo(elementArg, editors.meeting, editors.recipe); | ||||
|  | ||||
|     // Populate initial content | ||||
|     populateInitialContent(editors); | ||||
|      | ||||
| @@ -488,11 +515,46 @@ export const demoFunc = (): TemplateResult => html` | ||||
|  | ||||
|       .output-grid { | ||||
|         display: grid; | ||||
|         grid-template-columns: 1fr 1fr; | ||||
|         grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); | ||||
|         gap: 24px; | ||||
|         margin-top: 24px; | ||||
|       } | ||||
|  | ||||
|       .output-card { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 12px; | ||||
|       } | ||||
|  | ||||
|       .output-actions { | ||||
|         display: flex; | ||||
|         justify-content: flex-end; | ||||
|       } | ||||
|  | ||||
|       .output-preview { | ||||
|         display: none; | ||||
|         background: rgba(15, 23, 42, 0.04); | ||||
|         color: var(--dees-color-text, #0f172a); | ||||
|         border: 1px solid rgba(15, 23, 42, 0.1); | ||||
|         border-radius: 8px; | ||||
|         padding: 16px; | ||||
|         white-space: pre-wrap; | ||||
|         font-family: 'Geist Mono', 'Fira Code', monospace; | ||||
|         font-size: 13px; | ||||
|         max-height: 280px; | ||||
|         overflow: auto; | ||||
|       } | ||||
|  | ||||
|       :host([theme='dark']) .output-preview { | ||||
|         background: rgba(250, 250, 250, 0.06); | ||||
|         border-color: rgba(250, 250, 250, 0.15); | ||||
|         color: var(--dees-color-text, #f4f4f5); | ||||
|       } | ||||
|  | ||||
|       .output-preview.visible { | ||||
|         display: block; | ||||
|       } | ||||
|  | ||||
|       @media (max-width: 768px) { | ||||
|         .output-grid { | ||||
|           grid-template-columns: 1fr; | ||||
| @@ -858,7 +920,7 @@ git merge feature-branch | ||||
|         </p> | ||||
|          | ||||
|         <div class="output-grid"> | ||||
|           <div> | ||||
|           <div class="output-card"> | ||||
|             <dees-input-wysiwyg  | ||||
|               id="editor-meeting" | ||||
|               label="Meeting Notes"  | ||||
| @@ -866,9 +928,13 @@ git merge feature-branch | ||||
|               outputFormat="html" | ||||
|               value="<h2>Q4 Planning Meeting</h2><p><strong>Date:</strong> December 15, 2024<br><strong>Attendees:</strong> Product Team, Engineering, Design</p><h3>Agenda Items</h3><ol><li>Review Q3 achievements</li><li>Set Q4 objectives</li><li>Resource allocation</li><li>Timeline discussion</li></ol><h3>Key Decisions</h3><ul><li>Launch new dashboard feature by end of January</li><li>Increase engineering team by 2 developers</li><li>Implement weekly design reviews</li></ul><blockquote>"Focus on user experience improvements based on Q3 feedback" - Product Manager</blockquote><h3>Action Items</h3><ul><li>Sarah: Create detailed project timeline</li><li>Mike: Draft technical requirements</li><li>Lisa: Schedule user research sessions</li></ul><hr><p>Next meeting: January 5, 2025</p>" | ||||
|             ></dees-input-wysiwyg> | ||||
|             <div class="output-actions"> | ||||
|               <button id="btn-show-html-output" class="demo-button">Show HTML Output</button> | ||||
|             </div> | ||||
|             <pre id="output-preview-html" class="output-preview" aria-live="polite"></pre> | ||||
|           </div> | ||||
|            | ||||
|           <div> | ||||
|           <div class="output-card"> | ||||
|             <dees-input-wysiwyg  | ||||
|               id="editor-recipe" | ||||
|               label="Recipe Blog Post"  | ||||
| @@ -927,6 +993,10 @@ Gradually blend in flour mixture, then stir in chocolate chips. Drop rounded tab | ||||
|  | ||||
| **Yield:** About 5 dozen cookies" | ||||
|             ></dees-input-wysiwyg> | ||||
|             <div class="output-actions"> | ||||
|               <button id="btn-show-markdown-output" class="demo-button">Show Markdown Output</button> | ||||
|             </div> | ||||
|             <pre id="output-preview-markdown" class="output-preview" aria-live="polite"></pre> | ||||
|           </div> | ||||
|         </div> | ||||
|       </dees-panel> | ||||
|   | ||||
| @@ -1,2 +1,3 @@ | ||||
| // Re-export the modular component from the wysiwyg directory | ||||
| export { DeesInputWysiwyg } from './wysiwyg/dees-input-wysiwyg.js'; | ||||
| // Re-export the component and related helpers from the dedicated subdirectory | ||||
| export { DeesInputWysiwyg } from './dees-input-wysiwyg/dees-input-wysiwyg.js'; | ||||
| export * from './dees-input-wysiwyg/index.js'; | ||||
|   | ||||
| @@ -118,6 +118,17 @@ export class DeesInputWysiwyg extends DeesInputBase<string> { | ||||
|   } | ||||
| 
 | ||||
|   async firstUpdated() { | ||||
|     if (this.value && this.value.trim().length > 0) { | ||||
|       const parsedBlocks = | ||||
|         this.outputFormat === 'html' | ||||
|           ? WysiwygConverters.parseHtmlToBlocks(this.value) | ||||
|           : WysiwygConverters.parseMarkdownToBlocks(this.value); | ||||
| 
 | ||||
|       if (parsedBlocks.length > 0) { | ||||
|         this.blocks = parsedBlocks; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     this.updateValue(); | ||||
|     this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement; | ||||
|      | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user