Compare commits

...

167 Commits

Author SHA1 Message Date
49ad998b2c update 2025-06-29 14:00:55 +00:00
5066681b3a update 2025-06-28 12:34:35 +00:00
ee22879c00 update 2025-06-28 12:27:35 +00:00
9b0ff2d856 1.10.9 2025-06-28 10:05:09 +00:00
7e14645ed7 update 2025-06-27 23:48:39 +00:00
811737adcd update 2025-06-27 22:55:20 +00:00
7b6c135cd3 update 2025-06-27 22:47:24 +00:00
46065b2424 1.10.8 2025-06-27 21:19:43 +00:00
e76a6c3632 update 2025-06-27 21:19:14 +00:00
896bc2bbb1 update 2025-06-27 21:16:52 +00:00
296d254ba2 update 2025-06-27 21:07:47 +00:00
ecad05098f update 2025-06-27 21:05:28 +00:00
956964f5b9 update dees-chips 2025-06-27 21:01:12 +00:00
ed73e16bbb update dees-modal 2025-06-27 19:48:32 +00:00
7817b4a440 1.10.7 2025-06-27 19:33:37 +00:00
03f25b7f10 update wysiwyg 2025-06-27 19:29:26 +00:00
24957f02d4 update wysiwyg 2025-06-27 19:25:34 +00:00
fe3cd0591f update 2025-06-27 18:38:39 +00:00
56f5f5887b update 2025-06-27 18:03:42 +00:00
2e0bf26301 update table 2025-06-27 17:50:54 +00:00
3d7f5253e8 update fonts 2025-06-27 17:32:01 +00:00
669f12e822 1.10.6 2025-06-27 17:14:49 +00:00
8b870a8e46 update 2025-06-27 17:14:46 +00:00
9148f0595a update 2025-06-27 17:14:26 +00:00
ea7da1c9b9 update 2025-06-27 16:39:17 +00:00
3e81f54e99 update 2025-06-27 16:29:19 +00:00
65aa9f3c06 update 2025-06-27 16:20:06 +00:00
82ebd9c556 update 2025-06-27 15:58:26 +00:00
50aa071e2e update dees-chart 2025-06-27 15:43:26 +00:00
807e1ff733 1.10.5 2025-06-27 13:44:52 +00:00
4146a348ab update statsgrid 2025-06-27 13:44:36 +00:00
bd10b4e64d 1.10.4 2025-06-27 11:50:26 +00:00
243ecddd42 update button and statsgrid with better styles. 2025-06-27 11:50:07 +00:00
d7b690621e 1.10.3 2025-06-27 00:45:53 +00:00
60951330d1 fix(typelist): update styling 2025-06-27 00:45:11 +00:00
7095197d07 1.10.2 2025-06-27 00:35:21 +00:00
3ee48e80ad fix(wysiwig): zindexregistry for menus 2025-06-27 00:35:06 +00:00
00ad2b0563 1.10.1 2025-06-27 00:19:03 +00:00
a57005a49b fix(modal): scroll behaviour contain 2025-06-27 00:18:36 +00:00
d67a66662d 1.10.0
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-27 00:00:00 +00:00
c75c5bcd3b feat(dees-modal): Add mobileFullscreen option to modals for full-screen mobile support 2025-06-27 00:00:00 +00:00
ad0864cddf 1.9.9
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 23:34:11 +00:00
9985c29a84 fix(dees-input-multitoggle, dees-input-typelist): Replace dynamic import with static import for demo functions in dees-input-multitoggle and dees-input-typelist 2025-06-26 23:34:11 +00:00
1dcaccdb6d 1.9.8
Some checks failed
Default (tags) / security (push) Failing after 33s
Default (tags) / test (push) Failing after 27s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 23:19:44 +00:00
35eb410051 fix(deps, windowlayer): Update dependency versions and adjust dees-windowlayer CSS to add pointer-events fix 2025-06-26 23:19:43 +00:00
10c43ecd59 update 2025-06-26 20:20:34 +00:00
9df4a09414 1.9.7
Some checks failed
Default (tags) / security (push) Failing after 35s
Default (tags) / test (push) Failing after 29s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 19:19:19 +00:00
7cbc941407 update readme 2025-06-26 19:18:58 +00:00
b31f306106 1.9.6
Some checks failed
Default (tags) / security (push) Failing after 36s
Default (tags) / test (push) Failing after 29s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 19:00:57 +00:00
1dbbac450c update dees-tags 2025-06-26 19:00:15 +00:00
b5a2bd7436 fix zindex 2025-06-26 18:37:49 +00:00
931a760ee1 update z-index showcase 2025-06-26 18:13:08 +00:00
27414e0284 1.9.5
Some checks failed
Default (tags) / security (push) Failing after 39s
Default (tags) / test (push) Failing after 31s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 15:57:43 +00:00
d63bc762d0 update 2025-06-26 15:57:27 +00:00
505e40a57f update modals 2025-06-26 15:51:05 +00:00
d1ea10d8c6 update z-index use 2025-06-26 15:46:44 +00:00
1038759d8b 1.9.4
Some checks failed
Default (tags) / security (push) Failing after 40s
Default (tags) / test (push) Failing after 28s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 15:33:18 +00:00
ab9b545c9a update file upload 2025-06-26 15:32:29 +00:00
e1329ecd7a 1.9.3
Some checks failed
Default (tags) / security (push) Failing after 43s
Default (tags) / test (push) Failing after 24s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-26 15:08:25 +00:00
167df241b7 update 2025-06-26 15:08:14 +00:00
b41e9f31e7 add proper input demo page 2025-06-26 14:46:37 +00:00
02f25aa02e fix(editor-demos): update 2025-06-26 14:27:39 +00:00
312fc4ba90 feat(dees-input-richtext): use lucide icons 2025-06-26 14:15:52 +00:00
56d7b44b01 feat(prosemirror): add prosemirror support 2025-06-26 14:12:06 +00:00
f72c9fad3a update navigation 2025-06-26 13:45:00 +00:00
d48fd667a2 update 2025-06-26 13:38:09 +00:00
979877b3b0 update 2025-06-26 13:32:37 +00:00
342bd7d7c2 update 2025-06-26 13:18:34 +00:00
4d42911198 update 2025-06-26 12:00:35 +00:00
3ea7186d6c update 2025-06-26 11:57:04 +00:00
09e35d0245 update codeblock 2025-06-26 11:41:58 +00:00
4a26307e1b update 2025-06-25 05:30:20 +00:00
113c013ea9 1.9.2
Some checks failed
Default (tags) / security (push) Failing after 26s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-24 23:57:32 +00:00
0571d5bf4b fi(wysiwyg): fix navigation 2025-06-24 23:56:40 +00:00
5f86fdba72 update 2025-06-24 23:46:52 +00:00
474385a939 update 2025-06-24 23:15:56 +00:00
71d64fccb8 1.9.1
Some checks failed
Default (tags) / security (push) Failing after 28s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-24 22:46:24 +00:00
e9541da8ff refactor 2025-06-24 22:45:50 +00:00
68b4e9ec8e feat(wysiwyg): Add more block types 2025-06-24 20:32:03 +00:00
856d354b5a update 2025-06-24 18:52:48 +00:00
89a4a15e78 update 2025-06-24 18:43:51 +00:00
fca3638f7f implement image upload 2025-06-24 17:16:13 +00:00
90fc8bed35 update selection reversal 2025-06-24 17:09:19 +00:00
bd223f77d0 selection manipulation 2025-06-24 16:53:54 +00:00
1041814823 update 2025-06-24 16:49:40 +00:00
366544befc fix(wysiwyg): Improve text selection detection with block-level approach
- Remove global selectionchange listener in favor of block-level detection
- Add comprehensive debugging logs to track selection detection
- Add multiple event listeners (mouseup, keyup, selectstart) for better coverage
- Add debounced selection checking to avoid race conditions
- Add click-outside handler to hide formatting menu
- Simplify selection detection logic by removing complex shadow DOM traversal
2025-06-24 16:31:00 +00:00
3b93bd63a7 fix(wysiwyg): Fix text selection detection for formatting menu in Shadow DOM
- Update selection detection to properly handle Shadow DOM boundaries
- Use getComposedRanges API correctly according to MDN documentation
- Add direct selection detection within block components
- Dispatch custom events from blocks when text is selected
- Fix formatting menu positioning using selection rect from events
2025-06-24 16:17:00 +00:00
ca525ce7e3 feat(wysiwyg): implement backspace 2025-06-24 15:52:28 +00:00
83f153f654 update 2025-06-24 15:17:37 +00:00
75637c7793 fix(wysiwyg): fix demo 2025-06-24 15:15:46 +00:00
e0a125c9bd fix(wysiwyg): cursor position 2025-06-24 13:53:47 +00:00
4b2178cedd fix(wysiwyg):Improve Wysiwyg editor 2025-06-24 13:41:12 +00:00
08a4c361fa fix(wysiwyg):Improve Wysiwyg editor 2025-06-24 12:24:02 +00:00
35a648d450 refactor(wysiwyg): Clean up code and ensure drag-drop works with programmatic rendering
- Update drag handler to work without requestUpdate calls
- Remove duplicate modal methods (using WysiwygModalManager)
- Clean up unused imports and methods
- Ensure all DOM updates are programmatic
- Add comprehensive test files for all features
- Follow separation of concerns principles
2025-06-24 11:12:56 +00:00
1c76ade150 fix(wysiwyg): Implement programmatic rendering to eliminate focus loss during typing
- Convert parent component to use static rendering with programmatic DOM manipulation
- Remove all reactive state that could trigger re-renders during editing
- Delay content sync to avoid interference with typing (2s auto-save)
- Update all block operations to use manual DOM updates instead of Lit re-renders
- Fix specific issue where typing + arrow keys caused focus loss
- Add comprehensive focus management documentation
2025-06-24 11:06:02 +00:00
8b02c5aea3 fix(dees-modal): theming 2025-06-24 10:45:06 +00:00
c82c407350 fix(dees-moadl): theming 2025-06-24 08:23:24 +00:00
169f74aa2e Improve Wysiwyg editor 2025-06-24 08:19:53 +00:00
e4a042907a Improve Wysiwyg editor 2025-06-24 07:21:09 +00:00
7ce282c500 feat(wysiwyg): Add language selection for code blocks, block settings menu, fix cursor navigation, and update demo to use dees-panel
- Added modal to select programming language when creating code blocks
- Added settings button (3 dots) on each block for configuration
- Fixed cursor not jumping to new block after pressing Enter
- Special handling for code blocks: Enter creates new line, Shift+Enter creates new block
- Display language indicator on code blocks
- Updated demo to use dees-panel instead of demo-section divs
- Added language selection in block settings modal for existing code blocks
2025-06-23 21:28:58 +00:00
302777feff feat(editor): Add wysiwyg editor 2025-06-23 21:18:03 +00:00
cdcd4f79c8 feat(editor): Add wysiwyg editor 2025-06-23 21:15:04 +00:00
f2e6342a61 feat(editor): Add wysiwyg editor 2025-06-23 18:12:24 +00:00
0f02e7d00f feat(editor): Add wysiwyg editor 2025-06-23 18:07:46 +00:00
a1079cbbdd feat(editor): Add wysiwyg editor 2025-06-23 18:02:40 +00:00
58af08cb0d feat(editor): Add wysiwyg editor 2025-06-23 17:52:10 +00:00
6626726029 feat(editor): Add wysiwyg editor 2025-06-23 17:36:39 +00:00
f3afbb2e48 feat(editor): Add wysiwyg editor 2025-06-23 17:14:47 +00:00
fbd52ee9a5 feat(editor): Add wysiwyg editor 2025-06-23 17:09:53 +00:00
3284d91c2a feat(editor): Add wysiwyg editor 2025-06-23 17:05:28 +00:00
22e6b74c4f 1.9.0
Some checks failed
Default (tags) / security (push) Failing after 27s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-22 20:32:59 +00:00
4de835474b feat(form-inputs): Improve form input consistency and auto spacing across inputs and buttons 2025-06-22 20:32:59 +00:00
024d8af40d 1.8.20
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 21s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-20 00:11:30 +00:00
808b74fa17 fix(deps): Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0 2025-06-20 00:11:30 +00:00
202881ef1a 1.8.19
Some checks failed
Default (tags) / security (push) Failing after 42s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 14:01:14 +00:00
7de3d451ad fix: Change import to type for DeesForm in dees-form-submit 2025-06-19 14:01:07 +00:00
f0e0430016 1.8.18
Some checks failed
Default (tags) / security (push) Failing after 44s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 13:48:18 +00:00
873579fc97 fix: Import dees-button in dees-form-submit for button functionality 2025-06-19 13:47:52 +00:00
d321db363d 1.8.17
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:54:51 +00:00
73c1874e3f 1.8.16
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:53:28 +00:00
1aa06398a0 1.8.15
Some checks failed
Default (tags) / security (push) Failing after 47s
Default (tags) / test (push) Failing after 38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:42:53 +00:00
99b23236a1 fix: Update default button text handling and improve demo example in dees-form-submit 2025-06-19 12:42:50 +00:00
d1e7e5447c 1.8.14
Some checks failed
Default (tags) / security (push) Failing after 49s
Default (tags) / test (push) Failing after 38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:31:45 +00:00
4f22a98b78 refactor: Remove unnecessary imports in dees-form-submit and dees-simple-login 2025-06-19 12:31:33 +00:00
eb09aee264 1.8.13
Some checks failed
Default (tags) / security (push) Failing after 51s
Default (tags) / test (push) Failing after 38s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:10:41 +00:00
c3fca1db36 1.8.12
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:10:18 +00:00
2a5e6ee37a feat: Register dees-button in dees-form-submit and import necessary components in dees-simple-login 2025-06-19 12:09:48 +00:00
41e2125dc7 1.8.11
Some checks failed
Default (tags) / security (push) Failing after 55s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 11:55:09 +00:00
2a76b67e9a 1.8.10 2025-06-19 11:50:37 +00:00
d697958536 feat: Improve login event handling and form data validation in dees-simple-login component 2025-06-19 11:50:24 +00:00
1789807f90 1.8.9 2025-06-19 11:39:23 +00:00
03315db863 feat: Enhance demo components with new input types and layout options
- Added dropdown and radio input components to the demo for application settings.
- Introduced horizontal layout for display preferences and notification settings.
- Implemented checkbox demo with programmatic selection and clear functionality.
- Created file upload and quantity selector demos with various states and configurations.
- Added comprehensive radio input demo showcasing group behavior and various states.
- Developed text input demo with validation states and advanced features like password visibility.
- Introduced a new panel component for better content organization in demos.
2025-06-19 11:39:16 +00:00
79b1a4ea9f feat: Implement unified input component architecture with standardized margins and layout modes 2025-06-19 09:41:00 +00:00
8fb5e2e2a2 1.8.8 2025-06-17 11:51:47 +00:00
640a69f4cd feat: Integrate dees-statsgrid component into dashboard view with dynamic stats tiles 2025-06-17 11:51:34 +00:00
bdb666cbe2 feat: Enhance demo components with improved layout, styling, and functionality for login and dashboard views 2025-06-17 11:45:25 +00:00
8a1d830376 feat: Enhance context menu functionality with keyboard navigation and improved item handling 2025-06-17 11:39:16 +00:00
c1e8f8c2a6 feat: Enhance selection options with icons and dividers for improved UI 2025-06-17 10:00:50 +00:00
a8f0e5659e feat: Add profile dropdown component and integrate with appbar for user menu 2025-06-17 09:55:28 +00:00
cd3c7c8e63 feat: Refactor theming in app components to use dynamic CSS variables 2025-06-17 08:58:47 +00:00
5b4319432c feat: Enhance dees-appui components with dynamic tab and menu configurations
- Updated dees-appui-mainmenu to accept dynamic tabs with actions and icons.
- Modified dees-appui-mainselector to support dynamic selection options.
- Introduced dees-appui-tabs for improved tab navigation with customizable styles.
- Added dees-appui-view to manage views with tabs and content dynamically.
- Implemented event dispatching for tab and option selections.
- Created a comprehensive architecture documentation for dees-appui system.
- Added demo implementations for dees-appui-base and other components.
- Improved responsiveness and user interaction feedback across components.
2025-06-17 08:41:36 +00:00
e33f4e7a70 1.8.7 2025-06-16 23:48:47 +00:00
f101df9329 1.8.6 2025-06-16 23:48:37 +00:00
d926f5c5e4 1.8.5 2025-06-16 23:48:13 +00:00
8ad754c9bc feat(dees-appui-appbar): implement dynamic menu system with support for submenus, shortcuts, and user account features
feat(dees-contextmenu): adjust menu item positioning for improved alignment
fix(dees-appui-appbar.demo): add demo functionality for app bar with dynamic menu items and user interactions
feat(interfaces): create IAppBarMenuItem interface for enhanced menu item configurations
docs: add comprehensive improvement plan for dees-appui-appbar component
2025-06-16 23:16:25 +00:00
ed20e04e96 fix(dees-catalog): update @design.estate/dees-wcctools dependency to version 1.0.98 for compatibility and enhance demo functionality with real-time data updates 2025-06-16 22:23:22 +00:00
daef1aa841 fix(dees-catalog): update @design.estate/dees-wcctools dependency to version 1.0.97 for compatibility 2025-06-16 16:04:04 +00:00
339ea2d7d4 fix(dees-catalog): update @design.estate/dees-wcctools dependency to version 1.0.96 for compatibility and add demotools import in demo files 2025-06-16 15:11:52 +00:00
036bba44ae fix(dees-catalog): update @design.estate/dees-wcctools dependency to version 1.0.95 for compatibility
feat(dees-chart-area): refactor demo function for improved dataset handling and real-time updates
feat(dees-chart-log): enhance demo function with simulation controls for server log generation
2025-06-16 14:59:22 +00:00
48fbeb397d feat(dees-button-group): add new button group component with demo and styling
fix(dees-chart-area): improve real-time updates and chart element handling
fix(dees-chart-log): refactor demo to store log element reference
chore: update dependencies in package.json and pnpm-lock.yaml
2025-06-16 14:37:09 +00:00
346abfa685 1.8.4
Some checks failed
Default (tags) / security (push) Failing after 32s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-12 11:11:36 +00:00
f1123f319f fix(dees-catalog): downgrade @webcontainer/api to version 1.2.0 for compatibility 2025-06-12 11:11:21 +00:00
ac15da9c82 1.8.3
Some checks failed
Default (tags) / security (push) Failing after 34s
Default (tags) / test (push) Failing after 19s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-12 11:09:27 +00:00
b9432c8489 feat(dees-chart-area): Enhance chart component with dynamic datasets, real-time updates, and improved demo features 2025-06-12 11:09:14 +00:00
b35b1fbae7 1.8.2
Some checks failed
Default (tags) / security (push) Failing after 36s
Default (tags) / test (push) Failing after 21s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-12 11:01:20 +00:00
e39590df2c fix(dees-chart-area): Improve resize handling and initial rendering for better responsiveness
fix(dees-chart-log): Simplify firstUpdated method by removing unnecessary variable
2025-06-12 11:00:33 +00:00
fad7fda2a6 feat(dees-chart-log): Enhance log component with realistic log simulation and improved UI controls 2025-06-12 10:44:21 +00:00
987f557c60 Enhance DeesToast component with new features and improved demo
- Updated README to reflect new toast positions and convenience methods.
- Expanded demo functionality to showcase various toast types, positions, and durations.
- Added programmatic control for toast dismissal and multiple toast notifications.
- Introduced new toast positions: top-center and bottom-center.
- Implemented a progress bar for auto-dismiss functionality.
- Improved styling and animations for better user experience.
2025-06-12 09:33:46 +00:00
4eef9fc731 1.8.1
Some checks failed
Default (tags) / security (push) Failing after 25s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-10 18:58:05 +00:00
cd86001713 fix(dees-statsgrid): Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles. 2025-06-10 18:58:05 +00:00
f7e4582fde feat(dees-statsgrid): Add dees-statsgrid component with demo and integration in the main export 2025-06-10 18:29:37 +00:00
4635e3fce5 1.8.0
Some checks failed
Default (tags) / security (push) Failing after 20s
Default (tags) / test (push) Failing after 9s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-25 14:27:51 +00:00
af3dc5c466 feat(dees-pagination): Add new pagination component to the library along with its demo and integration in the main export. 2025-04-25 14:27:51 +00:00
12861b2230 1.7.0
Some checks failed
Default (tags) / security (push) Failing after 17s
Default (tags) / test (push) Failing after 8s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-22 12:49:57 +00:00
b7f672e0f2 feat(dees-searchbar): Add dees-searchbar component with live search and filter demo 2025-04-22 12:49:57 +00:00
fcb44dfd24 1.6.0
Some checks failed
Default (tags) / security (push) Failing after 20s
Default (tags) / test (push) Failing after 9s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-04-22 12:30:22 +00:00
f17b880b59 feat(documentation/dees-heading): Add codex documentation overview and dees-heading component demo 2025-04-22 12:30:22 +00:00
178 changed files with 41054 additions and 3562 deletions

1
.gitignore vendored
View File

@ -3,7 +3,6 @@
# artifacts
coverage/
public/
pages/
# installs
node_modules/

174
CLAUDE.md Normal file
View File

@ -0,0 +1,174 @@
# 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

View File

@ -1,5 +1,155 @@
# Changelog
## 2025-06-29 - 1.10.10 - improve(dees-dashboardgrid, dees-input-wysiwyg)
Enhanced dashboard grid component with advanced spacing and layout features inspired by gridstack.js
Dashboard Grid improvements:
- Improved margin system supporting uniform or individual margins (top, right, bottom, left)
- Added collision detection to prevent widget overlap during drag operations
- Implemented auto-positioning for new widgets to find first available space
- Added compact() method to eliminate gaps and compress layout vertically or horizontally
- Enhanced resize constraints with minW, maxW, minH, maxH support
- Added optional grid lines visualization for better layout understanding
- Improved resize handles with better visibility and hover states
- Added RTL (right-to-left) layout support
- Implemented cellHeightUnit option supporting 'px', 'em', 'rem', or 'auto' (square cells)
- Added configurable animation with enableAnimation property
- Enhanced demo with interactive controls for testing all features
- Better calculation of widget positions accounting for margins between cells
- Added findAvailablePosition() for intelligent widget placement
- Improved drag and resize calculations for pixel-perfect positioning
WYSIWYG editor drag and drop fixes:
- Fixed drop indicator positioning to properly account for block margins
- Added defensive checks in drag event handlers to prevent potential crashes
- Improved updateBlockPositions with null checks and error handling
- Updated drop indicator calculation to use simplified margin approach
- Fixed drop indicator height to match the exact space occupied by dragged blocks
- Improved drop indicator positioning algorithm to accurately show where blocks will land
- Simplified visual block position calculations accounting for CSS transforms
- Enhanced margin calculation to use correct values based on block type (16px for paragraphs, 24px for headings, 20px for code/quotes)
- Fixed index calculation issue when dragging blocks downward by adjusting target index for excluded dragged block
## 2025-06-28 - 1.10.9 - feat(dees-dashboardgrid)
Add new dashboard grid component with drag-and-drop and resize capabilities
- Created dees-dashboardgrid component for building flexible dashboard layouts
- Features drag-and-drop functionality for rearranging widgets
- Includes resize handles for adjusting widget dimensions
- Supports configurable grid properties (columns, cell height, gap)
- Provides widget locking and editable mode controls
- Styled with shadcn design principles
- No external dependencies - built with native browser APIs
- Emits events for widget movements and resizes
- Includes comprehensive demo with sample dashboard widgets
## 2025-06-27 - 1.10.8 - feat(ui-components)
Update multiple components with shadcn-aligned styling and improved animations
- Updated dees-modal with shadcn colors, borders, and subtle shadows
- Updated dees-chips with shadcn styling and fixed selection logic bug
- Updated dees-dataview-codebox with shadcn syntax highlighting colors and responsive label layout
- Updated dees-input-multitoggle with transparent blue indicator and smooth animations
- Updated dees-appui-tabs with animated sliding indicator for both horizontal and vertical layouts
- Fixed indicator positioning to be perfectly centered on tab content
- Indicator width is content width + 8px for minimal visual padding
- Fixed tab content centering by using consistent padding (12px → 16px on all sides)
- Fixed icon rendering by correcting property name from .iconName to .icon
- Added visual separators between tabs for better distinction
- Added subtle hover backgrounds for improved interactivity
- Refactored tabs component code for better maintainability and elegance
- Updated dees-appui-activitylog with shadcn-aligned styling:
- Updated background and text colors to match shadcn palette
- Enhanced topbar with better spacing and typography
- Improved activity entries with subtle hover states and better spacing
- Added activity type icons with color-coded backgrounds (login, logout, view, create, update)
- Added date separators ("Today", "Yesterday") for better temporal organization
- Enhanced streaming indicators with animated pulse effect
- Redesigned searchbox with modern input styling, search icon, and focus states
- Added custom scrollbar styling for consistency
- Updated timestamps to be more subtle with tabular number formatting
- Refined shadow effects for better visual hierarchy
- Added subtle box shadow to component for depth
- Added fade-in animation for new activity entries
- Improved user name highlighting with better typography
- Updated context menu with more relevant actions
- Improved overall spacing and visual consistency across components
## 2025-06-27 - 1.10.1 - fix(modal)
Improve modal overscroll behavior by adding 'overscroll-behavior: contain' to content container
- Added 'overscroll-behavior: contain' to .modal .content to ensure proper scroll containment
- Applied overscroll-behavior in modal container for enhanced responsiveness on mobile and desktop
## 2025-06-26 - 1.10.0 - feat(dees-modal)
Add mobileFullscreen option to modals for full-screen mobile support
- Introduced a new boolean property 'mobileFullscreen' in ts_web/elements/dees-modal.ts
- Updated modal CSS under the media query to apply 'mobile-fullscreen' class, allowing full viewport modals on mobile devices
- Extended modal style rules to include adjustments for margin, border-radius, and maximum heights on smaller screens
## 2025-06-26 - 1.9.9 - fix(dees-input-multitoggle, dees-input-typelist)
Replace dynamic import with static import for demo functions in dees-input-multitoggle and dees-input-typelist
- Converted `await import('./dees-input-multitoggle.demo.js')` to a direct static import.
- Converted `await import('./dees-input-typelist.demo.js')` to a direct static import to improve build performance and clarity.
## 2025-06-26 - 1.9.8 - fix(deps, windowlayer)
Update dependency versions and adjust dees-windowlayer CSS to add pointer-events fix
- Bump @design.estate/dees-wcctools from ^1.0.98 to ^1.0.101
- Bump @tiptap packages from 2.22.3 to 2.23.0
- Bump lucide from ^0.522.0 to ^0.523.0
- Bump @git.zone/tsbundle from ^2.4.0 to ^2.5.1 and tswatch from ^2.0.37 to ^2.1.2
- Add 'pointer-events: none' to dees-windowlayer CSS to improve overlay behavior
## 2025-06-22 - 1.9.0 - feat(form-inputs)
Improve form input consistency and auto spacing across inputs and buttons
- Add an 'insideForm' property to dees-button for auto-detection and proper margin adjustment in forms.
- Update dees-input-radio to include a 'name' property so that radio buttons in the same group are mutually exclusive.
- Enhance dees-form to group radio inputs properly when collecting form data.
- Revise readme.hints.md and readme.plan.md to document changes and provide guidance for dees-input-radio.
- Update demos for dees-button and dees-form to showcase correct spacing in vertical and horizontal layouts.
## 2025-06-20 - 1.8.20 - fix(deps)
Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0
- Upgrade @design.estate/dees-domtools from ^2.1.1 to ^2.3.3
- Upgrade @design.estate/dees-element from ^2.0.42 to ^2.0.44
- Upgrade lucide from ^0.515.0 to ^0.518.0
- Upgrade @git.zone/tsbundle from ^2.0.15 to ^2.4.0
## 2025-06-10 - 1.8.1 - fix(dees-statsgrid)
Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles.
- Center-align tile header elements by setting align-items to center and ensuring full width.
- Increase tile content height to 90px and center its content.
- Update gauge visualization: reduce circle radius from 40 to 30, adjust stroke dasharray (from 251.2 to 188.5), and decrease gauge text font size.
- Refine trend chart layout: set trend-svg height to 40px, center trend value and adjust typography to larger, bolder text.
- Ensure overall grid responsiveness with adjusted gap and column sizing.
## 2025-04-25 - 1.8.0 - feat(dees-pagination)
Add new pagination component to the library along with its demo and integration in the main export.
- Introduced dees-pagination component with support for various page range scenarios.
- Created demo file to showcase pagination with both small and large sets of pages.
- Updated the module's index to export the new pagination component.
## 2025-04-22 - 1.7.0 - feat(dees-searchbar)
Add dees-searchbar component with live search and filter demo
- Introduces a new dees-searchbar element with an input field, a search button, and filters
- Wires up events for 'search-changed' and 'search-submit' to provide realtime feedback
- Adds a demo file to showcase usage and logging of search events
## 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
- 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
## 2025-04-18 - 1.5.6 - fix(dependencies)
Bump dependency versions and update demo code references

43
codex.md Normal file
View File

@ -0,0 +1,43 @@
# 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.

View File

@ -1,23 +1,23 @@
{
"name": "@design.estate/dees-catalog",
"version": "1.5.6",
"version": "1.10.9",
"private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js",
"typings": "dist_ts_web/index.d.ts",
"type": "module",
"scripts": {
"test": "tstest test/ --web",
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
"test": "tstest test/ --web --verbose --timeout 30 --logfile",
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild",
"watch": "tswatch element",
"buildDocs": "tsdoc"
},
"author": "Lossless GmbH",
"license": "MIT",
"dependencies": {
"@design.estate/dees-domtools": "^2.1.1",
"@design.estate/dees-element": "^2.0.41",
"@design.estate/dees-wcctools": "^1.0.90",
"@design.estate/dees-domtools": "^2.3.3",
"@design.estate/dees-element": "^2.0.45",
"@design.estate/dees-wcctools": "^1.1.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
@ -25,25 +25,31 @@
"@push.rocks/smarti18n": "^1.0.4",
"@push.rocks/smartpromise": "^4.2.0",
"@push.rocks/smartstring": "^4.0.15",
"@tsclass/tsclass": "^9.0.0",
"@tiptap/core": "^2.23.0",
"@tiptap/extension-link": "^2.23.0",
"@tiptap/extension-text-align": "^2.23.0",
"@tiptap/extension-typography": "^2.23.0",
"@tiptap/extension-underline": "^2.23.0",
"@tiptap/starter-kit": "^2.23.0",
"@tsclass/tsclass": "^9.2.0",
"@webcontainer/api": "1.2.0",
"apexcharts": "^4.3.0",
"apexcharts": "^4.7.0",
"highlight.js": "11.11.1",
"ibantools": "^4.5.1",
"lucide": "^0.501.0",
"lucide": "^0.525.0",
"monaco-editor": "^0.52.2",
"pdfjs-dist": "^4.10.38",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.84",
"@git.zone/tsbundle": "^2.0.15",
"@git.zone/tstest": "^1.0.90",
"@git.zone/tswatch": "^2.0.37",
"@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tstest": "^2.3.1",
"@git.zone/tswatch": "^2.1.2",
"@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/tapbundle": "^5.5.6",
"@types/node": "^22.14.1"
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.0.0"
},
"files": [
"ts/**/*",

3343
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,513 @@
# Building Applications with dees-appui Architecture
## Overview
The dees-appui system provides a comprehensive framework for building desktop-style web applications with a consistent layout, navigation, and view management system. This document outlines the architecture and best practices for building applications using these components.
## Core Architecture
### Component Hierarchy
```
dees-appui-base
├── dees-appui-appbar (top menu bar)
├── dees-appui-mainmenu (left sidebar - primary navigation)
├── dees-appui-mainselector (second sidebar - contextual navigation)
├── dees-appui-maincontent (main content area)
│ └── dees-appui-view (view container)
│ └── dees-appui-tabs (tab navigation within views)
└── dees-appui-activitylog (right sidebar - optional)
```
### View-Based Architecture
The system is built around the concept of **Views** - self-contained modules that represent different sections of your application. Each view can have:
- Its own tabs for sub-navigation
- Menu items for the selector (contextual navigation)
- Content areas with dynamic loading
- State management
- Event handling
## Implementation Plan
### Phase 1: Application Shell Setup
```typescript
// app-shell.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { IAppView } from '@design.estate/dees-catalog';
@customElement('my-app-shell')
export class MyAppShell extends LitElement {
@property({ type: Array })
views: IAppView[] = [];
@property({ type: String })
activeViewId: string = '';
render() {
const activeView = this.views.find(v => v.id === this.activeViewId);
return html`
<dees-appui-base
.appbarMenuItems=${this.getAppBarMenuItems()}
.appbarBreadcrumbs=${this.getBreadcrumbs()}
.appbarTheme=${'dark'}
.appbarUser=${{ name: 'User', status: 'online' }}
.mainmenuTabs=${this.getMainMenuTabs()}
.mainselectorOptions=${activeView?.menuItems || []}
@mainmenu-tab-select=${this.handleMainMenuSelect}
@mainselector-option-select=${this.handleSelectorSelect}
>
<dees-appui-view
slot="maincontent"
.viewConfig=${activeView}
@view-tab-select=${this.handleViewTabSelect}
></dees-appui-view>
</dees-appui-base>
`;
}
}
```
### Phase 2: View Definition
```typescript
// views/dashboard-view.ts
export const dashboardView: IAppView = {
id: 'dashboard',
name: 'Dashboard',
description: 'System overview and metrics',
iconName: 'home',
tabs: [
{
key: 'overview',
iconName: 'chart-line',
action: () => console.log('Overview selected'),
content: () => html`
<dashboard-overview></dashboard-overview>
`
},
{
key: 'metrics',
iconName: 'tachometer-alt',
action: () => console.log('Metrics selected'),
content: () => html`
<dashboard-metrics></dashboard-metrics>
`
},
{
key: 'alerts',
iconName: 'bell',
action: () => console.log('Alerts selected'),
content: () => html`
<dashboard-alerts></dashboard-alerts>
`
}
],
menuItems: [
{ key: 'Time Range', action: () => showTimeRangeSelector() },
{ key: 'Refresh Rate', action: () => showRefreshSettings() },
{ key: 'Export Data', action: () => exportDashboardData() }
]
};
```
### Phase 3: View Management System
```typescript
// services/view-manager.ts
export class ViewManager {
private views: Map<string, IAppView> = new Map();
private activeView: IAppView | null = null;
private viewCache: Map<string, any> = new Map();
registerView(view: IAppView) {
this.views.set(view.id, view);
}
async activateView(viewId: string) {
const view = this.views.get(viewId);
if (!view) throw new Error(`View ${viewId} not found`);
// Deactivate current view
if (this.activeView) {
await this.deactivateView(this.activeView.id);
}
// Activate new view
this.activeView = view;
// Update navigation
this.updateMainSelector(view.menuItems);
this.updateBreadcrumbs(view);
// Load view data if needed
if (!this.viewCache.has(viewId)) {
await this.loadViewData(view);
}
return view;
}
private async loadViewData(view: IAppView) {
// Implement lazy loading of view data
const viewData = await import(`./views/${view.id}/data.js`);
this.viewCache.set(view.id, viewData);
}
}
```
### Phase 4: Navigation Integration
```typescript
// navigation/app-navigation.ts
export class AppNavigation {
constructor(
private viewManager: ViewManager,
private appShell: MyAppShell
) {}
setupMainMenu(): ITab[] {
return [
{
key: 'dashboard',
iconName: 'home',
action: () => this.navigateToView('dashboard')
},
{
key: 'projects',
iconName: 'folder',
action: () => this.navigateToView('projects')
},
{
key: 'analytics',
iconName: 'chart-bar',
action: () => this.navigateToView('analytics')
},
{
key: 'settings',
iconName: 'cog',
action: () => this.navigateToView('settings')
}
];
}
async navigateToView(viewId: string) {
const view = await this.viewManager.activateView(viewId);
this.appShell.activeViewId = viewId;
// Update URL
window.history.pushState(
{ viewId },
view.name,
`/${viewId}`
);
}
handleBrowserNavigation() {
window.addEventListener('popstate', (event) => {
if (event.state?.viewId) {
this.navigateToView(event.state.viewId);
}
});
}
}
```
### Phase 5: Dynamic View Loading
```typescript
// views/view-loader.ts
export class ViewLoader {
private loadedViews: Set<string> = new Set();
async loadView(viewId: string): Promise<IAppView> {
if (this.loadedViews.has(viewId)) {
return this.getViewConfig(viewId);
}
// Dynamic import
const viewModule = await import(`./views/${viewId}/index.js`);
const viewConfig = viewModule.default as IAppView;
// Register custom elements if needed
if (viewModule.registerElements) {
await viewModule.registerElements();
}
this.loadedViews.add(viewId);
return viewConfig;
}
async preloadViews(viewIds: string[]) {
const promises = viewIds.map(id => this.loadView(id));
await Promise.all(promises);
}
}
```
## Best Practices
### 1. View Organization
```
src/
├── views/
│ ├── dashboard/
│ │ ├── index.ts # View configuration
│ │ ├── data.ts # Data fetching/management
│ │ ├── components/ # View-specific components
│ │ │ ├── dashboard-overview.ts
│ │ │ ├── dashboard-metrics.ts
│ │ │ └── dashboard-alerts.ts
│ │ └── styles.ts # View-specific styles
│ ├── projects/
│ │ └── ...
│ └── settings/
│ └── ...
├── services/
│ ├── view-manager.ts
│ ├── navigation.ts
│ └── state-manager.ts
└── app-shell.ts
```
### 2. State Management
```typescript
// services/state-manager.ts
export class StateManager {
private viewStates: Map<string, any> = new Map();
saveViewState(viewId: string, state: any) {
this.viewStates.set(viewId, {
...this.getViewState(viewId),
...state,
lastUpdated: Date.now()
});
}
getViewState(viewId: string): any {
return this.viewStates.get(viewId) || {};
}
// Persist to localStorage
persistState() {
const serialized = JSON.stringify(
Array.from(this.viewStates.entries())
);
localStorage.setItem('app-state', serialized);
}
restoreState() {
const saved = localStorage.getItem('app-state');
if (saved) {
const entries = JSON.parse(saved);
this.viewStates = new Map(entries);
}
}
}
```
### 3. View Communication
```typescript
// events/view-events.ts
export class ViewEventBus {
private eventTarget = new EventTarget();
emit(eventName: string, detail: any) {
this.eventTarget.dispatchEvent(
new CustomEvent(eventName, { detail })
);
}
on(eventName: string, handler: (detail: any) => void) {
this.eventTarget.addEventListener(eventName, (e: CustomEvent) => {
handler(e.detail);
});
}
// Cross-view communication
sendMessage(fromView: string, toView: string, message: any) {
this.emit('view-message', {
from: fromView,
to: toView,
message
});
}
}
```
### 4. Responsive Design
```typescript
// views/responsive-view.ts
export const createResponsiveView = (config: IAppView): IAppView => {
return {
...config,
tabs: config.tabs.map(tab => ({
...tab,
content: () => html`
<div class="view-content ${getDeviceClass()}">
${tab.content()}
</div>
`
}))
};
};
function getDeviceClass(): string {
const width = window.innerWidth;
if (width < 768) return 'mobile';
if (width < 1024) return 'tablet';
return 'desktop';
}
```
### 5. Performance Optimization
```typescript
// optimization/lazy-components.ts
export const lazyComponent = (
importFn: () => Promise<any>,
componentName: string
) => {
let loaded = false;
return () => {
if (!loaded) {
importFn().then(() => {
loaded = true;
});
return html`<dees-spinner></dees-spinner>`;
}
return html`<${componentName}></${componentName}>`;
};
};
// Usage in view
tabs: [
{
key: 'heavy-component',
content: lazyComponent(
() => import('./components/heavy-component.js'),
'heavy-component'
)
}
]
```
## Advanced Features
### 1. View Permissions
```typescript
interface IAppViewWithPermissions extends IAppView {
requiredPermissions?: string[];
visibleTo?: (user: User) => boolean;
}
class PermissionManager {
canAccessView(view: IAppViewWithPermissions, user: User): boolean {
if (view.visibleTo) {
return view.visibleTo(user);
}
if (view.requiredPermissions) {
return view.requiredPermissions.every(
perm => user.permissions.includes(perm)
);
}
return true;
}
}
```
### 2. View Lifecycle Hooks
```typescript
interface IAppViewLifecycle extends IAppView {
onActivate?: () => Promise<void>;
onDeactivate?: () => Promise<void>;
onTabChange?: (oldTab: string, newTab: string) => void;
onDestroy?: () => void;
}
```
### 3. Dynamic Menu Generation
```typescript
class DynamicMenuBuilder {
buildMainMenu(views: IAppView[], user: User): ITab[] {
return views
.filter(view => this.canShowInMenu(view, user))
.map(view => ({
key: view.id,
iconName: view.iconName || 'file',
action: () => this.navigation.navigateToView(view.id)
}));
}
buildSelectorMenu(view: IAppView, context: any): ISelectionOption[] {
const baseItems = view.menuItems || [];
const contextItems = this.getContextualItems(view, context);
return [...baseItems, ...contextItems];
}
}
```
## Migration Strategy
For existing applications:
1. **Identify Views**: Map existing routes/pages to views
2. **Extract Components**: Move page-specific components into view folders
3. **Define View Configs**: Create IAppView configurations
4. **Update Navigation**: Replace existing routing with view navigation
5. **Migrate State**: Move page state to ViewManager
6. **Test & Optimize**: Ensure smooth transitions and performance
## Example Application Structure
```typescript
// main.ts
import { ViewManager } from './services/view-manager.js';
import { AppNavigation } from './services/navigation.js';
import { dashboardView } from './views/dashboard/index.js';
import { projectsView } from './views/projects/index.js';
import { settingsView } from './views/settings/index.js';
const app = new MyAppShell();
const viewManager = new ViewManager();
const navigation = new AppNavigation(viewManager, app);
// Register views
viewManager.registerView(dashboardView);
viewManager.registerView(projectsView);
viewManager.registerView(settingsView);
// Setup navigation
app.views = [dashboardView, projectsView, settingsView];
navigation.setupMainMenu();
navigation.handleBrowserNavigation();
// Initial navigation
navigation.navigateToView('dashboard');
document.body.appendChild(app);
```
This architecture provides:
- **Modularity**: Each view is self-contained
- **Scalability**: Easy to add new views
- **Performance**: Lazy loading and caching
- **Consistency**: Unified navigation and layout
- **Flexibility**: Customizable per view
- **Maintainability**: Clear separation of concerns

View File

@ -1,4 +1,607 @@
!!! Please pay attention to the following points when writing the readme: !!!
* Give a short rundown of components and a few points abputspecific features on each.
* Give a short rundown of components and a few points abput specific features on each.
* Try to list all components in a summary.
* Then list all components with a short description.
## Chart Components
### dees-chart-area
- Fully functional area chart component using ApexCharts
- Displays time-series data with gradient fills
- Responsive with ResizeObserver (debounced to prevent flicker)
- Fixed: Chart now properly respects container boundaries on initial render
- Overflow prevention with proper CSS containment
- Enhanced demo features:
- Multiple dataset examples (System Usage, Network Traffic, Sales Analytics)
- Real-time data simulation with automatic updates
- Dynamic dataset switching
- Customizable Y-axis formatters (percentages, currency, units)
- Data randomization for testing
- Manual data point addition
- Properties:
- `label`: Chart title
- `series`: ApexAxisChartSeries data
- `yAxisFormatter`: Custom Y-axis label formatter function
- Methods:
- `updateSeries()`: Update chart data
- `appendData()`: Add new data points to existing series
- Demo uses global reference to access chart element (window.__demoChartElement)
### dees-chart-log
- Server log viewer component (not a chart despite the name)
- Terminal-style interface with monospace font
- Supports log levels: debug, info, warn, error, success
- Features:
- Auto-scroll toggle
- Clear logs button
- Colored log levels
- Timestamp with milliseconds
- Source labels for log entries
- Maximum 1000 entries (configurable)
- Light/dark theme support
- Demo includes realistic server log simulation
- Note: In demos, buttons use `@clicked` event (not `@click`)
- Demo uses global reference to access log element (window.__demoLogElement)
## UI Components
### dees-button-group
- Groups multiple buttons together with a unified background
- Properties:
- `label`: Optional label text displayed before the buttons
- `direction`: 'horizontal' | 'vertical' layout
- Features:
- Light/dark theme support
- Flexible layout with proper spacing
- Works with all button types (normal, highlighted, success, danger)
- Use cases:
- View mode selectors
- Action grouping
- Navigation options
- Filter controls
## Form Components
### dees-input-radio
- Radio button component with proper group behavior
- Properties:
- `name`: Group name for mutually exclusive selection
- `key`: Unique identifier for the radio option
- `value`: Boolean indicating selection state
- `label`: Display label
- Features:
- Automatic group management (radios with same name are mutually exclusive)
- Cannot be deselected by clicking (proper radio behavior)
- Form integration: Radio groups are collected by name, value is the selected radio's key
- Works both inside and outside forms
- Supports disabled state
- Fixed: Radio buttons now properly deselect others in the group on first click
- Note: When using in forms, set both `name` (for grouping) and `key` (for the value)
## WYSIWYG Editor Architecture
### Recent Refactoring (2025-06-24)
The WYSIWYG editor has been refactored to improve maintainability and separation of concerns:
#### New Handler Classes
1. **WysiwygBlockOperations** (`wysiwyg.blockoperations.ts`)
- Manages all block-related operations
- Methods: createBlock, insertBlockAfter, removeBlock, findBlock, focusBlock, etc.
- Centralized block manipulation logic
2. **WysiwygInputHandler** (`wysiwyg.inputhandler.ts`)
- Handles all input events for blocks
- Manages block content updates based on type
- Detects block type transformations
- Handles slash commands
- Manages auto-save with debouncing
3. **WysiwygKeyboardHandler** (`wysiwyg.keyboardhandler.ts`)
- Handles all keyboard events
- Manages formatting shortcuts (Cmd/Ctrl + B/I/U/K)
- Handles special keys: Tab, Enter, Backspace
- Manages slash menu navigation
4. **WysiwygDragDropHandler** (`wysiwyg.dragdrophandler.ts`)
- Manages drag and drop operations
- Tracks drag state
- Handles visual feedback during drag
- Manages block reordering
5. **WysiwygModalManager** (`wysiwyg.modalmanager.ts`)
- Static methods for showing modals
- Language selection for code blocks
- Block settings modal
- Reusable modal patterns
#### Main Component Updates
The main `DeesInputWysiwyg` component now:
- Instantiates handler classes in `connectedCallback`
- Delegates complex operations to appropriate handlers
- Maintains cleaner, more focused code
- Better separation of concerns
#### Benefits
- Reduced main component size from 1100+ lines
- Each handler class is focused on a single responsibility
- Easier to test individual components
- Better code organization
- Improved maintainability
#### Fixed Issues
- Enter key no longer duplicates content in new blocks
- Removed problematic `setBlockContents()` method
- Content is now managed directly through DOM properties
- Better timing for block creation and focus
- Slash menu no longer disappears immediately on first "/" press
- Focus is properly maintained when slash menu opens
- Removed duplicate event handling methods from main component
- Simplified focus management throughout the editor
#### Additional Refactoring (2025-06-24 - Part 2)
- **Removed duplicate code**: handleBlockInput and handleBlockKeyDown methods removed from main component
- **Simplified focus management**: Removed complex lifecycle methods and timers
- **Fixed slash menu behavior**: Changed to click events and proper event prevention
- **dees-wysiwyg-block component**: Now uses static HTML rendering for better content control
- **Improved formatting preservation**: HTML formatting (bold, italic, etc.) properly preserved in all block types
#### Notes
- All input handling now goes through WysiwygInputHandler
- All keyboard handling goes through WysiwygKeyboardHandler
- The slash menu uses click events instead of mousedown for better UX
- Focus is maintained using requestAnimationFrame for better timing
- The refactoring maintains all existing functionality with improved reliability
### Global Menu Architecture (2025-06-24 - Part 3)
The slash menu and formatting menu have been refactored to render globally instead of inside the wysiwyg component. This fixes focus loss issues that were occurring when the menus were re-rendered with the component.
#### Key Components:
1. **DeesSlashMenu** (`dees-slash-menu.ts`)
- Singleton component that renders globally in the document body
- Accessed via `DeesSlashMenu.getInstance()`
- Manages its own visibility, position, and filtering
- Emits callbacks when items are selected
2. **DeesFormattingMenu** (`dees-formatting-menu.ts`)
- Singleton component that renders globally in the document body
- Accessed via `DeesFormattingMenu.getInstance()`
- Shows when text is selected
- Applies formatting commands via callback
3. **Integration in DeesInputWysiwyg**
- Stores singleton instances: `private slashMenu = DeesSlashMenu.getInstance()`
- Shows menus with absolute positioning
- Menus handle their own rendering and state management
#### Benefits:
- No focus loss when menus appear/disappear
- Better performance (menus don't re-render with component)
- Cleaner separation of concerns
- Menus persist across component updates
#### Usage:
```typescript
// Show slash menu
this.slashMenu.show(
{ x: cursorX, y: cursorY },
(type: string) => this.insertBlock(type)
);
// Show formatting menu
this.formattingMenu.show(
{ x: selectionX, y: selectionY },
(command: string) => this.applyFormat(command)
);
```
#### Previous Issues Fixed:
- Slash menu was disappearing immediately on first "/" press
- Focus was lost when menus appeared
- Text selection was not working properly
- Cursor position was lost after menu interactions
### Arrow Key Navigation (2025-06-24 - Part 4)
Enhanced arrow key handling for seamless navigation between blocks:
#### Features:
1. **ArrowUp at block start**: Automatically navigates to the end of the previous block
2. **ArrowDown at block end**: Automatically navigates to the beginning of the next block
3. **Smart detection**: Checks actual cursor position within the block content
4. **Slash menu integration**: When slash menu is open, arrow keys navigate menu items instead
5. **No focus loss**: Navigation maintains focus throughout
#### Implementation:
- Added `handleArrowUp()` and `handleArrowDown()` methods to `WysiwygKeyboardHandler`
- Smart cursor position detection for different block types (text, lists, etc.)
- Helper method `getLastTextNode()` for finding the last text position in complex HTML
- Prevents default behavior only when navigating between blocks
- Skips divider blocks during navigation
### Focus Management Improvements (2025-06-24 - Part 5)
Enhanced focus management to prevent focus loss during various operations:
#### Key Improvements:
1. **Formatting Without execCommand**:
- Replaced deprecated `document.execCommand` with modern DOM manipulation
- Proper selection restoration after formatting
- Async formatting operations to maintain focus
2. **Link Dialog**:
- Replaced `prompt()` with custom modal dialog
- Maintains focus context during async operations
- Auto-focuses input field in modal
3. **Robust Focus Methods**:
- Double `requestAnimationFrame` for DOM update timing
- Fallback focus attempts with microtasks
- Contenteditable attribute verification
4. **Cursor Positioning**:
- Enhanced `setCursorToStart/End` with edge case handling
- Zero-width space insertion for empty elements
- Recursive node traversal for complex HTML structures
5. **Async Keyboard Shortcuts**:
- Formatting shortcuts use Promise resolution
- Prevents focus loss during rapid keyboard input
#### Implementation Details:
- `focusWithCursor()` method now handles empty blocks and complex HTML
- `applyFormat()` is async and properly restores selection
- Link creation no longer uses blocking `prompt()` dialog
- All focus operations use proper timing with RAF and microtasks
### Focus Loss Prevention for Menus (2025-06-24 - Part 6)
Fixed focus loss issues when slash menu and formatting menu appear:
#### Key Fixes:
1. **Timeout Reduction**:
- Replaced 50ms setTimeout with requestAnimationFrame
- Immediate focus attempt before falling back to RAF
- Reduced delay when inserting blocks
2. **Menu Focus Prevention**:
- Added `tabindex="-1"` to prevent menus from taking focus
- Added focus event prevention on menus
- Menus now use mousedown prevention consistently
3. **Blur Event Handling**:
- Skip value updates when slash menu is visible
- Prevent auto-save during slash menu interaction
- Maintain focus after menu appears with RAF
4. **Block Focus Optimization**:
- Try immediate focus if block element exists
- Fall back to RAF only when necessary
- Consistent focus handling across all block types
#### Implementation:
- `handleBlockBlur()` checks if slash menu is visible before updating
- `scheduleAutoSave()` skips saving when slash menu is open
- Slash menu show adds RAF to restore focus if lost
- Reduced timing delays throughout the focus chain
### Slash Command Cleanup (2025-06-24 - Part 7)
Fixed the issue where "/" remained in the editor after selecting a block type:
#### The Fix:
1. **In `insertBlock()`**:
- Clear slash command before transforming block type
- Use regex `/^\/[^\s]*\s*/` to match slash + filter text
- Trim the result to ensure clean content
- Set content to empty for transformed blocks
2. **Improved Content Handling**:
- Wait for `updateComplete` before focusing
- Ensure lists start with empty content
- Consistent cleanup in both `insertBlock` and `closeSlashMenu`
3. **Edge Cases**:
- Handle filtered commands (e.g., "/hea" for heading)
- Clear content even with partial matches
- Proper content reset for all block types
Now when selecting a block type from the slash menu, the "/" and any filter text is properly removed before the block transformation occurs.
### Enhanced Enter Key and Block Settings (2025-06-24 - Part 8)
Added two major improvements to the wysiwyg editor:
#### 1. Smart Enter Key Behavior:
When pressing Enter, content after the cursor is now moved to the next block:
- **Content Splitting**: Uses Range API to extract content after cursor
- **HTML Preservation**: Maintains formatting when splitting blocks
- **Clean Split**: Current block keeps content before cursor, new block gets content after
- **Empty Block**: If cursor is at end, creates empty new block
Implementation in `WysiwygKeyboardHandler.handleEnter()`:
```typescript
// Clone the range to extract content after cursor
const afterRange = range.cloneRange();
afterRange.selectNodeContents(target);
afterRange.setStart(range.endContainer, range.endOffset);
// Extract content after cursor
const afterContent = afterRange.extractContents();
```
#### 2. Block Type Changing via Settings Menu:
The block settings menu (three dots) now includes block type selection:
- **Type Selector Grid**: Shows all available block types with icons
- **Smart Metadata Handling**:
- Clears code language when changing from code block
- Clears list type when changing from list
- Prompts for language when changing to code block
- **Visual Feedback**: Currently selected type is highlighted
- **Instant Update**: Block transforms immediately on selection
Features:
- Works for all block types (not just code blocks)
- Preserves content during type transformation
- Handles special cases like code block language selection
- Modal closes automatically after selection
### Complete WYSIWYG Refactoring (2025-06-24 - Part 9)
Major architectural improvements to fix Enter key behavior and left arrow focus loss:
#### 1. Async Operation Architecture:
- All focus operations are now async with proper Promise handling
- `insertBlockAfter()` waits for component updates before focusing
- `focusBlock()` ensures DOM is ready with `updateComplete`
- Eliminated arbitrary timeouts in favor of proper async/await
#### 2. Enter Key Split Content Fix:
- Added `getSplitContent()` method to block component
- Properly extracts content before/after cursor using Range API
- Updates current block and creates new block atomically
- Content after cursor correctly moves to new block
```typescript
// In block component
public getSplitContent(): { before: string; after: string } | null {
const beforeRange = range.cloneRange();
beforeRange.selectNodeContents(this.blockElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
// ... extract and return split content
}
```
#### 3. Arrow Key Navigation:
- Added ArrowLeft/ArrowRight handlers for block boundaries
- Prevents focus loss when navigating between blocks
- Only intercepts at block boundaries, normal navigation otherwise
- All arrow key operations are async for proper timing
#### 4. Interface Architecture:
Created `wysiwyg.interfaces.ts` with proper typing:
- `IWysiwygComponent` - Main component contract
- `IBlockOperations` - Block operation methods
- `IWysiwygBlockComponent` - Block component interface
- `IBlockEventHandlers` - Event handler signatures
#### 5. Focus Management Improvements:
- Eliminated double RAF in favor of single async flow
- Focus operations wait for DOM updates via `updateComplete`
- Proper cursor positioning after all operations
- No more focus loss during navigation
#### Key Changes:
1. Keyboard handler methods are now async
2. Block operations return Promises
3. Enter key properly splits content at cursor
4. Arrow keys handle block navigation without focus loss
5. All timing is handled via proper async/await patterns
The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems.
### Programmatic Rendering Solution (2025-06-24 - Part 10)
Fixed persistent focus loss issue by implementing fully programmatic rendering:
#### The Problem:
- User would click in a block, type text, then press arrow keys and lose focus
- Root cause: Lit was re-rendering components when block content was mutated
- Even with shouldUpdate() preventing re-renders, parent re-evaluation caused focus loss
#### The Solution:
1. **Static Parent Rendering**:
- Parent component renders only once with empty editor content div
- All blocks are created and managed programmatically via DOM manipulation
- No Lit re-renders triggered by state changes
2. **Manual Block Management**:
- `renderBlocksProgrammatically()` creates all block elements manually
- `createBlockElement()` builds block wrapper with all event handlers
- `updateBlockElement()` replaces individual blocks when needed
- No reactive properties trigger parent re-renders
3. **Content Update Strategy**:
- During typing, content is NOT immediately synced to data model
- Auto-save delayed to 2 seconds to avoid interference
- Content synced from DOM only on blur or before save
- `syncAllBlockContent()` reads from DOM when needed
4. **Focus Preservation**:
- Block components prevent re-renders with `shouldUpdate()`
- Parent never re-renders after initial load
- Focus remains stable during all editing operations
- Arrow key navigation works without focus loss
5. **Implementation Details**:
```typescript
// Parent render method - static after first render
render(): TemplateResult {
return html`
<div class="editor-content" id="editor-content">
<!-- Blocks rendered programmatically -->
</div>
`;
}
// All block operations use DOM manipulation
private renderBlocksProgrammatically() {
this.editorContentRef.innerHTML = '';
this.blocks.forEach(block => {
const blockWrapper = this.createBlockElement(block);
this.editorContentRef.appendChild(blockWrapper);
});
}
```
This approach completely eliminates focus loss by taking full control of the DOM and preventing any framework-induced re-renders during editing.
### Code Refactoring and Cleanup (2025-06-24 - Part 11)
Completed comprehensive refactoring to ensure clean, maintainable code with separated concerns:
#### Refactoring Changes:
1. **Drag and Drop Handler Cleanup**:
- Removed all `requestUpdate()` calls from drag handler
- Handler now only updates internal state
- Parent component handles DOM updates programmatically
- Simplified drag state management
2. **Unused Code Removal**:
- Removed duplicate `showBlockSettingsModal` method (using WysiwygModalManager)
- Removed duplicate `showLanguageSelectionModal` method
- Removed unused `renderBlock` method
- Cleaned up unused imports (WysiwygBlocks, ISlashMenuItem)
3. **Import Cleanup**:
- Removed unused type imports
- Organized imports logically
- Kept only necessary dependencies
4. **Separated Concerns**:
- Modal management in WysiwygModalManager
- Block operations in WysiwygBlockOperations
- Input handling in WysiwygInputHandler
- Keyboard handling in WysiwygKeyboardHandler
- Drag/drop in WysiwygDragDropHandler
- Each class has a single responsibility
5. **Programmatic DOM Management**:
- All DOM updates happen through explicit methods
- No reactive re-renders during user interaction
- Manual class management for drag states
- Direct DOM manipulation for performance
6. **Test Files Created**:
- `test-focus-fix.html` - Verifies focus management
- `test-drag-drop.html` - Tests drag and drop functionality
- `test-comprehensive.html` - Tests all features together
The refactoring follows the principles in instructions.md:
- Uses static templates with manual DOM operations
- Maintains separated concerns in different classes
- Results in clean, concise, and manageable code
## Z-Index Management System (2025-12-24)
A comprehensive z-index management system has been implemented to fix overlay stacking conflicts:
### The Problem:
- Modals were hiding dropdown overlays
- Context menus appeared behind modals
- Inconsistent z-index values across components
- No clear hierarchy for overlay stacking
### The Solution:
#### 1. Central Z-Index Constants (`00zindex.ts`):
Created a centralized file defining all z-index layers:
```typescript
export const zIndexLayers = {
// Base layer: Regular content
base: {
content: 'auto',
inputElements: 1,
},
// Fixed UI elements
fixed: {
appBar: 10,
sideMenu: 10,
mobileNav: 250,
},
// Overlay backdrops
backdrop: {
dropdown: 1999,
modal: 2999,
contextMenu: 3999,
},
// Interactive overlays
overlay: {
dropdown: 2000, // Dropdowns and select menus
modal: 3000, // Modal dialogs
contextMenu: 4000, // Context menus and tooltips
toast: 5000, // Toast notifications
},
// Special cases
modalDropdown: 3500, // Dropdowns inside modals
wysiwygMenus: 4500, // Editor formatting menus
}
```
#### 2. Updated Components:
- **dees-modal**: Changed from 2000 to 3000
- **dees-windowlayer**: Changed from 200-201 to 1999-2000 (used by dropdowns)
- **dees-contextmenu**: Changed from 10000 to 4000
- **dees-toast**: Changed from 10000 to 5000
- **wysiwyg menus**: Changed from 10000 to 4500
- **dees-appui-profiledropdown**: Uses new dropdown z-index (2000)
#### 3. Stacking Order (bottom to top):
1. Regular page content (auto)
2. Fixed navigation elements (10-250)
3. Dropdown backdrop (1999)
4. Dropdown content (2000)
5. Modal backdrop (2999)
6. Modal content (3000)
7. Context menu (4000)
8. WYSIWYG menus (4500)
9. Toast notifications (5000)
#### 4. Key Benefits:
- Dropdowns now appear above modals
- Context menus appear above dropdowns and modals
- Toast notifications always appear on top
- Consistent and predictable stacking behavior
- Easy to adjust hierarchy by modifying central constants
#### 5. Testing:
Created `test-zindex.demo.ts` to verify stacking behavior with:
- Modal containing dropdown
- Context menu on modal
- Toast notifications
- Complex overlay combinations
### Usage:
Import and use the z-index constants in any component:
```typescript
import { zIndexLayers } from './00zindex.js';
// In styles
z-index: ${zIndexLayers.overlay.modal};
```
This system ensures proper stacking order for all overlay components and prevents z-index conflicts.

967
readme.md

File diff suppressed because it is too large Load Diff

BIN
readme.plan.md Normal file

Binary file not shown.

View File

@ -0,0 +1,138 @@
# WYSIWYG Editor Refactoring Progress Summary
## Latest Updates
### Selection Highlighting Fix ✅
- **Issue**: "Paragraphs are not highlighted consistently, headings are always highlighted"
- **Root Cause**: The `shouldUpdate` method in `dees-wysiwyg-block.ts` was using a generic `.block` selector that would match the first element with that class, not necessarily the correct block element
- **Solution**: Changed the selector to be more specific: `.block.${blockType}` which ensures the correct element is found for each block type
- **Result**: All block types now highlight consistently when selected
### Enter Key Block Creation Fix ✅
- **Issue**: "When pressing enter and jumping to new block then typing something: The cursor is not at the beginning of the new block and there is content"
- **Root Cause**: Block handlers were rendering content with template syntax `${block.content || ''}` in their render methods, which violates the static HTML principle
- **Solution**:
- Removed all `${block.content}` from render methods in paragraph, heading, quote, and code block handlers
- Content is now set programmatically in the setup() method only when needed
- Fixed `setCursorToStart` and `setCursorToEnd` to always find elements fresh instead of relying on cached `blockElement`
- **Result**: New empty blocks remain truly empty, cursor positioning works correctly
### Backspace Key Deletion Fix ✅
- **Issue**: "After typing in a new block, pressing backspace deletes the whole block instead of just the last character"
- **Root Cause**:
1. `getCursorPositionInElement` was using `element.contains()` which doesn't work across Shadow DOM boundaries
2. The backspace handler was checking `block.content === ''` which only contains the stored content, not the actual DOM content
- **Solution**:
1. Fixed `getCursorPositionInElement` to use `containsAcrossShadowDOM` for proper Shadow DOM support
2. Updated backspace handler to get actual content from DOM using `blockComponent.getContent()` instead of relying on stored `block.content`
3. Added debug logging to track cursor position and content state
- **Result**: Backspace now correctly deletes individual characters instead of the whole block
### Arrow Left Navigation Fix ✅
- **Issue**: "When jumping to the previous block from the beginning of a block with arrow left, the cursor should be at the end of the previous block, not at the start"
- **Root Cause**: Browser's default focus behavior places cursor at the beginning of contenteditable elements, overriding our cursor positioning
- **Solution**: For 'end' position, set up the selection range BEFORE focusing the element:
1. Create a range pointing to the end of content
2. Apply the selection
3. Then focus the element (which preserves the existing selection)
4. Only use setCursorToEnd for empty blocks
- **Result**: Arrow left navigation now correctly places cursor at the end of the previous block
## Completed Phases
### Phase 1: Infrastructure ✅
- Created modular block handler architecture
- Implemented `IBlockHandler` interface and `BaseBlockHandler` class
- Created `BlockRegistry` for dynamic block type registration
- Set up proper file structure under `blocks/` directory
### Phase 2: Proof of Concept ✅
- Successfully migrated divider block as the simplest example
- Validated the architecture works correctly
- Established patterns for block migration
### Phase 3: Text Blocks ✅
- **Paragraph Block**: Full editing support with text splitting, selection handling, and cursor tracking
- **Heading Blocks**: All three heading levels (h1, h2, h3) with unified handler
- **Quote Block**: Italic styling with border, full editing capabilities
- **Code Block**: Monospace font, tab handling, plain text paste support
- **List Block**: Bullet/numbered lists with proper list item management
## Key Achievements
### 1. Preserved Critical Knowledge
- **Static Rendering**: Blocks use `innerHTML` in `firstUpdated` to prevent focus loss during typing
- **Shadow DOM Selection**: Implemented `containsAcrossShadowDOM` utility for proper selection detection
- **Cursor Position Tracking**: All editable blocks track cursor position across multiple events
- **Content Splitting**: HTML-aware splitting using Range API preserves formatting
- **Focus Management**: Microtask-based focus restoration ensures reliable cursor placement
### 2. Enhanced Architecture
- Each block type is now self-contained in its own file
- Block handlers are dynamically registered and loaded
- Common functionality is shared through base classes
- Styles are co-located with their block handlers
### 3. Maintained Functionality
- All keyboard navigation works (arrows, backspace, delete, enter)
- Text selection across Shadow DOM boundaries functions correctly
- Block merging and splitting behave as before
- IME (Input Method Editor) support is preserved
- Formatting shortcuts (Cmd/Ctrl+B/I/U/K) continue to work
## Code Organization
```
ts_web/elements/wysiwyg/
├── dees-wysiwyg-block.ts (simplified main component)
├── wysiwyg.selection.ts (Shadow DOM selection utilities)
├── wysiwyg.blockregistration.ts (handler registration)
└── blocks/
├── index.ts (exports and registry)
├── block.base.ts (base handler interface)
├── decorative/
│ └── divider.block.ts
└── text/
├── paragraph.block.ts
├── heading.block.ts
├── quote.block.ts
├── code.block.ts
└── list.block.ts
```
## Next Steps
### Phase 4: Media Blocks (In Progress)
- Image block with upload/drag-drop support
- YouTube block with video embedding
- Attachment block for file uploads
### Phase 5: Content Blocks
- Markdown block with preview toggle
- HTML block with raw HTML editing
### Phase 6: Cleanup
- Remove old code from main component
- Optimize bundle size
- Update documentation
## Technical Improvements
1. **Modularity**: Each block type is now completely self-contained
2. **Extensibility**: New blocks can be added by creating a handler and registering it
3. **Maintainability**: Files are smaller and focused on single responsibilities
4. **Type Safety**: Strong TypeScript interfaces ensure consistent implementation
5. **Performance**: No degradation in performance; potential for lazy loading in future
## Migration Pattern
For future block migrations, follow this pattern:
1. Create block handler extending `BaseBlockHandler`
2. Implement required methods: `render()`, `setup()`, `getStyles()`
3. Add helper methods for cursor/content management
4. Handle Shadow DOM selection properly using utilities
5. Register handler in `wysiwyg.blockregistration.ts`
6. Test all interactions (typing, selection, navigation)
The refactoring has been successful in making the codebase more maintainable while preserving all the hard-won functionality and edge case handling from the original implementation.

82
readme.refactoring.md Normal file
View File

@ -0,0 +1,82 @@
# WYSIWYG Editor Refactoring
## Summary of Changes
This refactoring cleaned up the wysiwyg editor implementation to fix focus, cursor position, and selection issues.
### Phase 1: Code Organization
#### 1. Removed Duplicate Code
- Removed duplicate `handleBlockInput` method from main component (was already in inputHandler)
- Removed duplicate `handleBlockKeyDown` method from main component (was already in keyboardHandler)
- Consolidated all input handling in the respective handler classes
#### 2. Simplified Focus Management
- Removed complex `updated` lifecycle method that was trying to maintain focus
- Simplified `handleBlockBlur` to not immediately close menus
- Added `requestAnimationFrame` to focus operations for better timing
- Removed `slashMenuOpenTime` tracking which was no longer needed
#### 3. Fixed Slash Menu Behavior
- Changed from `@mousedown` to `@click` events for better UX
- Added proper event prevention to avoid focus loss
- Menu now closes when clicking outside
- Simplified the insertBlock method to close menu first
### Phase 2: Cursor & Selection Fixes
#### 4. Enhanced Cursor Position Management
- Added `focusWithCursor()` method to block component for precise cursor positioning
- Improved `handleSlashCommand` to preserve cursor position when menu opens
- Added `getCaretCoordinates()` for accurate menu positioning based on cursor location
- Updated `focusBlock()` to support numeric cursor positions
#### 5. Fixed Selection Across Shadow DOM
- Added custom `block-text-selected` event to communicate selections across shadow boundaries
- Implemented `handleMouseUp()` in block component to detect selections
- Updated main component to listen for selection events from blocks
- Selection now works properly even with nested shadow DOMs
#### 6. Improved Slash Menu Close Behavior
- Added optional `clearSlash` parameter to `closeSlashMenu()`
- Escape key now properly clears the slash command
- Clicking outside clears the slash if menu is open
- Selecting an item preserves content and just transforms the block
### Technical Improvements
#### Block Component (`dees-wysiwyg-block`)
- Better focus management with immediate focus (removed unnecessary requestAnimationFrame)
- Added cursor position control methods
- Custom event dispatching for cross-shadow-DOM communication
- Improved content handling for different block types
#### Input Handler
- Preserves cursor position when showing slash menu
- Better caret coordinate calculation for menu positioning
- Ensures focus stays in the block when menu appears
#### Block Operations
- Enhanced `focusBlock()` to support start/end/numeric positions
- Better timing with requestAnimationFrame for focus operations
### Key Benefits
- Slash menu no longer causes focus or cursor position loss
- Text selection works properly across shadow DOM boundaries
- Cursor position is preserved when interacting with menus
- Cleaner, more maintainable code structure
- Better separation of concerns
## Testing
Use the test files in `.nogit/debug/`:
- `test-slash-menu.html` - Tests slash menu focus behavior
- `test-wysiwyg-formatting.html` - Tests text formatting
## Known Issues Fixed
- Slash menu disappearing immediately on first "/"
- Focus lost when slash menu opens
- Cursor position lost when typing "/"
- Text selection not working properly
- Selection events not propagating across shadow DOM
- Duplicate event handling causing conflicts

72
test-output.log Normal file
View File

@ -0,0 +1,72 @@
> @design.estate/dees-catalog@1.10.8 test /mnt/data/lossless/design.estate/dees-catalog
> tstest test/ --web --verbose --timeout 30 --logfile test/test.tabs-indicator.browser.ts

🔍 Test Discovery
 Mode: file
 Pattern: test/test.tabs-indicator.browser.ts
 Found: 1 test file(s)

▶️ test/test.tabs-indicator.browser.ts (1/1)
 Runtime: chromium
running spawned compilation process
=======> ESBUILD
{
cwd: '/mnt/data/lossless/design.estate/dees-catalog',
from: 'test/test.tabs-indicator.browser.ts',
to: '/mnt/data/lossless/design.estate/dees-catalog/.nogit/tstest_cache/test__test.tabs-indicator.browser.ts.js',
mode: 'test',
argv: { bundler: 'esbuild' }
}
switched to /mnt/data/lossless/design.estate/dees-catalog
building for test:
Got no SSL certificates. Please ensure encryption using e.g. a reverse proxy
"/test" maps to 1 handlers
-> GET
"*" maps to 1 handlers
-> GET
now listening on 3007!
Launching puppeteer browser with arguments:
[]
Using executable: /usr/bin/google-chrome
added connection. now 1 sockets connected.
added connection. now 2 sockets connected.
connection ended
removed connection. 1 sockets remaining.
connection ended
removed connection. 0 sockets remaining.
added connection. now 1 sockets connected.
/favicon.ico
could not resolve /mnt/data/lossless/design.estate/dees-catalog/.nogit/tstest_cache/favicon.ico
/test__test.tabs-indicator.browser.ts.js
 Test starting: tabs indicator positioning debug
 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
 Using globalThis.tapPromise
 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
connection ended
removed connection. 0 sockets remaining.
=>  Stopped test/test.tabs-indicator.browser.ts chromium instance and server.

⚠️ Error
 Only 0 out of 1 completed!

⚠️ Error
 The amount of received tests and expectedTests is unequal! Therefore the testfile failed
 Summary: -1 passed, 1 failed of 0 tests in 2.7s

📊 Test Summary
┌────────────────────────────────┐
│ Total Files: 1 │
│ Total Tests: 0 │
│ Passed: 0 │
│ Failed: 0 │
│ Duration: 4.2s │
└────────────────────────────────┘

⏱️ Performance Metrics:
 Average per test: 0ms

ALL TESTS PASSED! 🎉
Exited NOT OK!
ELIFECYCLE Test failed. See above for more details.

View File

@ -1,6 +1,6 @@
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import * as deesCatalog from '../ts_web';
import * as deesCatalog from '../ts_web/index.js';
tap.test('should create a working button', async () => {
const button: deesCatalog.DeesButton = await webhelpers.fixture(
@ -9,4 +9,4 @@ tap.test('should create a working button', async () => {
expect(button).toBeInstanceOf(deesCatalog.DeesButton);
});
tap.start();
export default tap.start();

View File

@ -0,0 +1,35 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
import { demoFunc } from '../ts_web/elements/dees-contextmenu.demo.js';
tap.test('should render context menu demo', async () => {
// Create demo container
const demoContainer = document.createElement('div');
document.body.appendChild(demoContainer);
// Render the demo
const demoContent = demoFunc();
// Create a temporary element to hold the rendered template
const tempDiv = document.createElement('div');
tempDiv.innerHTML = demoContent.strings.join('');
// Check that panels are rendered
const panels = tempDiv.querySelectorAll('dees-panel');
expect(panels.length).toEqual(4);
// Check panel headings
expect(panels[0].getAttribute('heading')).toEqual('Basic Context Menu with Nested Submenus');
expect(panels[1].getAttribute('heading')).toEqual('Component-Specific Context Menu');
expect(panels[2].getAttribute('heading')).toEqual('Advanced Context Menu Example');
expect(panels[3].getAttribute('heading')).toEqual('Static Context Menu (Always Visible)');
// Check that static context menu exists
const staticMenu = tempDiv.querySelector('dees-contextmenu');
expect(staticMenu).toBeTruthy();
// Clean up
demoContainer.remove();
});
export default tap.start();

View File

@ -0,0 +1,93 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
tap.test('should close all parent menus when clicking action in nested submenu', async () => {
let actionCalled = false;
// Create a test element
const testDiv = document.createElement('div');
testDiv.style.width = '300px';
testDiv.style.height = '300px';
testDiv.style.background = '#f0f0f0';
testDiv.innerHTML = 'Right-click for nested menu test';
document.body.appendChild(testDiv);
// Simulate right-click to open context menu
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 150,
clientY: 150,
bubbles: true,
cancelable: true
});
// Open context menu with nested structure
DeesContextmenu.openContextMenuWithOptions(contextMenuEvent, [
{
name: 'Parent Item',
iconName: 'folder',
action: async () => {}, // Parent items with submenus need an action
submenu: [
{
name: 'Child Item',
iconName: 'file',
action: async () => {
actionCalled = true;
console.log('Child action called');
}
},
{
name: 'Another Child',
iconName: 'fileText',
action: async () => console.log('Another child')
}
]
},
{
name: 'Regular Item',
iconName: 'box',
action: async () => console.log('Regular item')
}
]);
// Wait for main menu to appear
await new Promise(resolve => setTimeout(resolve, 150));
// Check main menu exists
const mainMenu = document.querySelector('dees-contextmenu');
expect(mainMenu).toBeInstanceOf(DeesContextmenu);
// Hover over "Parent Item" to trigger submenu
const parentItem = mainMenu!.shadowRoot!.querySelector('.menuitem');
expect(parentItem).toBeTruthy();
parentItem!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
// Wait for submenu to appear
await new Promise(resolve => setTimeout(resolve, 300));
// Check submenu exists
const allMenus = document.querySelectorAll('dees-contextmenu');
expect(allMenus.length).toEqual(2); // Main menu and submenu
const submenu = allMenus[1];
expect(submenu).toBeTruthy();
// Click on "Child Item" in submenu
const childItem = submenu.shadowRoot!.querySelector('.menuitem');
expect(childItem).toBeTruthy();
childItem!.click();
// Wait for menus to close
await new Promise(resolve => setTimeout(resolve, 200));
// Verify action was called
expect(actionCalled).toEqual(true);
// Verify all menus are closed
const remainingMenus = document.querySelectorAll('dees-contextmenu');
expect(remainingMenus.length).toEqual(0);
// Clean up
testDiv.remove();
});
export default tap.start();

View File

@ -0,0 +1,71 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
import { DeesElement, customElement, html } from '@design.estate/dees-element';
// Create a test element with shadow DOM
@customElement('test-shadow-element')
class TestShadowElement extends DeesElement {
public getContextMenuItems() {
return [
{ name: 'Shadow Item 1', iconName: 'box', action: async () => console.log('Shadow 1') },
{ name: 'Shadow Item 2', iconName: 'package', action: async () => console.log('Shadow 2') }
];
}
render() {
return html`
<div style="padding: 40px; background: #eee; border-radius: 8px;">
<h3>Shadow DOM Content</h3>
<p>Right-click anywhere inside this shadow DOM</p>
</div>
`;
}
}
tap.test('should show context menu when right-clicking inside shadow DOM', async () => {
// Create the shadow DOM element
const shadowElement = document.createElement('test-shadow-element');
document.body.appendChild(shadowElement);
// Wait for element to be ready
await shadowElement.updateComplete;
// Get the content inside shadow DOM
const shadowContent = shadowElement.shadowRoot!.querySelector('div');
expect(shadowContent).toBeTruthy();
// Simulate right-click on content inside shadow DOM
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 100,
clientY: 100,
bubbles: true,
cancelable: true,
composed: true // Important for shadow DOM
});
shadowContent!.dispatchEvent(contextMenuEvent);
// Wait for context menu to appear
await new Promise(resolve => setTimeout(resolve, 100));
// Check if context menu is created
const contextMenu = document.querySelector('dees-contextmenu');
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
// Check if menu items from shadow element are rendered
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
expect(menuItems.length).toBeGreaterThanOrEqual(2);
// Check menu item text
const menuTexts = Array.from(menuItems).map(item =>
item.querySelector('.menuitem-text')?.textContent
);
expect(menuTexts).toContain('Shadow Item 1');
expect(menuTexts).toContain('Shadow Item 2');
// Clean up
contextMenu!.remove();
shadowElement.remove();
});
export default tap.start();

View File

@ -0,0 +1,77 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
tap.test('should show context menu with nested submenu', async () => {
// Create a test element with context menu items
const testDiv = document.createElement('div');
testDiv.style.width = '200px';
testDiv.style.height = '200px';
testDiv.style.background = '#eee';
testDiv.innerHTML = 'Right-click me';
// Add getContextMenuItems method
(testDiv as any).getContextMenuItems = () => {
return [
{
name: 'Change Type',
iconName: 'type',
submenu: [
{ name: 'Paragraph', iconName: 'text', action: () => console.log('Paragraph') },
{ name: 'Heading 1', iconName: 'heading1', action: () => console.log('Heading 1') },
{ name: 'Heading 2', iconName: 'heading2', action: () => console.log('Heading 2') },
{ divider: true },
{ name: 'Code Block', iconName: 'fileCode', action: () => console.log('Code') },
{ name: 'Quote', iconName: 'quote', action: () => console.log('Quote') }
]
},
{ divider: true },
{
name: 'Delete',
iconName: 'trash2',
action: () => console.log('Delete')
}
];
};
document.body.appendChild(testDiv);
// Simulate right-click
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 100,
clientY: 100,
bubbles: true,
cancelable: true
});
testDiv.dispatchEvent(contextMenuEvent);
// Wait for context menu to appear
await new Promise(resolve => setTimeout(resolve, 100));
// Check if context menu is created
const contextMenu = document.querySelector('dees-contextmenu');
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
// Check if menu items are rendered
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
expect(menuItems.length).toEqual(2); // "Change Type" and "Delete"
// Hover over "Change Type" to trigger submenu
const changeTypeItem = menuItems[0] as HTMLElement;
changeTypeItem.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
// Wait for submenu to appear
await new Promise(resolve => setTimeout(resolve, 300));
// Check if submenu is created
const submenus = document.querySelectorAll('dees-contextmenu');
expect(submenus.length).toEqual(2); // Main menu and submenu
// Clean up
contextMenu!.remove();
const submenu = submenus[1];
if (submenu) submenu.remove();
testDiv.remove();
});
export default tap.start();

View File

@ -0,0 +1,175 @@
import { expect, tap, webhelpers } from '@push.rocks/tapbundle';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
import { WysiwygSelection } from '../ts_web/elements/wysiwyg/wysiwyg.selection.js';
tap.test('Shadow DOM containment should work correctly', async () => {
console.log('=== Testing Shadow DOM Containment ===');
// Create a WYSIWYG block component
const block = await webhelpers.fixture<DeesWysiwygBlock>(
'<dees-wysiwyg-block></dees-wysiwyg-block>'
);
// Set the block data
block.block = {
id: 'test-1',
type: 'paragraph',
content: 'Hello world test content'
};
block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
await block.updateComplete;
// Get the paragraph element inside Shadow DOM
const container = block.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
expect(paragraphBlock).toBeTruthy();
console.log('Found paragraph block:', paragraphBlock);
console.log('Paragraph text content:', paragraphBlock.textContent);
// Focus the paragraph
paragraphBlock.focus();
// Manually set cursor position
const textNode = paragraphBlock.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
// Set cursor at position 11 (after "Hello world")
range.setStart(textNode, 11);
range.setEnd(textNode, 11);
selection?.removeAllRanges();
selection?.addRange(range);
console.log('Set cursor at position 11');
// Test the containment check
console.log('\n--- Testing containment ---');
const currentSelection = window.getSelection();
if (currentSelection && currentSelection.rangeCount > 0) {
const selRange = currentSelection.getRangeAt(0);
console.log('Selection range:', {
startContainer: selRange.startContainer,
startOffset: selRange.startOffset,
containerText: selRange.startContainer.textContent
});
// Test regular contains (should fail across Shadow DOM)
const regularContains = paragraphBlock.contains(selRange.startContainer);
console.log('Regular contains:', regularContains);
// Test Shadow DOM-aware contains
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selRange.startContainer);
console.log('Shadow DOM contains:', shadowDOMContains);
// Since we're setting selection within the same shadow DOM, both should be true
expect(regularContains).toBeTrue();
expect(shadowDOMContains).toBeTrue();
}
// Test getSplitContent
console.log('\n--- Testing getSplitContent ---');
const splitResult = block.getSplitContent();
console.log('Split result:', splitResult);
expect(splitResult).toBeTruthy();
if (splitResult) {
console.log('Before:', JSON.stringify(splitResult.before));
console.log('After:', JSON.stringify(splitResult.after));
// Expected split at position 11
expect(splitResult.before).toEqual('Hello world');
expect(splitResult.after).toEqual(' test content');
}
}
});
tap.test('Shadow DOM containment across different shadow roots', async () => {
console.log('=== Testing Cross Shadow Root Containment ===');
// Create parent component with WYSIWYG editor
const parentDiv = document.createElement('div');
parentDiv.innerHTML = `
<dees-input-wysiwyg>
<dees-wysiwyg-block></dees-wysiwyg-block>
</dees-input-wysiwyg>
`;
document.body.appendChild(parentDiv);
// Wait for components to be ready
await new Promise(resolve => setTimeout(resolve, 100));
const wysiwygInput = parentDiv.querySelector('dees-input-wysiwyg') as any;
const blockElement = wysiwygInput?.shadowRoot?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
if (blockElement) {
// Set block data
blockElement.block = {
id: 'test-2',
type: 'paragraph',
content: 'Cross shadow DOM test'
};
blockElement.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
await blockElement.updateComplete;
// Get the paragraph inside the nested shadow DOM
const container = blockElement.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
if (paragraphBlock) {
console.log('Found nested paragraph block');
// Focus and set selection
paragraphBlock.focus();
const textNode = paragraphBlock.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
range.setStart(textNode, 5);
range.setEnd(textNode, 5);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
// Test containment from parent's perspective
const selRange = selection?.getRangeAt(0);
if (selRange) {
// This should fail because it crosses shadow DOM boundary
const regularContains = wysiwygInput.contains(selRange.startContainer);
console.log('Parent regular contains:', regularContains);
expect(regularContains).toBeFalse();
// This should work with our Shadow DOM-aware method
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(wysiwygInput, selRange.startContainer);
console.log('Parent shadow DOM contains:', shadowDOMContains);
expect(shadowDOMContains).toBeTrue();
}
}
}
}
// Clean up
document.body.removeChild(parentDiv);
});
export default tap.start();

View File

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

View File

@ -0,0 +1,9 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
tap.test('should create wysiwyg editor', async () => {
const editor = new DeesInputWysiwyg();
expect(editor).toBeInstanceOf(DeesInputWysiwyg);
});
export default tap.start();

View File

@ -0,0 +1,85 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg block movement during drag', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
// Start dragging block 1
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: () => {},
setDragImage: () => {}
},
clientY: 50,
preventDefault: () => {},
} as any;
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
// Wait for dragging class
await new Promise(resolve => setTimeout(resolve, 20));
// Verify drag state
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Check that drag height was calculated
console.log('Checking drag height...');
const dragHandler = element.dragDropHandler as any;
console.log('draggedBlockHeight:', dragHandler.draggedBlockHeight);
console.log('draggedBlockContentHeight:', dragHandler.draggedBlockContentHeight);
// Manually call updateBlockPositions to simulate drag movement
console.log('Simulating drag movement...');
const updateBlockPositions = dragHandler.updateBlockPositions.bind(dragHandler);
// Simulate dragging down past block 2
const block2 = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
const block2Rect = block2.getBoundingClientRect();
const dragToY = block2Rect.bottom + 10;
console.log('Dragging to Y position:', dragToY);
updateBlockPositions(dragToY);
// Check if blocks have moved
await new Promise(resolve => setTimeout(resolve, 50));
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
console.log('Block states after drag:');
blocks.forEach((block, i) => {
const classes = block.className;
const offset = (block as HTMLElement).style.getPropertyValue('--drag-offset');
console.log(`Block ${i}: classes="${classes}", offset="${offset}"`);
});
// Check that at least one block has move class
const movedUpBlocks = editorContent.querySelectorAll('.block-wrapper.move-up');
const movedDownBlocks = editorContent.querySelectorAll('.block-wrapper.move-down');
console.log('Moved up blocks:', movedUpBlocks.length);
console.log('Moved down blocks:', movedDownBlocks.length);
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
tap.start();

View File

@ -0,0 +1,69 @@
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
tap.test('Debug: should create empty wysiwyg block component', async () => {
try {
console.log('Creating DeesWysiwygBlock...');
const block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
console.log('Block created:', block);
expect(block).toBeDefined();
expect(block).toBeInstanceOf(DeesWysiwygBlock);
console.log('Initial block property:', block.block);
console.log('Initial handlers property:', block.handlers);
} catch (error) {
console.error('Error creating block:', error);
throw error;
}
});
tap.test('Debug: should set properties step by step', async () => {
try {
console.log('Step 1: Creating component...');
const block: DeesWysiwygBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(block).toBeDefined();
console.log('Step 2: Setting handlers...');
block.handlers = {
onInput: () => console.log('onInput'),
onKeyDown: () => console.log('onKeyDown'),
onFocus: () => console.log('onFocus'),
onBlur: () => console.log('onBlur'),
onCompositionStart: () => console.log('onCompositionStart'),
onCompositionEnd: () => console.log('onCompositionEnd')
};
console.log('Handlers set:', block.handlers);
console.log('Step 3: Setting block data...');
block.block = {
id: 'test-block',
type: 'divider',
content: ' '
};
console.log('Block set:', block.block);
console.log('Step 4: Appending to body...');
document.body.appendChild(block);
console.log('Step 5: Waiting for update...');
await block.updateComplete;
console.log('Update complete');
console.log('Step 6: Checking shadowRoot...');
expect(block.shadowRoot).toBeDefined();
console.log('ShadowRoot exists');
} catch (error) {
console.error('Error in step-by-step test:', error);
throw error;
}
});
export default tap.start();

View File

@ -0,0 +1,205 @@
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
tap.test('BlockRegistry should have registered handlers', async () => {
// Test divider handler
const dividerHandler = BlockRegistry.getHandler('divider');
expect(dividerHandler).toBeDefined();
expect(dividerHandler?.type).toEqual('divider');
// Test paragraph handler
const paragraphHandler = BlockRegistry.getHandler('paragraph');
expect(paragraphHandler).toBeDefined();
expect(paragraphHandler?.type).toEqual('paragraph');
// Test heading handlers
const heading1Handler = BlockRegistry.getHandler('heading-1');
expect(heading1Handler).toBeDefined();
expect(heading1Handler?.type).toEqual('heading-1');
const heading2Handler = BlockRegistry.getHandler('heading-2');
expect(heading2Handler).toBeDefined();
expect(heading2Handler?.type).toEqual('heading-2');
const heading3Handler = BlockRegistry.getHandler('heading-3');
expect(heading3Handler).toBeDefined();
expect(heading3Handler?.type).toEqual('heading-3');
// Test that getAllTypes returns all registered types
const allTypes = BlockRegistry.getAllTypes();
expect(allTypes).toContain('divider');
expect(allTypes).toContain('paragraph');
expect(allTypes).toContain('heading-1');
expect(allTypes).toContain('heading-2');
expect(allTypes).toContain('heading-3');
});
tap.test('should render divider block using handler', async () => {
const dividerBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
dividerBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {}
};
// Set a divider block
dividerBlock.block = {
id: 'test-divider',
type: 'divider',
content: ' '
};
await dividerBlock.updateComplete;
// Check that the divider is rendered
const dividerElement = dividerBlock.shadowRoot?.querySelector('.block.divider');
expect(dividerElement).toBeDefined();
expect(dividerElement?.getAttribute('tabindex')).toEqual('0');
// Check for the divider icon
const icon = dividerBlock.shadowRoot?.querySelector('.divider-icon');
expect(icon).toBeDefined();
});
tap.test('should render paragraph block using handler', async () => {
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
paragraphBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
// Set a paragraph block
paragraphBlock.block = {
id: 'test-paragraph',
type: 'paragraph',
content: 'Test paragraph content'
};
await paragraphBlock.updateComplete;
// Check that the paragraph is rendered
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
expect(paragraphElement).toBeDefined();
expect(paragraphElement?.getAttribute('contenteditable')).toEqual('true');
expect(paragraphElement?.textContent).toEqual('Test paragraph content');
});
tap.test('should render heading blocks using handler', async () => {
// Test heading-1
const heading1Block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
heading1Block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
heading1Block.block = {
id: 'test-h1',
type: 'heading-1',
content: 'Heading 1 Test'
};
await heading1Block.updateComplete;
const h1Element = heading1Block.shadowRoot?.querySelector('.block.heading-1');
expect(h1Element).toBeDefined();
expect(h1Element?.textContent).toEqual('Heading 1 Test');
// Test heading-2
const heading2Block: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
heading2Block.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
heading2Block.block = {
id: 'test-h2',
type: 'heading-2',
content: 'Heading 2 Test'
};
await heading2Block.updateComplete;
const h2Element = heading2Block.shadowRoot?.querySelector('.block.heading-2');
expect(h2Element).toBeDefined();
expect(h2Element?.textContent).toEqual('Heading 2 Test');
});
tap.test('paragraph block handler methods should work', async () => {
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
);
// Set required handlers
paragraphBlock.handlers = {
onInput: () => {},
onKeyDown: () => {},
onFocus: () => {},
onBlur: () => {},
onCompositionStart: () => {},
onCompositionEnd: () => {},
onMouseUp: () => {}
};
paragraphBlock.block = {
id: 'test-methods',
type: 'paragraph',
content: 'Initial content'
};
await paragraphBlock.updateComplete;
// Test getContent
const content = paragraphBlock.getContent();
expect(content).toEqual('Initial content');
// Test setContent
paragraphBlock.setContent('Updated content');
await paragraphBlock.updateComplete;
expect(paragraphBlock.getContent()).toEqual('Updated content');
// Test that the DOM is updated
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
expect(paragraphElement?.textContent).toEqual('Updated content');
});
export default tap.start();

View File

@ -0,0 +1,98 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
tap.test('should change block type via context menu', async () => {
// Create WYSIWYG editor with a paragraph
const wysiwygEditor = new DeesInputWysiwyg();
wysiwygEditor.value = '<p>This is a test paragraph</p>';
document.body.appendChild(wysiwygEditor);
// Wait for editor to be ready
await wysiwygEditor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get the first block
const firstBlock = wysiwygEditor.blocks[0];
expect(firstBlock.type).toEqual('paragraph');
// Get the block element
const firstBlockWrapper = wysiwygEditor.shadowRoot!.querySelector('.block-wrapper');
expect(firstBlockWrapper).toBeTruthy();
const blockComponent = firstBlockWrapper!.querySelector('dees-wysiwyg-block') as any;
expect(blockComponent).toBeTruthy();
await blockComponent.updateComplete;
// Get the editable content inside the block's shadow DOM
const editableBlock = blockComponent.shadowRoot!.querySelector('.block');
expect(editableBlock).toBeTruthy();
// Simulate right-click on the editable block
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 200,
clientY: 200,
bubbles: true,
cancelable: true,
composed: true
});
editableBlock!.dispatchEvent(contextMenuEvent);
// Wait for context menu to appear
await new Promise(resolve => setTimeout(resolve, 100));
// Check if context menu is created
const contextMenu = document.querySelector('dees-contextmenu');
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
// Find "Change Type" menu item
const menuItems = Array.from(contextMenu!.shadowRoot!.querySelectorAll('.menuitem'));
const changeTypeItem = menuItems.find(item =>
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Change Type'
);
expect(changeTypeItem).toBeTruthy();
// Hover over "Change Type" to trigger submenu
changeTypeItem!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
// Wait for submenu to appear
await new Promise(resolve => setTimeout(resolve, 300));
// Check if submenu is created
const allMenus = document.querySelectorAll('dees-contextmenu');
expect(allMenus.length).toEqual(2);
const submenu = allMenus[1];
const submenuItems = Array.from(submenu.shadowRoot!.querySelectorAll('.menuitem'));
// Find "Heading 1" option
const heading1Item = submenuItems.find(item =>
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Heading 1'
);
expect(heading1Item).toBeTruthy();
// Click on "Heading 1"
(heading1Item as HTMLElement).click();
// Wait for menu to close and block to update
await new Promise(resolve => setTimeout(resolve, 300));
// Verify block type has changed
expect(wysiwygEditor.blocks[0].type).toEqual('heading-1');
// Verify DOM has been updated
const updatedBlockComponent = wysiwygEditor.shadowRoot!
.querySelector('.block-wrapper')!
.querySelector('dees-wysiwyg-block') as any;
await updatedBlockComponent.updateComplete;
const updatedBlock = updatedBlockComponent.shadowRoot!.querySelector('.block');
expect(updatedBlock?.classList.contains('heading-1')).toEqual(true);
// Clean up
wysiwygEditor.remove();
});
export default tap.start();

View File

@ -0,0 +1,68 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
tap.test('should show context menu on WYSIWYG blocks', async () => {
// Create WYSIWYG editor
const wysiwygEditor = new DeesInputWysiwyg();
wysiwygEditor.value = '<p>Test paragraph</p><h1>Test heading</h1>';
document.body.appendChild(wysiwygEditor);
// Wait for editor to be ready
await wysiwygEditor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get the first block element
const firstBlockWrapper = wysiwygEditor.shadowRoot!.querySelector('.block-wrapper');
expect(firstBlockWrapper).toBeTruthy();
const blockComponent = firstBlockWrapper!.querySelector('dees-wysiwyg-block') as any;
expect(blockComponent).toBeTruthy();
// Wait for block to be ready
await blockComponent.updateComplete;
// Get the editable content inside the block's shadow DOM
const editableBlock = blockComponent.shadowRoot!.querySelector('.block');
expect(editableBlock).toBeTruthy();
// Simulate right-click on the editable block
const contextMenuEvent = new MouseEvent('contextmenu', {
clientX: 200,
clientY: 200,
bubbles: true,
cancelable: true,
composed: true // Important for shadow DOM
});
editableBlock!.dispatchEvent(contextMenuEvent);
// Wait for context menu to appear
await new Promise(resolve => setTimeout(resolve, 100));
// Check if context menu is created
const contextMenu = document.querySelector('dees-contextmenu');
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
// Check if menu items from WYSIWYG block are rendered
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
const menuTexts = Array.from(menuItems).map(item =>
item.querySelector('.menuitem-text')?.textContent?.trim()
);
// Should have "Change Type" and "Delete Block" items
expect(menuTexts).toContain('Change Type');
expect(menuTexts).toContain('Delete Block');
// Check if "Change Type" has submenu indicator
const changeTypeItem = Array.from(menuItems).find(item =>
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Change Type'
);
expect(changeTypeItem?.classList.contains('has-submenu')).toEqual(true);
// Clean up
contextMenu!.remove();
wysiwygEditor.remove();
});
export default tap.start();

View File

@ -0,0 +1,95 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drag handler initialization', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
// Wait for element to be ready
await element.updateComplete;
// Check that drag handler is initialized
expect(element.dragDropHandler).toBeTruthy();
// Set initial content with multiple blocks
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
{ id: 'block2', type: 'paragraph', content: 'Second paragraph' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Check that editor content ref exists
console.log('editorContentRef:', element.editorContentRef);
expect(element.editorContentRef).toBeTruthy();
// Check that blocks are rendered
const blockWrappers = element.shadowRoot!.querySelectorAll('.block-wrapper');
console.log('Number of block wrappers:', blockWrappers.length);
expect(blockWrappers.length).toEqual(2);
// Check drag handles
const dragHandles = element.shadowRoot!.querySelectorAll('.drag-handle');
console.log('Number of drag handles:', dragHandles.length);
expect(dragHandles.length).toEqual(2);
// Clean up
document.body.removeChild(element);
});
tap.test('wysiwyg drag start behavior', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const dragHandle = element.shadowRoot!.querySelector('.drag-handle') as HTMLElement;
expect(dragHandle).toBeTruthy();
// Check that drag handle has draggable attribute
console.log('Drag handle draggable:', dragHandle.draggable);
expect(dragHandle.draggable).toBeTrue();
// Test drag handler state before drag
console.log('Initial drag state:', element.dragDropHandler.dragState);
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
// Try to manually call handleDragStart
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {
console.log('setData called with:', type, data);
},
setDragImage: (img: any, x: number, y: number) => {
console.log('setDragImage called');
}
},
clientY: 100,
preventDefault: () => {},
} as any;
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
// Check drag state after drag start
console.log('Drag state after start:', element.dragDropHandler.dragState);
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
tap.start();

View File

@ -0,0 +1,133 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drag visual feedback - block movement', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
// Manually start drag
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {},
setDragImage: (img: any, x: number, y: number) => {}
},
clientY: 50,
preventDefault: () => {},
} as any;
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
// Wait for dragging class
await new Promise(resolve => setTimeout(resolve, 20));
// Check dragging state
console.log('Block 1 classes:', block1.className);
console.log('Editor content classes:', editorContent.className);
expect(block1.classList.contains('dragging')).toBeTrue();
expect(editorContent.classList.contains('dragging')).toBeTrue();
// Check drop indicator exists
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
console.log('Drop indicator:', dropIndicator);
expect(dropIndicator).toBeTruthy();
// Test block movement calculation
console.log('Testing updateBlockPositions...');
// Access private method for testing
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
// Simulate dragging to different position
updateBlockPositions(150); // Move down
// Check if blocks have move classes
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
console.log('Block classes after move:');
blocks.forEach((block, i) => {
console.log(`Block ${i}:`, block.className, 'transform:', (block as HTMLElement).style.getPropertyValue('--drag-offset'));
});
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
tap.test('wysiwyg drop indicator positioning', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Paragraph 1' },
{ id: 'block2', type: 'heading-2', content: 'Heading 2' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
// Start dragging first block
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {},
setDragImage: (img: any, x: number, y: number) => {}
},
clientY: 50,
preventDefault: () => {},
} as any;
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
// Wait for initialization
await new Promise(resolve => setTimeout(resolve, 20));
// Get drop indicator
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
expect(dropIndicator).toBeTruthy();
// Check initial display state
console.log('Drop indicator initial display:', dropIndicator.style.display);
// Trigger updateBlockPositions to see drop indicator
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
updateBlockPositions(100);
// Check drop indicator position
console.log('Drop indicator after update:');
console.log('- display:', dropIndicator.style.display);
console.log('- top:', dropIndicator.style.top);
console.log('- height:', dropIndicator.style.height);
expect(dropIndicator.style.display).toEqual('block');
expect(dropIndicator.style.top).toBeTruthy();
expect(dropIndicator.style.height).toBeTruthy();
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
tap.start();

View File

@ -0,0 +1,172 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drag and drop should work correctly', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
// Wait for element to be ready
await element.updateComplete;
// Set initial content with multiple blocks
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
{ id: 'block2', type: 'heading-2', content: 'Test Heading' },
{ id: 'block3', type: 'paragraph', content: 'Second paragraph' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Check that blocks are rendered
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
expect(editorContent).toBeTruthy();
const blockWrappers = editorContent.querySelectorAll('.block-wrapper');
expect(blockWrappers.length).toEqual(3);
// Test drag handles exist for non-divider blocks
const dragHandles = editorContent.querySelectorAll('.drag-handle');
expect(dragHandles.length).toEqual(3);
// Get references to specific blocks
const firstBlock = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
const secondBlock = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
const firstDragHandle = firstBlock.querySelector('.drag-handle') as HTMLElement;
expect(firstBlock).toBeTruthy();
expect(secondBlock).toBeTruthy();
expect(firstDragHandle).toBeTruthy();
// Test drag initialization
console.log('Testing drag initialization...');
// Create drag event
const dragStartEvent = new DragEvent('dragstart', {
dataTransfer: new DataTransfer(),
clientY: 100,
bubbles: true
});
// Simulate drag start
firstDragHandle.dispatchEvent(dragStartEvent);
// Check that drag state is initialized
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Check that dragging class is applied
await new Promise(resolve => setTimeout(resolve, 20)); // Wait for setTimeout in drag start
expect(firstBlock.classList.contains('dragging')).toBeTrue();
expect(editorContent.classList.contains('dragging')).toBeTrue();
// Test drop indicator creation
const dropIndicator = editorContent.querySelector('.drop-indicator');
expect(dropIndicator).toBeTruthy();
// Simulate drag over
const dragOverEvent = new DragEvent('dragover', {
dataTransfer: new DataTransfer(),
clientY: 200,
bubbles: true,
cancelable: true
});
document.dispatchEvent(dragOverEvent);
// Check that blocks move out of the way
console.log('Checking block movements...');
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
const hasMovedBlocks = blocks.some(block =>
block.classList.contains('move-up') || block.classList.contains('move-down')
);
console.log('Blocks with move classes:', blocks.filter(block =>
block.classList.contains('move-up') || block.classList.contains('move-down')
).length);
// Test drag end
const dragEndEvent = new DragEvent('dragend', {
bubbles: true
});
document.dispatchEvent(dragEndEvent);
// Wait for cleanup
await new Promise(resolve => setTimeout(resolve, 150));
// Check that drag state is cleaned up
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
expect(firstBlock.classList.contains('dragging')).toBeFalse();
expect(editorContent.classList.contains('dragging')).toBeFalse();
// Check that drop indicator is removed
const dropIndicatorAfter = editorContent.querySelector('.drop-indicator');
expect(dropIndicatorAfter).toBeFalsy();
// Clean up
document.body.removeChild(element);
});
tap.test('wysiwyg drag and drop visual feedback', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
const dragHandle1 = block1.querySelector('.drag-handle') as HTMLElement;
// Start dragging block 1
const dragStartEvent = new DragEvent('dragstart', {
dataTransfer: new DataTransfer(),
clientY: 50,
bubbles: true
});
dragHandle1.dispatchEvent(dragStartEvent);
// Wait for dragging class
await new Promise(resolve => setTimeout(resolve, 20));
// Simulate dragging down
const dragOverEvent = new DragEvent('dragover', {
dataTransfer: new DataTransfer(),
clientY: 150, // Move down past block 2
bubbles: true,
cancelable: true
});
// Trigger the global drag over handler
element.dragDropHandler['handleGlobalDragOver'](dragOverEvent);
// Check that transform is applied to dragged block
const transform = block1.style.transform;
console.log('Dragged block transform:', transform);
expect(transform).toContain('translateY');
// Check drop indicator position
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
if (dropIndicator) {
const indicatorStyle = dropIndicator.style;
console.log('Drop indicator position:', indicatorStyle.top, 'display:', indicatorStyle.display);
}
// Clean up
document.body.removeChild(element);
});
tap.start();

View File

@ -0,0 +1,124 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drag full flow without await', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Mock drag event
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {
console.log('setData:', type, data);
},
setDragImage: (img: any, x: number, y: number) => {
console.log('setDragImage');
}
},
clientY: 100,
preventDefault: () => {},
} as any;
console.log('Starting drag...');
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
console.log('Drag started');
// Check immediate state
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Instead of await with setTimeout, use a done callback
return new Promise<void>((resolve) => {
console.log('Setting up delayed check...');
// Use regular setTimeout
setTimeout(() => {
console.log('In setTimeout callback');
try {
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
console.log('Block has dragging class:', block1?.classList.contains('dragging'));
console.log('Editor has dragging class:', editorContent?.classList.contains('dragging'));
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
resolve();
} catch (error) {
console.error('Error in setTimeout:', error);
throw error;
}
}, 50);
});
});
tap.test('identify the crash point', async () => {
console.log('Test started');
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
console.log('Element created');
await element.updateComplete;
console.log('Setting blocks');
element.blocks = [{ id: 'block1', type: 'paragraph', content: 'Test' }];
element.renderBlocksProgrammatically();
console.log('Waiting for update');
await element.updateComplete;
console.log('Creating mock event');
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: () => {},
setDragImage: () => {}
},
clientY: 100,
preventDefault: () => {},
} as any;
console.log('Calling handleDragStart');
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
console.log('handleDragStart completed');
// Try different wait methods
console.log('About to wait...');
// Method 1: Direct promise
await Promise.resolve();
console.log('Promise.resolve completed');
// Method 2: setTimeout 0
await new Promise(resolve => setTimeout(resolve, 0));
console.log('setTimeout 0 completed');
// Method 3: requestAnimationFrame
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)));
console.log('requestAnimationFrame completed');
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
console.log('Cleanup completed');
});
tap.start();

View File

@ -0,0 +1,108 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg drop indicator creation', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Check editorContentRef
console.log('editorContentRef exists:', !!element.editorContentRef);
console.log('editorContentRef tagName:', element.editorContentRef?.tagName);
expect(element.editorContentRef).toBeTruthy();
// Check initial state - no drop indicator
let dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
console.log('Drop indicator before drag:', dropIndicator);
expect(dropIndicator).toBeFalsy();
// Manually call createDropIndicator
try {
console.log('Calling createDropIndicator...');
element.dragDropHandler['createDropIndicator']();
console.log('createDropIndicator succeeded');
} catch (error) {
console.error('Error creating drop indicator:', error);
throw error;
}
// Check drop indicator was created
dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
console.log('Drop indicator after creation:', dropIndicator);
console.log('Drop indicator parent:', dropIndicator?.parentElement?.className);
expect(dropIndicator).toBeTruthy();
expect(dropIndicator!.style.display).toEqual('none');
// Clean up
document.body.removeChild(element);
});
tap.test('wysiwyg drag initialization with drop indicator', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
// Mock drag event
const mockDragEvent = {
dataTransfer: {
effectAllowed: '',
setData: (type: string, data: string) => {
console.log('setData:', type, data);
},
setDragImage: (img: any, x: number, y: number) => {
console.log('setDragImage');
}
},
clientY: 100,
preventDefault: () => {},
} as any;
console.log('Starting drag...');
try {
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
console.log('Drag start succeeded');
} catch (error) {
console.error('Error during drag start:', error);
throw error;
}
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 20));
// Check drop indicator exists
const dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
console.log('Drop indicator after drag start:', dropIndicator);
expect(dropIndicator).toBeTruthy();
// Check drag state
console.log('Drag state:', element.dragDropHandler.dragState);
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
// Clean up
element.dragDropHandler.handleDragEnd();
document.body.removeChild(element);
});
tap.start();

View File

@ -0,0 +1,114 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
// Initialize the element
DeesInputWysiwyg;
tap.test('wysiwyg global event listeners', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
console.log('Block 1 found:', !!block1);
// Set up drag state manually without using handleDragStart
element.dragDropHandler['draggedBlockId'] = 'block1';
element.dragDropHandler['draggedBlockElement'] = block1;
element.dragDropHandler['initialMouseY'] = 100;
// Create drop indicator manually
element.dragDropHandler['createDropIndicator']();
// Test adding global event listeners
console.log('Adding event listeners...');
const handleGlobalDragOver = element.dragDropHandler['handleGlobalDragOver'];
const handleGlobalDragEnd = element.dragDropHandler['handleGlobalDragEnd'];
try {
document.addEventListener('dragover', handleGlobalDragOver);
console.log('dragover listener added');
document.addEventListener('dragend', handleGlobalDragEnd);
console.log('dragend listener added');
} catch (error) {
console.error('Error adding event listeners:', error);
throw error;
}
// Test firing a dragover event
console.log('Creating dragover event...');
const dragOverEvent = new Event('dragover', {
bubbles: true,
cancelable: true
});
Object.defineProperty(dragOverEvent, 'clientY', { value: 150 });
console.log('Dispatching dragover event...');
document.dispatchEvent(dragOverEvent);
console.log('dragover event dispatched');
// Clean up
document.removeEventListener('dragover', handleGlobalDragOver);
document.removeEventListener('dragend', handleGlobalDragEnd);
document.body.removeChild(element);
});
tap.test('wysiwyg setTimeout in drag start', async () => {
const element = document.createElement('dees-input-wysiwyg');
document.body.appendChild(element);
await element.updateComplete;
// Set initial content
element.blocks = [
{ id: 'block1', type: 'paragraph', content: 'Test block' },
];
element.renderBlocksProgrammatically();
await element.updateComplete;
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
// Set drag state
element.dragDropHandler['draggedBlockId'] = 'block1';
element.dragDropHandler['draggedBlockElement'] = block1;
console.log('Testing setTimeout callback...');
// Test the setTimeout callback directly
try {
if (block1) {
console.log('Adding dragging class to block...');
block1.classList.add('dragging');
console.log('Block classes:', block1.className);
}
if (editorContent) {
console.log('Adding dragging class to editor...');
editorContent.classList.add('dragging');
console.log('Editor classes:', editorContent.className);
}
} catch (error) {
console.error('Error in setTimeout callback:', error);
throw error;
}
expect(block1.classList.contains('dragging')).toBeTrue();
expect(editorContent.classList.contains('dragging')).toBeTrue();
// Clean up
document.body.removeChild(element);
});
tap.start();

View File

@ -0,0 +1,341 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('Keyboard: Arrow navigation between blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import multiple blocks
editor.importBlocks([
{ id: 'block-1', type: 'paragraph', content: 'First paragraph' },
{ id: 'block-2', type: 'paragraph', content: 'Second paragraph' },
{ id: 'block-3', type: 'paragraph', content: 'Third paragraph' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus first block at end
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const firstBlockContainer = firstBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const firstParagraph = firstBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus and set cursor at end of first block
firstParagraph.focus();
const textNode = firstParagraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, textNode.textContent?.length || 0);
range.setEnd(textNode, textNode.textContent?.length || 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press ArrowRight to move to second block
const arrowRightEvent = new KeyboardEvent('keydown', {
key: 'ArrowRight',
code: 'ArrowRight',
bubbles: true,
cancelable: true,
composed: true
});
firstParagraph.dispatchEvent(arrowRightEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if second block is focused
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Check if the second paragraph has focus
const activeElement = secondBlockComponent.shadowRoot?.activeElement;
expect(activeElement).toEqual(secondParagraph);
console.log('Arrow navigation test complete');
});
tap.test('Keyboard: Backspace merges blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'merge-1', type: 'paragraph', content: 'First' },
{ id: 'merge-2', type: 'paragraph', content: 'Second' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus second block at beginning
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="merge-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus and set cursor at beginning
secondParagraph.focus();
const textNode = secondParagraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Backspace to merge with previous block
const backspaceEvent = new KeyboardEvent('keydown', {
key: 'Backspace',
code: 'Backspace',
bubbles: true,
cancelable: true,
composed: true
});
secondParagraph.dispatchEvent(backspaceEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if blocks were merged
expect(editor.blocks.length).toEqual(1);
expect(editor.blocks[0].content).toContain('First');
expect(editor.blocks[0].content).toContain('Second');
console.log('Backspace merge test complete');
});
tap.test('Keyboard: Delete key on non-editable blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import blocks including a divider
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'Before divider' },
{ id: 'div-1', type: 'divider', content: '' },
{ id: 'para-2', type: 'paragraph', content: 'After divider' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus the divider block
const dividerBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="div-1"]');
const dividerBlockComponent = dividerBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const dividerBlockContainer = dividerBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const dividerElement = dividerBlockContainer?.querySelector('.block.divider') as HTMLElement;
// Non-editable blocks need to be focused differently
dividerElement?.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Press Delete to remove the divider
const deleteEvent = new KeyboardEvent('keydown', {
key: 'Delete',
code: 'Delete',
bubbles: true,
cancelable: true,
composed: true
});
dividerElement.dispatchEvent(deleteEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if divider was removed
expect(editor.blocks.length).toEqual(2);
expect(editor.blocks.find(b => b.type === 'divider')).toBeUndefined();
console.log('Delete key on non-editable block test complete');
});
tap.test('Keyboard: Tab key in code block', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a code block
editor.importBlocks([
{ id: 'code-1', type: 'code', content: 'function test() {', metadata: { language: 'javascript' } }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus code block
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeBlockContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeBlockContainer?.querySelector('.block.code') as HTMLElement;
// Focus and set cursor at end
codeElement.focus();
const textNode = codeElement.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, textNode.textContent?.length || 0);
range.setEnd(textNode, textNode.textContent?.length || 0);
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Tab to insert spaces
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
bubbles: true,
cancelable: true,
composed: true
});
codeElement.dispatchEvent(tabEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if spaces were inserted
const updatedContent = codeElement.textContent || '';
expect(updatedContent).toContain(' '); // Tab should insert 2 spaces
console.log('Tab in code block test complete');
});
tap.test('Keyboard: ArrowUp/Down navigation', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import multiple blocks
editor.importBlocks([
{ id: 'nav-1', type: 'paragraph', content: 'First line' },
{ id: 'nav-2', type: 'paragraph', content: 'Second line' },
{ id: 'nav-3', type: 'paragraph', content: 'Third line' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus second block
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-2"]');
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
secondParagraph.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Press ArrowUp to move to first block
const arrowUpEvent = new KeyboardEvent('keydown', {
key: 'ArrowUp',
code: 'ArrowUp',
bubbles: true,
cancelable: true,
composed: true
});
secondParagraph.dispatchEvent(arrowUpEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if first block is focused
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-1"]');
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const firstParagraph = firstBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
expect(firstBlockComponent.shadowRoot?.activeElement).toEqual(firstParagraph);
// Now press ArrowDown twice to get to third block
const arrowDownEvent = new KeyboardEvent('keydown', {
key: 'ArrowDown',
code: 'ArrowDown',
bubbles: true,
cancelable: true,
composed: true
});
firstParagraph.dispatchEvent(arrowDownEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Second block should be focused, dispatch again
const secondActiveElement = secondBlockComponent.shadowRoot?.activeElement;
if (secondActiveElement) {
secondActiveElement.dispatchEvent(arrowDownEvent);
await new Promise(resolve => setTimeout(resolve, 200));
}
// Check if third block is focused
const thirdBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-3"]');
const thirdBlockComponent = thirdBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const thirdParagraph = thirdBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
expect(thirdBlockComponent.shadowRoot?.activeElement).toEqual(thirdParagraph);
console.log('ArrowUp/Down navigation test complete');
});
tap.test('Keyboard: Formatting shortcuts', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a paragraph
editor.importBlocks([
{ id: 'format-1', type: 'paragraph', content: 'Test formatting' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Focus and select text
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="format-1"]');
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const blockContainer = blockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paragraph = blockContainer?.querySelector('.block.paragraph') as HTMLElement;
paragraph.focus();
// Select "formatting"
const textNode = paragraph.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 5); // After "Test "
range.setEnd(textNode, 15); // After "formatting"
selection?.removeAllRanges();
selection?.addRange(range);
}
await new Promise(resolve => setTimeout(resolve, 100));
// Press Cmd/Ctrl+B for bold
const boldEvent = new KeyboardEvent('keydown', {
key: 'b',
code: 'KeyB',
metaKey: true, // Use metaKey for Mac, ctrlKey for Windows/Linux
bubbles: true,
cancelable: true,
composed: true
});
paragraph.dispatchEvent(boldEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if bold was applied
const content = paragraph.innerHTML;
expect(content).toContain('<strong>') || expect(content).toContain('<b>');
console.log('Formatting shortcuts test complete');
});
export default tap.start();

View File

@ -0,0 +1,150 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('Phase 3: Quote block should render and work correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a quote block
editor.importBlocks([
{ id: 'quote-1', type: 'quote', content: 'This is a famous quote' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if quote block was rendered
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(quoteBlockComponent).toBeTruthy();
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
expect(quoteElement).toBeTruthy();
expect(quoteElement?.textContent).toEqual('This is a famous quote');
// Check if styles are applied (border-left for quote)
const computedStyle = window.getComputedStyle(quoteElement);
expect(computedStyle.borderLeftStyle).toEqual('solid');
expect(computedStyle.fontStyle).toEqual('italic');
});
tap.test('Phase 3: Code block should render and handle tab correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a code block
editor.importBlocks([
{ id: 'code-1', type: 'code', content: 'const x = 42;', metadata: { language: 'javascript' } }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if code block was rendered
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
expect(codeElement).toBeTruthy();
expect(codeElement?.textContent).toEqual('const x = 42;');
// Check if language label is shown
const languageLabel = codeContainer?.querySelector('.code-language');
expect(languageLabel?.textContent).toEqual('javascript');
// Check if monospace font is applied
const computedStyle = window.getComputedStyle(codeElement);
expect(computedStyle.fontFamily).toContain('monospace');
});
tap.test('Phase 3: List block should render correctly', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a list block
editor.importBlocks([
{ id: 'list-1', type: 'list', content: 'First item\nSecond item\nThird item' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check if list block was rendered
const listBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="list-1"]');
const listBlockComponent = listBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const listContainer = listBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const listElement = listContainer?.querySelector('.block.list') as HTMLElement;
expect(listElement).toBeTruthy();
// Check if list items were created
const listItems = listElement?.querySelectorAll('li');
expect(listItems?.length).toEqual(3);
expect(listItems?.[0].textContent).toEqual('First item');
expect(listItems?.[1].textContent).toEqual('Second item');
expect(listItems?.[2].textContent).toEqual('Third item');
// Check if it's an unordered list by default
const ulElement = listElement?.querySelector('ul');
expect(ulElement).toBeTruthy();
});
tap.test('Phase 3: Quote block split should work', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a quote block
editor.importBlocks([
{ id: 'quote-split', type: 'quote', content: 'To be or not to be' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get the quote block
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-split"]');
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
// Focus and set cursor after "To be"
quoteElement.focus();
const textNode = quoteElement.firstChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const selection = window.getSelection();
range.setStart(textNode, 5); // After "To be"
range.setEnd(textNode, 5);
selection?.removeAllRanges();
selection?.addRange(range);
await new Promise(resolve => setTimeout(resolve, 100));
// Press Enter to split
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true
});
quoteElement.dispatchEvent(enterEvent);
await new Promise(resolve => setTimeout(resolve, 200));
// Check if split happened correctly
expect(editor.blocks.length).toEqual(2);
expect(editor.blocks[0].content).toEqual('To be');
expect(editor.blocks[1].content).toEqual(' or not to be');
expect(editor.blocks[1].type).toEqual('paragraph'); // New block should be paragraph
}
});
export default tap.start();

View File

@ -0,0 +1,105 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
import { DividerBlockHandler } from '../ts_web/elements/wysiwyg/blocks/content/divider.block.js';
import { ParagraphBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/paragraph.block.js';
import { HeadingBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/heading.block.js';
// Import block registration to ensure handlers are registered
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
tap.test('BlockRegistry should register and retrieve handlers', async () => {
// Test divider handler
const dividerHandler = BlockRegistry.getHandler('divider');
expect(dividerHandler).toBeDefined();
expect(dividerHandler).toBeInstanceOf(DividerBlockHandler);
expect(dividerHandler?.type).toEqual('divider');
// Test paragraph handler
const paragraphHandler = BlockRegistry.getHandler('paragraph');
expect(paragraphHandler).toBeDefined();
expect(paragraphHandler).toBeInstanceOf(ParagraphBlockHandler);
expect(paragraphHandler?.type).toEqual('paragraph');
// Test heading handlers
const heading1Handler = BlockRegistry.getHandler('heading-1');
expect(heading1Handler).toBeDefined();
expect(heading1Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading1Handler?.type).toEqual('heading-1');
const heading2Handler = BlockRegistry.getHandler('heading-2');
expect(heading2Handler).toBeDefined();
expect(heading2Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading2Handler?.type).toEqual('heading-2');
const heading3Handler = BlockRegistry.getHandler('heading-3');
expect(heading3Handler).toBeDefined();
expect(heading3Handler).toBeInstanceOf(HeadingBlockHandler);
expect(heading3Handler?.type).toEqual('heading-3');
});
tap.test('Block handlers should render content correctly', async () => {
const testBlock = {
id: 'test-1',
type: 'paragraph' as const,
content: 'Test paragraph content'
};
const handler = BlockRegistry.getHandler('paragraph');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(testBlock, false);
expect(rendered).toContain('contenteditable="true"');
expect(rendered).toContain('data-block-type="paragraph"');
expect(rendered).toContain('Test paragraph content');
}
});
tap.test('Divider handler should render correctly', async () => {
const dividerBlock = {
id: 'test-divider',
type: 'divider' as const,
content: ' '
};
const handler = BlockRegistry.getHandler('divider');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(dividerBlock, false);
expect(rendered).toContain('class="block divider"');
expect(rendered).toContain('tabindex="0"');
expect(rendered).toContain('divider-icon');
}
});
tap.test('Heading handlers should render with correct levels', async () => {
const headingBlock = {
id: 'test-h1',
type: 'heading-1' as const,
content: 'Test Heading'
};
const handler = BlockRegistry.getHandler('heading-1');
expect(handler).toBeDefined();
if (handler) {
const rendered = handler.render(headingBlock, false);
expect(rendered).toContain('class="block heading-1"');
expect(rendered).toContain('contenteditable="true"');
expect(rendered).toContain('Test Heading');
}
});
tap.test('getAllTypes should return all registered types', async () => {
const allTypes = BlockRegistry.getAllTypes();
expect(allTypes).toContain('divider');
expect(allTypes).toContain('paragraph');
expect(allTypes).toContain('heading-1');
expect(allTypes).toContain('heading-2');
expect(allTypes).toContain('heading-3');
expect(allTypes.length).toBeGreaterThanOrEqual(5);
});
export default tap.start();

View File

@ -0,0 +1,156 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('Selection highlighting should work consistently for all block types', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import various block types
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'This is a paragraph' },
{ id: 'heading-1', type: 'heading-1', content: 'This is a heading' },
{ id: 'quote-1', type: 'quote', content: 'This is a quote' },
{ id: 'code-1', type: 'code', content: 'const x = 42;' },
{ id: 'list-1', type: 'list', content: 'Item 1\nItem 2' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Test paragraph highlighting
console.log('Testing paragraph highlighting...');
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const paraContainer = paraComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const paraElement = paraContainer?.querySelector('.block.paragraph') as HTMLElement;
// Focus paragraph to select it
paraElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if paragraph has selected class
const paraHasSelected = paraElement.classList.contains('selected');
console.log('Paragraph has selected class:', paraHasSelected);
// Check computed styles
const paraStyle = window.getComputedStyle(paraElement);
console.log('Paragraph background:', paraStyle.background);
console.log('Paragraph box-shadow:', paraStyle.boxShadow);
// Test heading highlighting
console.log('\nTesting heading highlighting...');
const headingWrapper = editor.shadowRoot?.querySelector('[data-block-id="heading-1"]');
const headingComponent = headingWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const headingContainer = headingComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const headingElement = headingContainer?.querySelector('.block.heading-1') as HTMLElement;
// Focus heading to select it
headingElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if heading has selected class
const headingHasSelected = headingElement.classList.contains('selected');
console.log('Heading has selected class:', headingHasSelected);
// Check computed styles
const headingStyle = window.getComputedStyle(headingElement);
console.log('Heading background:', headingStyle.background);
console.log('Heading box-shadow:', headingStyle.boxShadow);
// Test quote highlighting
console.log('\nTesting quote highlighting...');
const quoteWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
const quoteComponent = quoteWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const quoteContainer = quoteComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
// Focus quote to select it
quoteElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if quote has selected class
const quoteHasSelected = quoteElement.classList.contains('selected');
console.log('Quote has selected class:', quoteHasSelected);
// Test code highlighting
console.log('\nTesting code highlighting...');
const codeWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
const codeComponent = codeWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const codeContainer = codeComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
// Focus code to select it
codeElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check if code has selected class
const codeHasSelected = codeElement.classList.contains('selected');
console.log('Code has selected class:', codeHasSelected);
// Focus back on paragraph and check if others are deselected
console.log('\nFocusing back on paragraph...');
paraElement.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check that only paragraph is selected
expect(paraElement.classList.contains('selected')).toBeTrue();
expect(headingElement.classList.contains('selected')).toBeFalse();
expect(quoteElement.classList.contains('selected')).toBeFalse();
expect(codeElement.classList.contains('selected')).toBeFalse();
console.log('Selection highlighting test complete');
});
tap.test('Selected class should toggle correctly when clicking between blocks', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'block-1', type: 'paragraph', content: 'First block' },
{ id: 'block-2', type: 'paragraph', content: 'Second block' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Get both blocks
const block1Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
const block1Component = block1Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const block1Container = block1Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const block1Element = block1Container?.querySelector('.block.paragraph') as HTMLElement;
const block2Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
const block2Component = block2Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const block2Container = block2Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const block2Element = block2Container?.querySelector('.block.paragraph') as HTMLElement;
// Initially neither should be selected
expect(block1Element.classList.contains('selected')).toBeFalse();
expect(block2Element.classList.contains('selected')).toBeFalse();
// Click on first block
block1Element.click();
block1Element.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// First block should be selected
expect(block1Element.classList.contains('selected')).toBeTrue();
expect(block2Element.classList.contains('selected')).toBeFalse();
// Click on second block
block2Element.click();
block2Element.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Second block should be selected, first should not
expect(block1Element.classList.contains('selected')).toBeFalse();
expect(block2Element.classList.contains('selected')).toBeTrue();
console.log('Toggle test complete');
});
export default tap.start();

View File

@ -0,0 +1,62 @@
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('Selection highlighting basic test', async () => {
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import two blocks
editor.importBlocks([
{ id: 'para-1', type: 'paragraph', content: 'First paragraph' },
{ id: 'head-1', type: 'heading-1', content: 'First heading' }
]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 500));
// Get paragraph element
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const paraBlock = paraComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
// Get heading element
const headWrapper = editor.shadowRoot?.querySelector('[data-block-id="head-1"]');
const headComponent = headWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const headBlock = headComponent?.shadowRoot?.querySelector('.block.heading-1') as HTMLElement;
console.log('Found elements:', {
paraBlock: !!paraBlock,
headBlock: !!headBlock
});
// Focus paragraph
paraBlock.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check classes
console.log('Paragraph classes:', paraBlock.className);
console.log('Heading classes:', headBlock.className);
// Check isSelected property
console.log('Paragraph component isSelected:', paraComponent.isSelected);
console.log('Heading component isSelected:', headComponent.isSelected);
// Focus heading
headBlock.focus();
await new Promise(resolve => setTimeout(resolve, 100));
// Check classes again
console.log('\nAfter focusing heading:');
console.log('Paragraph classes:', paraBlock.className);
console.log('Heading classes:', headBlock.className);
console.log('Paragraph component isSelected:', paraComponent.isSelected);
console.log('Heading component isSelected:', headComponent.isSelected);
// Check that heading is selected
expect(headBlock.classList.contains('selected')).toBeTrue();
expect(paraBlock.classList.contains('selected')).toBeFalse();
});
export default tap.start();

View File

@ -0,0 +1,98 @@
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
tap.test('should split paragraph content on Enter key', async () => {
// Create the wysiwyg editor
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a test paragraph
editor.importBlocks([{
id: 'test-para-1',
type: 'paragraph',
content: 'Hello World'
}]);
await editor.updateComplete;
// Wait for blocks to render
await new Promise(resolve => setTimeout(resolve, 100));
// Get the block wrapper and component
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-para-1"]');
expect(blockWrapper).toBeDefined();
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
expect(blockComponent).toBeDefined();
expect(blockComponent.block.type).toEqual('paragraph');
// Wait for block to render
await blockComponent.updateComplete;
// Test getSplitContent
console.log('Testing getSplitContent...');
const splitResult = blockComponent.getSplitContent();
console.log('Split result:', splitResult);
// Since we haven't set cursor position, it might return null or split at start
// This is just to test if the method is callable
expect(typeof blockComponent.getSplitContent).toEqual('function');
});
tap.test('should handle Enter key press in paragraph', async () => {
// Create the wysiwyg editor
const editor: DeesInputWysiwyg = await webhelpers.fixture(
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
);
// Import a test paragraph
editor.importBlocks([{
id: 'test-enter-1',
type: 'paragraph',
content: 'First part|Second part' // | marks where we'll simulate cursor
}]);
await editor.updateComplete;
await new Promise(resolve => setTimeout(resolve, 100));
// Check initial state
expect(editor.blocks.length).toEqual(1);
expect(editor.blocks[0].content).toEqual('First part|Second part');
// Get the block element
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-enter-1"]');
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
const blockElement = blockComponent.shadowRoot?.querySelector('.block.paragraph') as HTMLDivElement;
expect(blockElement).toBeDefined();
// Set content without the | marker
blockElement.textContent = 'First partSecond part';
// Focus the block
blockElement.focus();
// Create and dispatch Enter key event
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true
});
// Dispatch the event
blockElement.dispatchEvent(enterEvent);
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 200));
// Check if block was split (this might not work perfectly in test environment)
console.log('Blocks after Enter:', editor.blocks.length);
console.log('Block contents:', editor.blocks.map(b => b.content));
});
export default tap.start();

View File

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

View File

@ -0,0 +1,53 @@
import { unsafeCSS } from '@design.estate/dees-element';
/**
* Geist Sans font family - Main font for the design system
* Already available in the environment, no need to load
*/
export const geistSansFont = 'Geist Sans';
/**
* Intel One Mono font family - Monospace font for code and technical content
* Already available in the environment, no need to load
*/
export const intelOneMonoFont = 'Intel One Mono';
/**
* Complete font family stacks with fallbacks
*/
export const geistFontFamily = `'${geistSansFont}', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif`;
export const monoFontFamily = `'${intelOneMonoFont}', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace`;
/**
* CSS-ready font family values using unsafeCSS
* Use these in component styles
*/
export const cssGeistFontFamily = unsafeCSS(geistFontFamily);
export const cssMonoFontFamily = unsafeCSS(monoFontFamily);
/**
* Cal Sans font for headings - Display font
* May need to be loaded separately
*/
export const calSansFont = 'Cal Sans';
export const calSansFontFamily = `'${calSansFont}', ${geistFontFamily}`;
export const cssCalSansFontFamily = unsafeCSS(calSansFontFamily);
/**
* Roboto Slab font for special content - Serif font
* May need to be loaded separately
*/
export const robotoSlabFont = 'Roboto Slab';
export const robotoSlabFontFamily = `'${robotoSlabFont}', Georgia, serif`;
export const cssRobotoSlabFontFamily = unsafeCSS(robotoSlabFontFamily);
/**
* Base font styles that can be applied to components
*/
export const baseFontStyles = unsafeCSS(`
font-family: ${geistFontFamily};
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'cv11', 'tnum', 'cv05' 1;
`);

161
ts_web/elements/00zindex.ts Normal file
View File

@ -0,0 +1,161 @@
/**
* Central z-index management for consistent stacking order
* Higher numbers appear on top of lower numbers
*/
export const zIndexLayers = {
// Base layer: Regular content
base: {
content: 'auto',
inputElements: 1,
},
// Fixed UI elements
fixed: {
appBar: 10,
sideMenu: 10,
mobileNav: 250,
},
// Overlay backdrops (semi-transparent backgrounds)
backdrop: {
dropdown: 1999, // Below modals but above fixed elements
modal: 2999, // Below dropdowns on modals
contextMenu: 3999, // Below critical overlays
},
// Interactive overlays
overlay: {
dropdown: 2000, // Dropdowns and select menus
modal: 3000, // Modal dialogs
contextMenu: 4000, // Context menus and tooltips
toast: 5000, // Toast notifications (highest priority)
},
// Special cases for nested elements
modalDropdown: 3500, // Dropdowns inside modals
wysiwygMenus: 4500, // Editor formatting menus
} as const;
// Helper function to get z-index value
export function getZIndex(category: keyof typeof zIndexLayers, subcategory?: string): number | string {
const categoryObj = zIndexLayers[category];
if (typeof categoryObj === 'object' && subcategory) {
return categoryObj[subcategory as keyof typeof categoryObj] || 'auto';
}
return typeof categoryObj === 'number' ? categoryObj : 'auto';
}
// Z-index assignments for components
export const componentZIndex = {
'dees-modal': zIndexLayers.overlay.modal,
'dees-windowlayer': zIndexLayers.overlay.dropdown,
'dees-contextmenu': zIndexLayers.overlay.contextMenu,
'dees-toast': zIndexLayers.overlay.toast,
'dees-appui-mainmenu': zIndexLayers.fixed.appBar,
'dees-mobilenavigation': zIndexLayers.fixed.mobileNav,
'dees-slash-menu': zIndexLayers.wysiwygMenus,
'dees-formatting-menu': zIndexLayers.wysiwygMenus,
} as const;
/**
* Z-Index Registry for managing stacked elements
* Simple incremental z-index assignment based on creation order
*/
export class ZIndexRegistry {
private static instance: ZIndexRegistry;
private activeElements = new Set<HTMLElement>();
private elementZIndexMap = new WeakMap<HTMLElement, number>();
private currentZIndex = 1000; // Starting z-index
private constructor() {}
public static getInstance(): ZIndexRegistry {
if (!ZIndexRegistry.instance) {
ZIndexRegistry.instance = new ZIndexRegistry();
}
return ZIndexRegistry.instance;
}
/**
* Get the next available z-index
* @returns The next available z-index
*/
public getNextZIndex(): number {
this.currentZIndex += 10;
return this.currentZIndex;
}
/**
* Register an element with the z-index registry
* @param element - The HTML element to register
* @param zIndex - The z-index assigned to this element
*/
public register(element: HTMLElement, zIndex: number): void {
this.activeElements.add(element);
this.elementZIndexMap.set(element, zIndex);
}
/**
* Unregister an element from the z-index registry
* @param element - The HTML element to unregister
*/
public unregister(element: HTMLElement): void {
this.activeElements.delete(element);
this.elementZIndexMap.delete(element);
// If no more active elements, reset counter to base
if (this.activeElements.size === 0) {
this.currentZIndex = 1000;
}
}
/**
* Get the z-index for a specific element
* @param element - The HTML element
* @returns The z-index or undefined if not registered
*/
public getElementZIndex(element: HTMLElement): number | undefined {
return this.elementZIndexMap.get(element);
}
/**
* Get count of active elements
* @returns Number of active elements
*/
public getActiveCount(): number {
return this.activeElements.size;
}
/**
* Get the current highest z-index
* @returns The current z-index value
*/
public getCurrentZIndex(): number {
return this.currentZIndex;
}
/**
* Clear all registrations (useful for testing)
*/
public clear(): void {
this.activeElements.clear();
this.elementZIndexMap = new WeakMap();
this.currentZIndex = 1000;
}
/**
* Get all active elements in z-index order
* @returns Array of elements sorted by z-index
*/
public getActiveElementsInOrder(): HTMLElement[] {
return Array.from(this.activeElements).sort((a, b) => {
const aZ = this.elementZIndexMap.get(a) || 0;
const bZ = this.elementZIndexMap.get(b) || 0;
return aZ - bZ;
});
}
}
// Export singleton instance for convenience
export const zIndexRegistry = ZIndexRegistry.getInstance();

View File

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

View File

@ -0,0 +1,211 @@
import { html, css } from '@design.estate/dees-element';
import type { DeesAppuiBar } from './dees-appui-appbar.js';
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
// Sample menu items with various configurations
// Note: Following standard desktop UI patterns, top-level menu items don't have icons
// Icons are only used in dropdown menu items for better visual hierarchy
const menuItems: IAppBarMenuItem[] = [
{
name: 'File',
action: async () => {}, // No-op action for menu with submenu
submenu: [
{ name: 'New File', shortcut: 'Cmd+N', iconName: 'file-plus', action: async () => console.log('New file') },
{ name: 'Open...', shortcut: 'Cmd+O', iconName: 'folder-open', action: async () => console.log('Open') },
{ name: 'Open Recent', action: async () => {}, submenu: [
{ name: 'project-alpha.ts', action: async () => console.log('Open recent 1') },
{ name: 'config.json', action: async () => console.log('Open recent 2') },
{ name: 'readme.md', action: async () => console.log('Open recent 3') },
]},
{ divider: true },
{ name: 'Save', shortcut: 'Cmd+S', iconName: 'save', action: async () => console.log('Save') },
{ name: 'Save As...', shortcut: 'Cmd+Shift+S', action: async () => console.log('Save as'), disabled: true },
{ divider: true },
{ name: 'Exit', shortcut: 'Cmd+Q', action: async () => console.log('Exit') },
]
},
{
name: 'Edit',
action: async () => {}, // No-op action for menu with submenu
submenu: [
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => console.log('Undo') },
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => console.log('Redo') },
{ divider: true },
{ name: 'Cut', shortcut: 'Cmd+X', iconName: 'scissors', action: async () => console.log('Cut') },
{ name: 'Copy', shortcut: 'Cmd+C', iconName: 'copy', action: async () => console.log('Copy') },
{ name: 'Paste', shortcut: 'Cmd+V', iconName: 'clipboard', action: async () => console.log('Paste') },
{ divider: true },
{ name: 'Find', shortcut: 'Cmd+F', iconName: 'search', action: async () => console.log('Find') },
{ name: 'Replace', shortcut: 'Cmd+H', action: async () => console.log('Replace') },
]
},
{
name: 'View',
action: async () => {}, // No-op action for menu with submenu
submenu: [
{ name: 'Toggle Fullscreen', shortcut: 'F11', iconName: 'expand', action: async () => console.log('Fullscreen') },
{ name: 'Zoom In', shortcut: 'Cmd++', iconName: 'zoom-in', action: async () => console.log('Zoom in') },
{ name: 'Zoom Out', shortcut: 'Cmd+-', iconName: 'zoom-out', action: async () => console.log('Zoom out') },
{ name: 'Reset Zoom', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
{ divider: true },
{ name: 'Toggle Sidebar', shortcut: 'Cmd+B', action: async () => console.log('Toggle sidebar') },
{ name: 'Toggle Terminal', shortcut: 'Cmd+J', iconName: 'terminal', action: async () => console.log('Toggle terminal') },
]
},
{
name: 'Help',
action: async () => {}, // No-op action for menu with submenu
submenu: [
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
{ name: 'Release Notes', iconName: 'file-text', action: async () => console.log('Release notes') },
{ divider: true },
{ name: 'Report Issue', iconName: 'bug', action: async () => console.log('Report issue') },
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
]
}
];
return html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
const appbar = elementArg.querySelector('#appbar') as DeesAppuiBar;
// Set up status toggle
const statusButtons = elementArg.querySelectorAll('.status-toggle dees-button');
statusButtons[0].addEventListener('click', () => {
appbar.user = { ...appbar.user, status: 'online' };
});
statusButtons[1].addEventListener('click', () => {
appbar.user = { ...appbar.user, status: 'busy' };
});
statusButtons[2].addEventListener('click', () => {
appbar.user = { ...appbar.user, status: 'away' };
});
statusButtons[3].addEventListener('click', () => {
appbar.user = { ...appbar.user, status: 'offline' };
});
// Set up window controls toggle
const windowControlsButton = elementArg.querySelector('.window-controls-toggle dees-button');
windowControlsButton.addEventListener('click', () => {
appbar.showWindowControls = !appbar.showWindowControls;
});
// Set up breadcrumb buttons
const breadcrumbButtons = elementArg.querySelectorAll('.breadcrumb-toggle dees-button');
breadcrumbButtons[0].addEventListener('click', () => {
appbar.breadcrumbs = 'Home > Documents > Projects > MyApp > src > index.ts';
});
breadcrumbButtons[1].addEventListener('click', () => {
appbar.breadcrumbs = 'Dashboard';
});
}}>
<style>
${css`
.demo-container {
height: 600px;
width: 100%;
background: #1a1a1a;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 20px;
color: #ccc;
}
.controls {
padding: 20px;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-group label {
font-size: 12px;
color: #888;
}
`}
</style>
<div class="demo-container">
<dees-appui-appbar
id="appbar"
.menuItems=${menuItems}
.breadcrumbs=${'Project > src > components > AppBar.ts'}
.breadcrumbSeparator=${' > '}
.showWindowControls=${true}
.showSearch=${true}
.theme=${'dark'}
.user=${{
name: 'John Doe',
status: 'online' as 'online' | 'offline' | 'busy' | 'away'
}}
@menu-select=${(e: CustomEvent) => console.log('Menu selected:', e.detail.item)}
@breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb clicked:', e.detail)}
@search-click=${() => console.log('Search clicked')}
@user-menu-open=${() => console.log('User menu clicked')}
></dees-appui-appbar>
<div class="content">
<h2>App Bar Demo</h2>
<p>This demo shows various features of the app bar component:</p>
<ul>
<li>Dynamic menu items with icons, shortcuts, and submenus</li>
<li>Breadcrumb navigation</li>
<li>User account section with status indicator</li>
<li>Search icon</li>
<li>Window controls (platform-specific)</li>
<li>Dark/light theme support</li>
<li>Keyboard navigation (Tab, Enter, Escape)</li>
<li>Custom events for all interactions</li>
</ul>
</div>
<div class="controls">
<div class="control-group">
<label>Theme</label>
<dees-button-group class="theme-toggle">
<dees-button>Dark</dees-button>
<dees-button>Light</dees-button>
</dees-button-group>
</div>
<div class="control-group">
<label>User Status</label>
<dees-button-group class="status-toggle">
<dees-button>Online</dees-button>
<dees-button>Busy</dees-button>
<dees-button>Away</dees-button>
<dees-button>Offline</dees-button>
</dees-button-group>
</div>
<div class="control-group">
<label>Window Controls</label>
<dees-button-group class="window-controls-toggle">
<dees-button>Toggle</dees-button>
</dees-button-group>
</div>
<div class="control-group">
<label>Breadcrumbs</label>
<dees-button-group class="breadcrumb-toggle">
<dees-button>Long Path</dees-button>
<dees-button>Short Path</dees-button>
</dees-button-group>
</div>
</div>
</div>
</dees-demowrapper>
`;
};

View File

@ -1,59 +1,310 @@
import {
DeesElement,
type TemplateResult,
property,
customElement,
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 required components
import './dees-icon.js';
import './dees-windowcontrols.js';
import './dees-appui-profiledropdown.js';
declare global {
interface HTMLElementTagNameMap {
'dees-appui-appbar': DeesAppuiBar;
}
}
@customElement('dees-appui-appbar')
export class DeesAppuiBar extends DeesElement {
public static demo = () => html`<dees-appui-appbar></dees-appui-appbar>`;
public static demo = demoFunc;
// INSTANCE PROPERTIES
@property({ type: Array })
public menuItems: interfaces.IAppBarMenuItem[] = [];
@property({ type: String })
public breadcrumbs: string = '';
@property({ type: String })
public breadcrumbSeparator: string = ' > ';
@property({ type: Boolean })
public showWindowControls: boolean = true;
@property({ type: Object })
public user?: {
name: string;
email?: string;
avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away';
};
@property({ type: Array })
public profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean })
public showSearch: boolean = false;
// STATE
@state()
private activeMenu: string | null = null;
@state()
private openDropdowns: Set<string> = new Set();
@state()
private focusedItem: string | null = null;
@state()
private focusedDropdownItem: number = -1;
@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;
height: 100%;
width: 100%;
height: 40px;
border-bottom: 1px solid #202020;
background: #000000;
color: #ffffff80;
font-size: 12px;
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)};
grid-template-columns: ${cssManager.cssGridColumns(3, 20)};
-webkit-app-region: drag;
user-select: none;
}
.menus {
display: flex;
padding-left: 8px;
align-items: center;
gap: 4px;
padding: 0 8px;
cursor: default;
}
.menuItem {
position: relative;
line-height: 24px;
padding: 0px 8px;
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: #ffffff20;
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;
line-height: 24px;
margin: 8px;
border-radius: 8px;
text-align: center;
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;
}
`,
];
@ -62,16 +313,391 @@ export class DeesAppuiBar extends DeesElement {
public render(): TemplateResult {
return html`
<div class="menus">
<dees-windowcontrols></dees-windowcontrols>
<div class="menuItem">File</div>
<div class="menuItem">View</div>
<div class="menuItem">Help</div>
<div class="menuItem">Terminal</div>
${this.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''}
${this.renderMenuItems()}
</div>
<div class="breadcrumbs">
tool:social.io > org:design.estate > prop:lossless.com
${this.renderBreadcrumbs()}
</div>
<div class="account">
${this.renderAccountSection()}
</div>
<div class="account"></div>
`;
}
private renderMenuItems(): TemplateResult {
return html`
${this.menuItems.map((item, index) => this.renderMenuItem(item, `menu-${index}`))}
`;
}
private renderMenuItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult {
if ('divider' in item && item.divider) {
return html`<div class="dropdown-divider"></div>`;
}
const menuItem = item as interfaces.IAppBarMenuItemRegular;
const isActive = this.activeMenu === itemId;
const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0;
return html`
<div
class="menuItem ${isActive ? 'active' : ''}"
?disabled=${menuItem.disabled}
tabindex="${menuItem.disabled ? -1 : 0}"
data-item-id="${itemId}"
@click=${() => this.handleMenuClick(menuItem, itemId)}
@keydown=${(e: KeyboardEvent) => this.handleMenuKeydown(e, menuItem, itemId)}
role="menuitem"
aria-haspopup="${hasSubmenu}"
aria-expanded="${isActive}"
>
${menuItem.iconName ? html`<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>` : ''}
${menuItem.name}
${hasSubmenu ? this.renderDropdown(menuItem.submenu, itemId, isActive) : ''}
</div>
`;
}
private renderDropdown(items: interfaces.IAppBarMenuItem[], parentId: string, isOpen: boolean): TemplateResult {
return html`
<div
class="dropdown ${isOpen ? 'open' : ''}"
@click=${(e: Event) => e.stopPropagation()}
@keydown=${(e: KeyboardEvent) => this.handleDropdownKeydown(e, items, parentId)}
tabindex="${isOpen ? 0 : -1}"
role="menu"
>
${items.map((item, index) => this.renderDropdownItem(item, `${parentId}-${index}`))}
</div>
`;
}
private renderDropdownItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult {
if ('divider' in item && item.divider) {
return html`<div class="dropdown-divider"></div>`;
}
const menuItem = item as interfaces.IAppBarMenuItemRegular;
const itemIndex = parseInt(itemId.split('-').pop() || '0');
const isFocused = this.focusedDropdownItem === itemIndex;
return html`
<div
class="dropdown-item ${isFocused ? 'focused' : ''}"
?disabled=${menuItem.disabled}
@click=${() => this.handleDropdownItemClick(menuItem)}
@mouseenter=${() => this.focusedDropdownItem = itemIndex}
role="menuitem"
tabindex="${menuItem.disabled ? -1 : 0}"
>
${menuItem.iconName ? html`<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>` : ''}
<span>${menuItem.name}</span>
${menuItem.shortcut ? html`<span class="shortcut">${menuItem.shortcut}</span>` : ''}
</div>
`;
}
private renderBreadcrumbs(): TemplateResult {
if (!this.breadcrumbs) {
return html``;
}
const parts = this.breadcrumbs.split(this.breadcrumbSeparator);
return html`
${parts.map((part, index) => html`
${index > 0 ? html`<span class="breadcrumb-separator">${this.breadcrumbSeparator}</span>` : ''}
<span
class="breadcrumb-item"
@click=${() => this.handleBreadcrumbClick(part, index)}
>
${part}
</span>
`)}
`;
}
private renderAccountSection(): TemplateResult {
return html`
${this.showSearch ? html`
<dees-icon
class="search-icon"
.icon=${'lucide:search'}
@click=${this.handleSearchClick}
></dees-icon>
` : ''}
${this.user ? html`
<div style="position: relative;">
<div class="user-info" @click=${this.handleUserClick}>
<div class="user-avatar">
${this.user.avatar ?
html`<img src="${this.user.avatar}" alt="${this.user.name}">` :
html`${this.user.name.charAt(0).toUpperCase()}`
}
${this.user.status ? html`
<div class="user-status ${this.user.status}"></div>
` : ''}
</div>
<span>${this.user.name}</span>
</div>
<dees-appui-profiledropdown
.user=${this.user}
.menuItems=${this.profileMenuItems}
.isOpen=${this.isProfileDropdownOpen}
.position=${'top-right'}
@menu-select=${(e: CustomEvent) => this.handleProfileMenuSelect(e)}
></dees-appui-profiledropdown>
</div>
` : ''}
`;
}
// Event handlers
private handleMenuClick(item: interfaces.IAppBarMenuItemRegular, itemId: string) {
if (item.disabled) return;
if (item.submenu && item.submenu.length > 0) {
// Toggle dropdown
if (this.activeMenu === itemId) {
this.activeMenu = null;
} else {
this.activeMenu = itemId;
}
} else {
// Execute action
this.activeMenu = null;
if (item.action) {
item.action();
}
this.dispatchEvent(new CustomEvent('menu-select', {
detail: { item },
bubbles: true,
composed: true
}));
}
}
private handleDropdownItemClick(item: interfaces.IAppBarMenuItemRegular) {
if (item.disabled) return;
this.activeMenu = null;
if (item.action) {
item.action();
}
this.dispatchEvent(new CustomEvent('menu-select', {
detail: { item },
bubbles: true,
composed: true
}));
}
private handleMenuKeydown(e: KeyboardEvent, item: interfaces.IAppBarMenuItemRegular, itemId: string) {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
this.handleMenuClick(item, itemId);
break;
case 'ArrowDown':
if (item.submenu && this.activeMenu === itemId) {
e.preventDefault();
// Focus first non-disabled item in dropdown
this.focusedDropdownItem = 0;
const firstValidItem = this.findNextValidItem(item.submenu, -1, 1);
if (firstValidItem !== -1) {
this.focusedDropdownItem = firstValidItem;
// Focus the dropdown element
setTimeout(() => {
const dropdown = this.renderRoot.querySelector('.dropdown.open');
if (dropdown) {
(dropdown as HTMLElement).focus();
}
}, 0);
}
}
break;
case 'Escape':
this.activeMenu = null;
this.focusedDropdownItem = -1;
break;
case 'Tab':
// Let default tab navigation work but close dropdown
if (this.activeMenu === itemId) {
this.activeMenu = null;
this.focusedDropdownItem = -1;
}
break;
case 'ArrowRight':
e.preventDefault();
this.focusNextMenuItem(itemId, 1);
break;
case 'ArrowLeft':
e.preventDefault();
this.focusNextMenuItem(itemId, -1);
break;
}
}
private handleBreadcrumbClick(breadcrumb: string, index: number) {
this.dispatchEvent(new CustomEvent('breadcrumb-navigate', {
detail: { breadcrumb, index },
bubbles: true,
composed: true
}));
}
private handleSearchClick() {
this.dispatchEvent(new CustomEvent('search-click', {
bubbles: true,
composed: true
}));
}
private handleUserClick() {
this.isProfileDropdownOpen = !this.isProfileDropdownOpen;
// Also emit the event for backward compatibility
this.dispatchEvent(new CustomEvent('user-menu-open', {
bubbles: true,
composed: true
}));
}
private handleProfileMenuSelect(e: CustomEvent) {
this.isProfileDropdownOpen = false;
// Re-emit the event
this.dispatchEvent(new CustomEvent('profile-menu-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
// Lifecycle
async connectedCallback() {
await super.connectedCallback();
// Add global click listener to close dropdowns
this.addEventListener('click', this.handleGlobalClick);
document.addEventListener('click', this.handleDocumentClick);
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleDocumentClick);
}
private handleGlobalClick = (e: Event) => {
// Prevent closing when clicking inside
e.stopPropagation();
}
private handleDocumentClick = () => {
// Close all dropdowns when clicking outside
this.activeMenu = null;
this.focusedDropdownItem = -1;
// Note: Profile dropdown handles its own outside clicks
}
private handleDropdownKeydown(e: KeyboardEvent, items: interfaces.IAppBarMenuItem[], _parentId: string) {
const validItems = items.filter(item => !('divider' in item && item.divider));
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
const nextIndex = this.findNextValidItem(items, this.focusedDropdownItem, 1);
if (nextIndex !== -1) {
this.focusedDropdownItem = nextIndex;
}
break;
case 'ArrowUp':
e.preventDefault();
const prevIndex = this.findNextValidItem(items, this.focusedDropdownItem, -1);
if (prevIndex !== -1) {
this.focusedDropdownItem = prevIndex;
}
break;
case 'Enter':
e.preventDefault();
if (this.focusedDropdownItem !== -1) {
const focusedItem = validItems[this.focusedDropdownItem];
if (focusedItem && 'action' in focusedItem && !focusedItem.disabled) {
this.handleDropdownItemClick(focusedItem as interfaces.IAppBarMenuItemRegular);
}
}
break;
case 'Home':
e.preventDefault();
const firstIndex = this.findNextValidItem(items, -1, 1);
if (firstIndex !== -1) {
this.focusedDropdownItem = firstIndex;
}
break;
case 'End':
e.preventDefault();
const lastIndex = this.findNextValidItem(items, items.length, -1);
if (lastIndex !== -1) {
this.focusedDropdownItem = lastIndex;
}
break;
case 'Escape':
e.preventDefault();
this.activeMenu = null;
this.focusedDropdownItem = -1;
// Return focus to menu item
const menuItem = this.renderRoot.querySelector(`.menuItem.active`);
if (menuItem) {
(menuItem as HTMLElement).focus();
}
break;
}
}
private findNextValidItem(items: interfaces.IAppBarMenuItem[], currentIndex: number, direction: number): number {
let index = currentIndex + direction;
while (index >= 0 && index < items.length) {
const item = items[index];
// Skip dividers and disabled items
if (!('divider' in item && item.divider) && !('disabled' in item && item.disabled)) {
return index;
}
index += direction;
}
return -1;
}
private focusNextMenuItem(currentItemId: string, direction: number) {
const menuItems = Array.from(this.renderRoot.querySelectorAll('.menuItem'));
const currentIndex = menuItems.findIndex(item => item.getAttribute('data-item-id') === currentItemId);
if (currentIndex === -1) return;
let nextIndex = currentIndex + direction;
// Wrap around
if (nextIndex < 0) {
nextIndex = menuItems.length - 1;
} else if (nextIndex >= menuItems.length) {
nextIndex = 0;
}
// Find next non-disabled item
let attempts = 0;
while (attempts < menuItems.length) {
const nextItem = menuItems[nextIndex] as HTMLElement;
if (!nextItem.hasAttribute('disabled')) {
nextItem.focus();
// Close current dropdown if open
if (this.activeMenu) {
this.activeMenu = null;
this.focusedDropdownItem = -1;
}
break;
}
nextIndex = (nextIndex + direction + menuItems.length) % menuItems.length;
attempts++;
}
}
}

View File

@ -0,0 +1,157 @@
import { html, css } from '@design.estate/dees-element';
import type { DeesAppuiBase } from './dees-appui-base.js';
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
import type { ITab } from './interfaces/tab.js';
import type { ISelectionOption } from './interfaces/selectionoption.js';
import * as plugins from './00plugins.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
// Menu items for the appbar
const menuItems: IAppBarMenuItem[] = [
{
name: 'File',
action: async () => {},
submenu: [
{ name: 'New Project', shortcut: 'Cmd+N', iconName: 'filePlus', action: async () => console.log('New project') },
{ name: 'Open Project...', shortcut: 'Cmd+O', iconName: 'folderOpen', action: async () => console.log('Open project') },
{ name: 'Recent Projects', action: async () => {}, submenu: [
{ name: 'my-app', action: async () => console.log('Open my-app') },
{ name: 'component-lib', action: async () => console.log('Open component-lib') },
{ name: 'api-server', action: async () => console.log('Open api-server') },
]},
{ divider: true },
{ name: 'Save All', shortcut: 'Cmd+Shift+S', iconName: 'save', action: async () => console.log('Save all') },
{ divider: true },
{ name: 'Close Project', action: async () => console.log('Close project') },
]
},
{
name: 'Edit',
action: async () => {},
submenu: [
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => console.log('Undo') },
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => console.log('Redo') },
{ divider: true },
{ name: 'Cut', shortcut: 'Cmd+X', iconName: 'scissors', action: async () => console.log('Cut') },
{ name: 'Copy', shortcut: 'Cmd+C', iconName: 'copy', action: async () => console.log('Copy') },
{ name: 'Paste', shortcut: 'Cmd+V', iconName: 'clipboard', action: async () => console.log('Paste') },
]
},
{
name: 'View',
action: async () => {},
submenu: [
{ name: 'Toggle Sidebar', shortcut: 'Cmd+B', action: async () => console.log('Toggle sidebar') },
{ name: 'Toggle Terminal', shortcut: 'Cmd+J', iconName: 'terminal', action: async () => console.log('Toggle terminal') },
{ divider: true },
{ name: 'Zoom In', shortcut: 'Cmd++', iconName: 'zoomIn', action: async () => console.log('Zoom in') },
{ name: 'Zoom Out', shortcut: 'Cmd+-', iconName: 'zoomOut', action: async () => console.log('Zoom out') },
{ name: 'Reset Zoom', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
]
},
{
name: 'Help',
action: async () => {},
submenu: [
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
{ name: 'Release Notes', iconName: 'fileText', action: async () => console.log('Release notes') },
{ divider: true },
{ name: 'Report Issue', iconName: 'bug', action: async () => console.log('Report issue') },
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
]
}
];
// Main menu tabs (left sidebar)
const mainMenuTabs: ITab[] = [
{ key: 'dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard selected') },
{ key: 'projects', iconName: 'lucide:folder', action: () => console.log('Projects selected') },
{ key: 'analytics', iconName: 'lucide:lineChart', action: () => console.log('Analytics selected') },
{ key: 'settings', iconName: 'lucide:settings', action: () => console.log('Settings selected') },
];
// Selector options (second sidebar)
const selectorOptions: (ISelectionOption | { divider: true })[] = [
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview selected') },
{ key: 'Components', iconName: 'package', action: () => console.log('Components selected') },
{ key: 'Services', iconName: 'server', action: () => console.log('Services selected') },
{ divider: true },
{ key: 'Database', iconName: 'database', action: () => console.log('Database selected') },
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings selected') },
];
// Main content tabs
const mainContentTabs: ITab[] = [
{ key: 'Details', iconName: 'lucide:file', action: () => console.log('Details tab') },
{ key: 'Logs', iconName: 'lucide:list', action: () => console.log('Logs tab') },
{ key: 'Metrics', iconName: 'lucide:lineChart', action: () => console.log('Metrics tab') },
];
// Profile menu items
const profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [
{ name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile settings') },
{ name: 'Account', iconName: 'settings', action: async () => console.log('Account settings') },
{ divider: true },
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') },
{ divider: true },
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
];
return html`
<dees-demowrapper>
<style>
${css`
.demo-container {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
`}
</style>
<div class="demo-container">
<dees-appui-base
.appbarMenuItems=${menuItems}
.appbarBreadcrumbs=${'Dashboard'}
.appbarUser=${{
name: 'Jane Smith',
email: 'jane.smith@example.com',
status: 'online' as 'online' | 'offline' | 'busy' | 'away'
}}
.appbarProfileMenuItems=${profileMenuItems}
.appbarShowWindowControls=${true}
.appbarShowSearch=${true}
.mainmenuTabs=${mainMenuTabs}
.mainselectorOptions=${selectorOptions}
.maincontentTabs=${mainContentTabs}
@appbar-menu-select=${(e: CustomEvent) => console.log('Menu selected:', e.detail)}
@appbar-breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb:', e.detail)}
@appbar-search-click=${() => console.log('Search clicked')}
@appbar-user-menu-open=${() => console.log('User menu opened')}
@appbar-profile-menu-select=${(e: CustomEvent) => console.log('Profile menu selected:', e.detail)}
@mainmenu-tab-select=${(e: CustomEvent) => console.log('Tab selected:', e.detail)}
@mainselector-option-select=${(e: CustomEvent) => console.log('Option selected:', e.detail)}
>
<div slot="maincontent" style="padding: 40px; color: #ccc;">
<h1>Application Content</h1>
<p>This is the main content area where your application's primary interface would be displayed.</p>
<p>The layout includes:</p>
<ul>
<li>App bar with menus, breadcrumbs, and user account</li>
<li>Main menu (left sidebar) for primary navigation</li>
<li>Selector menu (second sidebar) for sub-navigation</li>
<li>Main content area (this section)</li>
<li>Activity log (right sidebar)</li>
</ul>
</div>
</dees-appui-base>
</div>
</dees-demowrapper>
`;
};

View File

@ -6,11 +6,89 @@ import {
html,
css,
cssManager,
state,
} 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 { DeesAppuiMainmenu } from './dees-appui-mainmenu.js';
import type { DeesAppuiMainselector } from './dees-appui-mainselector.js';
import type { DeesAppuiMaincontent } from './dees-appui-maincontent.js';
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-mainmenu.js';
import './dees-appui-mainselector.js';
import './dees-appui-maincontent.js';
import './dees-appui-activitylog.js';
@customElement('dees-appui-base')
export class DeesAppuiBase extends DeesElement {
public static demo = () => html`<dees-appui-base></dees-appui-base>`;
public static demo = demoFunc;
// Properties for appbar
@property({ type: Array })
public appbarMenuItems: interfaces.IAppBarMenuItem[] = [];
@property({ type: String })
public appbarBreadcrumbs: string = '';
@property({ type: String })
public appbarBreadcrumbSeparator: string = ' > ';
@property({ type: Boolean })
public appbarShowWindowControls: boolean = true;
@property({ type: Object })
public appbarUser?: {
name: string;
email?: string;
avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away';
};
@property({ type: Array })
public appbarProfileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean })
public appbarShowSearch: boolean = false;
// Properties for mainmenu
@property({ type: Array })
public mainmenuTabs: interfaces.ITab[] = [];
@property({ type: Object })
public mainmenuSelectedTab?: interfaces.ITab;
// Properties for mainselector
@property({ type: Array })
public mainselectorOptions: (interfaces.ISelectionOption | { divider: true })[] = [];
@property({ type: Object })
public mainselectorSelectedOption?: interfaces.ISelectionOption;
// Properties for maincontent
@property({ type: Array })
public maincontentTabs: interfaces.ITab[] = [];
// References to child components
@state()
public appbar?: DeesAppuiBar;
@state()
public mainmenu?: DeesAppuiMainmenu;
@state()
public mainselector?: DeesAppuiMainselector;
@state()
public maincontent?: DeesAppuiMaincontent;
@state()
public activitylog?: DeesAppuiActivitylog;
public static styles = [
cssManager.defaultStyles,
@ -19,6 +97,7 @@ export class DeesAppuiBase extends DeesElement {
position: absolute;
height: 100%;
width: 100%;
background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
}
.maingrid {
position: absolute;
@ -26,7 +105,7 @@ export class DeesAppuiBase extends DeesElement {
height: calc(100% - 40px);
width: 100%;
display: grid;
grid-template-columns: 60px 240px auto 240px;
grid-template-columns: 60px 240px 1fr 240px;
}
`,
];
@ -35,13 +114,106 @@ export class DeesAppuiBase extends DeesElement {
public render(): TemplateResult {
return html`
<style></style>
<dees-appui-appbar></dees-appui-appbar>
<dees-appui-appbar
.menuItems=${this.appbarMenuItems}
.breadcrumbs=${this.appbarBreadcrumbs}
.breadcrumbSeparator=${this.appbarBreadcrumbSeparator}
.showWindowControls=${this.appbarShowWindowControls}
.user=${this.appbarUser}
.profileMenuItems=${this.appbarProfileMenuItems}
.showSearch=${this.appbarShowSearch}
@menu-select=${(e: CustomEvent) => this.handleAppbarMenuSelect(e)}
@breadcrumb-navigate=${(e: CustomEvent) => this.handleAppbarBreadcrumbNavigate(e)}
@search-click=${() => this.handleAppbarSearchClick()}
@user-menu-open=${() => this.handleAppbarUserMenuOpen()}
@profile-menu-select=${(e: CustomEvent) => this.handleAppbarProfileMenuSelect(e)}
></dees-appui-appbar>
<div class="maingrid">
<dees-appui-mainmenu></dees-appui-mainmenu>
<dees-appui-mainselector></dees-appui-mainselector>
<dees-appui-maincontent></dees-appui-maincontent>
<dees-appui-mainmenu
.tabs=${this.mainmenuTabs}
.selectedTab=${this.mainmenuSelectedTab}
@tab-select=${(e: CustomEvent) => this.handleMainmenuTabSelect(e)}
></dees-appui-mainmenu>
<dees-appui-mainselector
.selectionOptions=${this.mainselectorOptions}
.selectedOption=${this.mainselectorSelectedOption}
@option-select=${(e: CustomEvent) => this.handleMainselectorOptionSelect(e)}
></dees-appui-mainselector>
<dees-appui-maincontent
.tabs=${this.maincontentTabs}
>
<slot name="maincontent"></slot>
</dees-appui-maincontent>
<dees-appui-activitylog></dees-appui-activitylog>
</div>
`;
}
async firstUpdated() {
// Get references to child components
this.appbar = this.shadowRoot.querySelector('dees-appui-appbar');
this.mainmenu = this.shadowRoot.querySelector('dees-appui-mainmenu');
this.mainselector = this.shadowRoot.querySelector('dees-appui-mainselector');
this.maincontent = this.shadowRoot.querySelector('dees-appui-maincontent');
this.activitylog = this.shadowRoot.querySelector('dees-appui-activitylog');
}
// Event handlers for appbar
private handleAppbarMenuSelect(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('appbar-menu-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
private handleAppbarBreadcrumbNavigate(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('appbar-breadcrumb-navigate', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
private handleAppbarSearchClick() {
this.dispatchEvent(new CustomEvent('appbar-search-click', {
bubbles: true,
composed: true
}));
}
private handleAppbarUserMenuOpen() {
this.dispatchEvent(new CustomEvent('appbar-user-menu-open', {
bubbles: true,
composed: true
}));
}
private handleAppbarProfileMenuSelect(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('appbar-profile-menu-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
// Event handlers for mainmenu
private handleMainmenuTabSelect(e: CustomEvent) {
this.mainmenuSelectedTab = e.detail.tab;
this.dispatchEvent(new CustomEvent('mainmenu-tab-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
// Event handlers for mainselector
private handleMainselectorOptionSelect(e: CustomEvent) {
this.mainselectorSelectedOption = e.detail.option;
this.dispatchEvent(new CustomEvent('mainselector-option-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
}

View File

@ -11,35 +11,47 @@ import {
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import './dees-appui-tabs.js';
import type { DeesAppuiTabs } from './dees-appui-tabs.js';
@customElement('dees-appui-maincontent')
export class DeesAppuiMaincontent extends DeesElement {
public static demo = () => html`<dees-appui-maincontent></dees-appui-maincontent>`;
public static demo = () => html`
<dees-appui-maincontent
.tabs=${[
{ key: 'Overview', iconName: 'lucide:home', action: () => console.log('Overview') },
{ key: 'Details', iconName: 'lucide:file', action: () => console.log('Details') },
{ key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') },
]}
>
<div slot="content" style="padding: 40px; color: #ccc;">
<h1>Main Content Area</h1>
<p>This is where your application content goes.</p>
</div>
</dees-appui-maincontent>
`;
// INSTANCE
@property({
type: Array,
})
public tabs: interfaces.ITab[] = [
{ key: 'option 1', action: () => {} },
{ key: 'a very long option', action: () => {} },
{ key: 'reminder: set your tabs', action: () => {} },
{ key: 'option 4', action: () => {} },
{ key: '⚠️ Please set tabs', action: () => console.warn('No tabs configured for maincontent') },
];
@property()
public selectedTab = null;
@property({ type: Object })
public selectedTab: interfaces.ITab | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
color: #fff;
color: ${cssManager.bdTheme('#333', '#fff')};
display: block;
width: 100%;
height: 100%;
position: relative;
background: #161616;
background: ${cssManager.bdTheme('#ffffff', '#161616')};
}
.maincontainer {
position: absolute;
@ -52,110 +64,58 @@ export class DeesAppuiMaincontent extends DeesElement {
.topbar {
position: absolute;
width: 100%;
background: #000000;
user-select: none;
}
.topbar .tabsContainer {
padding-top: 20px;
padding-bottom: 0px;
position: relative;
z-index: 1;
display: grid;
margin-left: 24px;
font-size: 14px;
}
.topbar .tabsContainer .tab {
color: #a0a0a0;
white-space: nowrap;
margin-right: 30px;
padding-top: 4px;
padding-bottom: 12px;
transition: color 0.1s;
}
.topbar .tabsContainer .tab:hover {
color: #ffffff;
}
.topbar .tabsContainer .tab.selectedTab {
color: #e0e0e0;
}
.topbar .tabIndicator {
.content-area {
position: absolute;
z-index: 0;
left: 40px;
bottom: 0px;
height: 40px;
width: 40px;
background: #161616;
transition: all 0.1s;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top: 1px solid #444444;
}
.mainicon {
top: 60px;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
}
`,
];
public render(): TemplateResult {
return html`
<style>
.topbar .tabsContainer {
grid-template-columns: repeat(${this.tabs.length}, min-content);
}
</style>
<div class="maincontainer">
<div class="topbar">
<div class="tabsContainer">
${this.tabs.map((tabArg) => {
return html`
<div
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : null}"
@click="${() => {
this.selectedTab = tabArg;
this.updateTabIndicator();
tabArg.action();
}}"
>
${tabArg.key}
</div>
`;
})}
</div>
<div class="tabIndicator"></div>
<dees-appui-tabs
.tabs=${this.tabs}
.selectedTab=${this.selectedTab}
.showTabIndicator=${true}
.tabStyle=${'horizontal'}
@tab-select=${(e: CustomEvent) => this.handleTabSelect(e)}
></dees-appui-tabs>
</div>
<div class="content-area">
<slot></slot>
<slot name="content"></slot>
</div>
</div>
`;
}
/**
* updates the indicator
*/
private updateTabIndicator() {
let selectedTab = this.selectedTab;
const tabIndex = this.tabs.indexOf(selectedTab);
const selectedTabElement: HTMLElement = this.shadowRoot.querySelector(
`.tabsContainer .tab:nth-child(${tabIndex + 1})`
);
const tabsContainer: HTMLElement = this.shadowRoot.querySelector('.tabsContainer');
const marginLeft = parseInt(window.getComputedStyle(tabsContainer).getPropertyValue("margin-left"));
const tabIndicator: HTMLElement = this.shadowRoot.querySelector('.tabIndicator');
tabIndicator.style.width = selectedTabElement.clientWidth + 24 + 'px';
tabIndicator.style.left = selectedTabElement.offsetLeft + marginLeft - 12 + 'px';
private handleTabSelect(e: CustomEvent) {
this.selectedTab = e.detail.tab;
// Re-emit the event
this.dispatchEvent(new CustomEvent('tab-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
private updateTab(tabArg: interfaces.ITab) {
this.selectedTab = tabArg;
this.updateTabIndicator();
this.selectedTab.action();
}
firstUpdated() {
this.updateTab(this.tabs[0]);
async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
await super.firstUpdated(_changedProperties);
// Tab selection is now handled by the dees-appui-tabs component
// But we need to ensure the tabs component is ready
const tabsComponent = this.shadowRoot.querySelector('dees-appui-tabs') as DeesAppuiTabs;
if (tabsComponent) {
await tabsComponent.updateComplete;
}
}
}

View File

@ -1,5 +1,6 @@
import * as plugins from './00plugins.js';
import * as interfaces from './interfaces/index.js';
import { zIndexLayers } from './00zindex.js';
import {
DeesElement,
@ -18,17 +19,23 @@ import { DeesContextmenu } from './dees-contextmenu.js';
*/
@customElement('dees-appui-mainmenu')
export class DeesAppuiMainmenu extends DeesElement {
public static demo = () => html`<dees-appui-mainmenu></dees-appui-mainmenu>`;
public static demo = () => html`
<dees-appui-mainmenu
.tabs=${[
{ key: 'Dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard') },
{ key: 'Projects', iconName: 'lucide:folder', action: () => console.log('Projects') },
{ key: 'Analytics', iconName: 'lucide:lineChart', action: () => console.log('Analytics') },
{ key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') },
]}
></dees-appui-mainmenu>
`;
// INSTANCE
// INSTANCE
@property()
@property({ type: Array })
public tabs: interfaces.ITab[] = [
{ key: 'option 1', iconName: 'building', action: () => {} },
{ key: 'option 2', iconName: 'building', action: () => {} },
{ key: 'option 3', iconName: 'building', action: () => {} },
{ key: 'option 4', iconName: 'building', action: () => {} },
{ key: '⚠️ Please set tabs', iconName: 'lucide:alertTriangle', action: () => console.warn('No tabs configured for mainmenu') },
];
@property()
@ -39,16 +46,16 @@ export class DeesAppuiMainmenu extends DeesElement {
css`
.mainContainer {
--menuSize: 60px;
color: #ccc;
z-index: 10;
color: ${cssManager.bdTheme('#666', '#ccc')};
z-index: ${zIndexLayers.fixed.appBar};
display: block;
position: relative;
width: var(--menuSize);
height: 100%;
background: #000000;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.5);
background: ${cssManager.bdTheme('#f5f5f5', '#000000')};
box-shadow: ${cssManager.bdTheme('0px 0px 5px rgba(0, 0, 0, 0.1)', '0px 0px 5px rgba(0, 0, 0, 0.5)')};
user-select: none;
border-right: 1px solid #202020;
border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.tabsContainer {
@ -64,17 +71,17 @@ export class DeesAppuiMainmenu extends DeesElement {
}
.tab:hover {
background: rgba(255, 255, 255, 0.15);
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.15)')};
}
.tab.selectedTab {
color: #fff;
background: rgba(255, 255, 255, 0.1);
color: ${cssManager.bdTheme('#000', '#fff')};
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
}
.tabIndicator {
opacity: 0;
background: #4e729a;
background: ${cssManager.bdTheme('#2196f3', '#4e729a')};
position: absolute;
width: 5px;
height: calc((var(--menuSize) / 3) * 2);
@ -105,7 +112,7 @@ export class DeesAppuiMainmenu extends DeesElement {
this.updateTab(tabArg);
}}"
>
<dees-icon iconFA="${tabArg.iconName as any}"></dees-icon>
<dees-icon .icon="${tabArg.iconName || ''}"></dees-icon>
</div>
`;
})}
@ -115,7 +122,7 @@ export class DeesAppuiMainmenu extends DeesElement {
`;
}
private async updateTabIndicator() {
private updateTabIndicator() {
let selectedTab = this.selectedTab;
if (!selectedTab) {
selectedTab = this.tabs[0];
@ -124,7 +131,12 @@ export class DeesAppuiMainmenu extends DeesElement {
const selectedTabElement: HTMLElement = this.shadowRoot.querySelector(
`.tabsContainer .tab:nth-child(${tabIndex + 1})`
);
if (!selectedTabElement) return;
const tabIndicator: HTMLElement = this.shadowRoot.querySelector('.tabIndicator');
if (!tabIndicator) return;
const offsetTop = selectedTabElement.offsetTop;
tabIndicator.style.opacity = `1`;
tabIndicator.style.top = `calc(${offsetTop}px + (var(--menuSize) / 6))`;
@ -134,6 +146,13 @@ export class DeesAppuiMainmenu extends DeesElement {
this.selectedTab = tabArg;
this.updateTabIndicator();
this.selectedTab.action();
// Emit tab-select event
this.dispatchEvent(new CustomEvent('tab-select', {
detail: { tab: tabArg },
bubbles: true,
composed: true
}));
}
firstUpdated() {

View File

@ -2,6 +2,7 @@ import * as plugins from './00plugins.js';
import * as interfaces from './interfaces/index.js';
import { DeesContextmenu } from './dees-contextmenu.js';
import './dees-icon.js';
import {
DeesElement,
@ -19,22 +20,22 @@ import {
*/
@customElement('dees-appui-mainselector')
export class DeesAppuiMainselector extends DeesElement {
public static demo = () => html`<dees-appui-mainselector></dees-appui-mainselector>`;
public static demo = () => html`
<dees-appui-mainselector
.selectionOptions=${[
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview') },
{ key: 'Components', iconName: 'package', action: () => console.log('Components') },
{ key: 'Services', iconName: 'server', action: () => console.log('Services') },
{ key: 'Database', iconName: 'database', action: () => console.log('Database') },
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings') },
]}
></dees-appui-mainselector>
`;
// INSTANCE
@property()
public selectionOptions: interfaces.ISelectionOption[] = [
{
key: 'Overview',
action: () => {},
},
{
key: 'option 1',
action: () => {},
},
{ key: 'option 2', action: () => {} },
{ key: 'option 3', action: () => {} },
{ key: 'option 4', action: () => {} },
@property({ type: Array })
public selectionOptions: (interfaces.ISelectionOption | { divider: true })[] = [
{ key: '⚠️ Please set selection options', action: () => console.warn('No selection options configured for mainselector') },
];
@property()
@ -44,14 +45,14 @@ export class DeesAppuiMainselector extends DeesElement {
cssManager.defaultStyles,
css`
:host {
color: #fff;
color: ${cssManager.bdTheme('#333', '#fff')};
position: relative;
display: block;
width: 100%;
max-width: 300px;
height: 100%;
background: #000000;
border-right: 1px solid #222222;
background: ${cssManager.bdTheme('#fafafa', '#000000')};
border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.maincontainer {
position: absolute;
@ -63,52 +64,79 @@ export class DeesAppuiMainselector extends DeesElement {
.topbar {
position: absolute;
height: 32px;
height: 40px;
width: 100%;
display: flex;
align-items: center;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.topbar .heading {
padding-left: 16px;
padding-top: 8px;
line-height: 24px;
padding-left: 12px;
font-family: 'Geist Sans', sans-serif;
font-weight: 600;
font-size: 14px;
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
text-transform: uppercase;
letter-spacing: 0.5px;
}
.selectionOptions {
position: absolute;
top: 32px;
padding-top: 8px;
top: 40px;
left: 0px;
width: 100%;
right: 0px;
bottom: 0px;
overflow-y: auto;
font-family: 'Geist Sans', sans-serif;
font-size: 14px;
font-size: 12px;
padding: 4px 0;
}
.selectionOptions .selectionOption {
cursor: default;
margin-left: 16px;
margin-right: 16px;
padding-top: 8px;
padding-bottom: 8px;
border-top: 1px dotted #303030;
border-left: 0px solid rgba(0, 0, 0, 0);
transition: all 0.1s;
padding: 8px 12px;
margin: 0;
transition: background 0.1s;
display: flex;
align-items: center;
gap: 8px;
color: ${cssManager.bdTheme('#333', '#ccc')};
user-select: none;
}
.selectionOptions .selectionOption:hover {
border-left: 2px solid #26a69a50;
padding-left: 8px;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.selectionOptions .selectionOption:first-child {
border-top: 1px solid rgba(0, 0, 0, 0);
.selectionOptions .selectionOption:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
}
.selectionOptions .selectionOption.selectedOption {
border-left: 4px solid #26a69a;
padding-left: 10px;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
color: ${cssManager.bdTheme('#000', '#fff')};
font-weight: 500;
}
.selectionOptions .selectionOption.selectedOption::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: ${cssManager.bdTheme('#26a69a', '#26a69a')};
}
.selectionOption {
position: relative;
}
.selection-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
`,
];
@ -118,17 +146,22 @@ export class DeesAppuiMainselector extends DeesElement {
<style></style>
<div class="maincontainer">
<div class="topbar">
<div class="heading">Properties</div>
<div class="heading">Selector</div>
</div>
<div class="selectionOptions">
${this.selectionOptions.map((selectionOptionArg) => {
if ('divider' in selectionOptionArg && selectionOptionArg.divider) {
return html`<div class="selection-divider"></div>`;
}
const option = selectionOptionArg as interfaces.ISelectionOption;
return html`
<div
class="selectionOption ${this.selectedOption === selectionOptionArg
class="selectionOption ${this.selectedOption === option
? 'selectedOption'
: null}"
@click="${() => {
this.selectOption(selectionOptionArg);
this.selectOption(option);
}}"
@contextmenu="${(eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
@ -140,7 +173,10 @@ export class DeesAppuiMainselector extends DeesElement {
]);
}}"
>
${selectionOptionArg.key}
${option.iconName ? html`
<dees-icon .icon="${`lucide:${option.iconName}`}" style="font-size: 14px; opacity: 0.7;"></dees-icon>
` : ''}
<span style="flex: 1;">${option.key}</span>
</div>
`;
})}
@ -152,9 +188,24 @@ export class DeesAppuiMainselector extends DeesElement {
private selectOption(optionArg: interfaces.ISelectionOption) {
this.selectedOption = optionArg;
this.selectedOption.action();
// Emit option-select event
this.dispatchEvent(new CustomEvent('option-select', {
detail: { option: optionArg },
bubbles: true,
composed: true
}));
}
firstUpdated() {
this.selectOption(this.selectionOptions[0]);
async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
await super.firstUpdated(_changedProperties);
if (this.selectionOptions && this.selectionOptions.length > 0) {
await this.updateComplete;
// Find first non-divider option
const firstOption = this.selectionOptions.find(option => !('divider' in option)) as interfaces.ISelectionOption;
if (firstOption) {
this.selectOption(firstOption);
}
}
}
}

View File

@ -0,0 +1,402 @@
import * as plugins from './00plugins.js';
import { zIndexLayers } from './00zindex.js';
import {
DeesElement,
type TemplateResult,
property,
customElement,
html,
css,
cssManager,
state,
} from '@design.estate/dees-element';
@customElement('dees-appui-profiledropdown')
export class DeesAppuiProfileDropdown extends DeesElement {
public static demo = () => html`
<dees-appui-profiledropdown
.user=${{
name: 'John Doe',
email: 'john.doe@example.com',
avatar: 'https://randomuser.me/api/portraits/men/1.jpg',
status: 'online' as 'online'
}}
.menuItems=${[
{ name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile') },
{ name: 'Account', iconName: 'settings', action: async () => console.log('Account') },
{ divider: true },
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') },
{ divider: true },
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
]}
.isOpen=${true}
></dees-appui-profiledropdown>
`;
@property({ type: Object })
public user?: {
name: string;
email?: string;
avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away';
};
@property({ type: Array })
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean, reflect: true })
public isOpen: boolean = false;
@property({ type: String })
public position: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' = 'top-right';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: absolute;
top: 100%;
left: 0;
right: 0;
pointer-events: none;
}
.dropdown {
position: absolute;
min-width: 220px;
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)'
)};
z-index: ${zIndexLayers.overlay.dropdown};
opacity: 0;
transform: scale(0.95) translateY(-10px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
overflow: hidden;
font-size: 12px;
}
:host([isopen]) .dropdown {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.backdrop {
display: none;
}
/* Position variants */
.dropdown.top-right {
top: 100%;
right: 0;
margin-top: 4px;
}
.dropdown.top-left {
top: 100%;
left: 0;
margin-top: 8px;
}
.dropdown.bottom-right {
bottom: 100%;
right: 0;
margin-bottom: 8px;
}
.dropdown.bottom-left {
bottom: 100%;
left: 0;
margin-bottom: 8px;
}
/* User section */
.user-section {
padding: 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
position: relative;
width: 36px;
height: 36px;
border-radius: 50%;
background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#666', '#999')};
overflow: hidden;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-status {
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
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;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#000', '#fff')};
line-height: 1.2;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 11px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Menu section */
.menu-section {
padding: 4px 0;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: default;
transition: background 0.1s;
color: ${cssManager.bdTheme('#333', '#ccc')};
font-size: 12px;
line-height: 1;
user-select: none;
}
.menu-item:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.menu-item:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
}
.menu-item dees-icon {
font-size: 14px;
opacity: 0.7;
}
.menu-item-text {
flex: 1;
}
.menu-shortcut {
font-size: 11px;
color: ${cssManager.bdTheme('#999', '#666')};
margin-left: auto;
opacity: 0.7;
}
.menu-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
/* Backdrop for mobile */
@media (max-width: 768px) {
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: ${zIndexLayers.backdrop.dropdown};
opacity: 0;
transition: opacity 0.2s;
display: none;
}
:host([isopen]) .backdrop {
display: block;
opacity: 1;
pointer-events: auto;
}
.dropdown {
position: fixed;
top: 50%;
left: 50%;
right: auto;
bottom: auto;
transform: translate(-50%, -50%) scale(0.95);
margin: 0;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow-y: auto;
}
:host([isopen]) .dropdown {
transform: translate(-50%, -50%) scale(1);
}
}
`,
];
public render(): TemplateResult {
return html`
<div class="backdrop" @click=${() => this.close()}></div>
<div class="dropdown ${this.position}">
${this.user ? html`
<div class="user-section">
<div class="user-info">
<div class="user-avatar">
${this.user.avatar
? html`<img src="${this.user.avatar}" alt="${this.user.name}">`
: this.getInitials(this.user.name)
}
${this.user.status ? html`
<div class="user-status ${this.user.status}"></div>
` : ''}
</div>
<div class="user-details">
<div class="user-name">${this.user.name}</div>
${this.user.email ? html`
<div class="user-email">${this.user.email}</div>
` : ''}
</div>
</div>
</div>
` : ''}
<div class="menu-section">
${this.menuItems.map(item => this.renderMenuItem(item))}
</div>
</div>
`;
}
private renderMenuItem(item: plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true }): TemplateResult {
if ('divider' in item && item.divider) {
return html`<div class="menu-divider"></div>`;
}
const menuItem = item as plugins.tsclass.website.IMenuItem & { iconName?: string; shortcut?: string };
return html`
<div class="menu-item" @click=${() => this.handleMenuClick(menuItem)}>
${menuItem.iconName ? html`
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
` : ''}
<span class="menu-item-text">${menuItem.name}</span>
${menuItem.shortcut ? html`
<span class="menu-shortcut">${menuItem.shortcut}</span>
` : ''}
</div>
`;
}
private getInitials(name: string): string {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
private async handleMenuClick(item: plugins.tsclass.website.IMenuItem & { iconName?: string; shortcut?: string }) {
await item.action();
this.close();
// Emit menu-select event
this.dispatchEvent(new CustomEvent('menu-select', {
detail: { item },
bubbles: true,
composed: true
}));
}
public open() {
this.isOpen = true;
}
public close() {
this.isOpen = false;
}
public toggle() {
this.isOpen = !this.isOpen;
}
// Handle clicks outside the dropdown
async connectedCallback() {
await super.connectedCallback();
this.handleOutsideClick = this.handleOutsideClick.bind(this);
document.addEventListener('click', this.handleOutsideClick);
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleOutsideClick);
}
private handleOutsideClick(event: MouseEvent) {
if (this.isOpen && !this.contains(event.target as Node)) {
// Check if the click is on the parent element (which contains the profile button)
const parentElement = this.parentElement;
if (parentElement && parentElement.contains(event.target as Node)) {
// Don't close if clicking within the parent element (e.g., on the profile button)
return;
}
this.close();
}
}
}

View File

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

View File

@ -0,0 +1,192 @@
import * as interfaces from './interfaces/index.js';
import {
DeesElement,
type TemplateResult,
property,
customElement,
html,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import './dees-appui-tabs.js';
import type { DeesAppuiTabs } from './dees-appui-tabs.js';
export interface IAppViewTab extends interfaces.ITab {
content?: TemplateResult | (() => TemplateResult);
}
export interface IAppView {
id: string;
name: string;
description?: string;
iconName?: string;
tabs: IAppViewTab[];
menuItems?: interfaces.ISelectionOption[];
}
@customElement('dees-appui-view')
export class DeesAppuiView extends DeesElement {
public static demo = () => html`
<dees-appui-view
.viewConfig=${{
id: 'demo-view',
name: 'Demo View',
description: 'A demonstration view',
iconName: 'lucide:home',
tabs: [
{
key: 'overview',
iconName: 'lucide:lineChart',
action: () => console.log('Overview tab'),
content: html`<div style="padding: 20px;">Overview Content</div>`
},
{
key: 'details',
iconName: 'lucide:fileText',
action: () => console.log('Details tab'),
content: html`<div style="padding: 20px;">Details Content</div>`
}
],
menuItems: [
{ key: 'General', action: () => console.log('General') },
{ key: 'Advanced', action: () => console.log('Advanced') },
]
}}
></dees-appui-view>
`;
// INSTANCE
@property({ type: Object })
public viewConfig: IAppView;
@state()
private selectedTab: IAppViewTab | null = null;
@state()
private tabs: DeesAppuiTabs;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
background: #161616;
}
.view-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.view-header {
background: #000000;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.view-content {
flex: 1;
position: relative;
overflow: hidden;
}
.tab-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
opacity: 0;
transition: opacity 0.2s;
}
.tab-content.active {
opacity: 1;
}
dees-appui-tabs {
height: 60px;
}
`,
];
public render(): TemplateResult {
if (!this.viewConfig) {
return html`<div>No view configuration provided</div>`;
}
return html`
<div class="view-container">
<div class="view-header">
<dees-appui-tabs
.tabs=${this.viewConfig.tabs}
.selectedTab=${this.selectedTab}
@tab-select=${(e: CustomEvent) => this.handleTabSelect(e)}
></dees-appui-tabs>
</div>
<div class="view-content">
${this.viewConfig.tabs.map((tab) => {
const isActive = tab === this.selectedTab;
const content = typeof tab.content === 'function' ? tab.content() : tab.content;
return html`
<div class="tab-content ${isActive ? 'active' : ''}">
${content || html`<slot name="${tab.key}"></slot>`}
</div>
`;
})}
</div>
</div>
`;
}
async firstUpdated() {
this.tabs = this.shadowRoot.querySelector('dees-appui-tabs');
if (this.viewConfig?.tabs?.length > 0) {
this.selectedTab = this.viewConfig.tabs[0];
}
}
private handleTabSelect(e: CustomEvent) {
this.selectedTab = e.detail.tab;
// Re-emit the event with view context
this.dispatchEvent(new CustomEvent('view-tab-select', {
detail: {
view: this.viewConfig,
tab: e.detail.tab
},
bubbles: true,
composed: true
}));
}
// Public methods for external control
public selectTab(tabKey: string) {
const tab = this.viewConfig.tabs.find(t => t.key === tabKey);
if (tab) {
this.selectedTab = tab;
if (this.tabs) {
this.tabs.selectedTab = tab;
}
}
}
public getMenuItems(): interfaces.ISelectionOption[] {
return this.viewConfig?.menuItems || [];
}
public getTabs(): IAppViewTab[] {
return this.viewConfig?.tabs || [];
}
}

View File

@ -0,0 +1,114 @@
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => {
return html`
<style>
${css`
.demoBox {
background: #000000;
padding: 40px;
min-height: 100vh;
box-sizing: border-box;
}
.demo-section {
margin-bottom: 32px;
}
.demo-title {
color: #fff;
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
font-family: 'Geist Sans', sans-serif;
}
.demo-description {
color: #999;
font-size: 14px;
margin-bottom: 24px;
font-family: 'Geist Sans', sans-serif;
}
`}
</style>
<div class="demoBox">
<div class="demo-section">
<h2 class="demo-title">Basic Button Groups</h2>
<p class="demo-description">Button groups without labels for simple grouping</p>
<dees-button-group>
<dees-button>Option 1</dees-button>
<dees-button>Option 2</dees-button>
<dees-button>Option 3</dees-button>
</dees-button-group>
</div>
<div class="demo-section">
<h2 class="demo-title">Labeled Button Groups</h2>
<p class="demo-description">Button groups with descriptive labels</p>
<dees-button-group label="View Mode:">
<dees-button type="highlighted">Grid</dees-button>
<dees-button>List</dees-button>
<dees-button>Cards</dees-button>
</dees-button-group>
</div>
<div class="demo-section">
<h2 class="demo-title">Multiple Groups</h2>
<p class="demo-description">Multiple button groups used together</p>
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
<dees-button-group label="Dataset:">
<dees-button type="highlighted">System</dees-button>
<dees-button>Network</dees-button>
<dees-button>Sales</dees-button>
</dees-button-group>
<dees-button-group label="Time Range:">
<dees-button>1H</dees-button>
<dees-button type="highlighted">24H</dees-button>
<dees-button>7D</dees-button>
<dees-button>30D</dees-button>
</dees-button-group>
<dees-button-group label="Actions:">
<dees-button>Refresh</dees-button>
<dees-button>Export</dees-button>
</dees-button-group>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Vertical Button Groups</h2>
<p class="demo-description">Button groups with vertical layout</p>
<div style="display: flex; gap: 24px;">
<dees-button-group direction="vertical" label="Navigation:">
<dees-button>Dashboard</dees-button>
<dees-button type="highlighted">Analytics</dees-button>
<dees-button>Reports</dees-button>
<dees-button>Settings</dees-button>
</dees-button-group>
<dees-button-group direction="vertical">
<dees-button>Add Item</dees-button>
<dees-button>Edit Item</dees-button>
<dees-button>Delete Item</dees-button>
</dees-button-group>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Mixed Button Types</h2>
<p class="demo-description">Different button types within groups</p>
<dees-button-group label="Status:">
<dees-button type="success">Active</dees-button>
<dees-button>Pending</dees-button>
<dees-button type="danger">Inactive</dees-button>
</dees-button-group>
</div>
</div>
`;
};

View File

@ -0,0 +1,83 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-button-group.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-button-group': DeesButtonGroup;
}
}
@customElement('dees-button-group')
export class DeesButtonGroup extends DeesElement {
public static demo = demoFunc;
@property()
public label: string = '';
@property()
public direction: 'horizontal' | 'vertical' = 'horizontal';
constructor() {
super();
domtools.elementBasic.setup();
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: inline-block;
}
.button-group {
display: flex;
gap: 8px;
align-items: center;
padding: 8px;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
border-radius: 6px;
}
.button-group.vertical {
flex-direction: column;
align-items: stretch;
}
.label {
color: ${cssManager.bdTheme('#666', '#999')};
font-size: 12px;
font-family: 'Geist Sans', sans-serif;
margin-right: 8px;
white-space: nowrap;
}
.button-group.vertical .label {
margin-right: 0;
margin-bottom: 8px;
}
::slotted(*) {
margin: 0 !important;
}
`,
];
public render(): TemplateResult {
return html`
<div class="button-group ${this.direction}">
${this.label ? html`<span class="label">${this.label}</span>` : ''}
<slot></slot>
</div>
`;
}
}

View File

@ -1,15 +1,301 @@
import { html } from '@design.estate/dees-element';
import { html, css, cssManager } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
import './dees-form.js';
import './dees-form-submit.js';
import './dees-input-text.js';
import './dees-icon.js';
export const demoFunc = () => html`
<dees-button>This is a slotted Text</dees-button>
<p>
<dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button>
</p>
<p><dees-button type="discreet">This is discreete button</dees-button></p>
<p><dees-button disabled>This is a disabled button</dees-button></p>
<p><dees-button type="big">This is a slotted Text</dees-button></p>
<p><dees-button status="normal">Normal Status</dees-button></p>
<p><dees-button disabled status="pending">Pending Status</dees-button></p>
<p><dees-button disabled status="success">Success Status</dees-button></p>
<p><dees-button disabled status="error">Error Status</dees-button></p>
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.button-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.vertical-group {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.demo-output {
margin-top: 16px;
padding: 12px;
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
border-radius: 6px;
font-size: 14px;
font-family: monospace;
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
}
.icon-row {
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0;
}
.code-snippet {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
padding: 8px 12px;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
display: inline-block;
margin: 4px 0;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Button Variants'} .subtitle=${'Different visual styles for various use cases'}>
<div class="button-group">
<dees-button type="default">Default</dees-button>
<dees-button type="secondary">Secondary</dees-button>
<dees-button type="destructive">Destructive</dees-button>
<dees-button type="outline">Outline</dees-button>
<dees-button type="ghost">Ghost</dees-button>
<dees-button type="link">Link Button</dees-button>
</div>
</dees-panel>
<dees-panel .title=${'2. Button Sizes'} .subtitle=${'Multiple sizes for different contexts and use cases'}>
<div class="button-group">
<dees-button size="sm">Small Button</dees-button>
<dees-button size="default">Default Size</dees-button>
<dees-button size="lg">Large Button</dees-button>
<dees-button size="icon" type="outline" .text=${'🚀'}></dees-button>
</div>
<div class="button-group" style="margin-top: 16px;">
<dees-button size="sm" type="secondary">Small Secondary</dees-button>
<dees-button size="default" type="destructive">Default Destructive</dees-button>
<dees-button size="lg" type="outline">Large Outline</dees-button>
</div>
</dees-panel>
<dees-panel .title=${'3. Buttons with Icons'} .subtitle=${'Combining icons with text for enhanced visual communication'}>
<div class="icon-row">
<dees-button>
<dees-icon iconFA="faPlus"></dees-icon>
Add Item
</dees-button>
<dees-button type="destructive">
<dees-icon iconFA="faTrash"></dees-icon>
Delete
</dees-button>
<dees-button type="outline">
<dees-icon iconFA="faDownload"></dees-icon>
Download
</dees-button>
</div>
<div class="icon-row">
<dees-button type="secondary" size="sm">
<dees-icon iconFA="faCog"></dees-icon>
Settings
</dees-button>
<dees-button type="ghost">
<dees-icon iconFA="faChevronLeft"></dees-icon>
Back
</dees-button>
<dees-button type="ghost">
Next
<dees-icon iconFA="faChevronRight"></dees-icon>
</dees-button>
</div>
<div class="icon-row">
<dees-button size="icon" type="default">
<dees-icon iconFA="faPlus"></dees-icon>
</dees-button>
<dees-button size="icon" type="secondary">
<dees-icon iconFA="faCog"></dees-icon>
</dees-button>
<dees-button size="icon" type="outline">
<dees-icon iconFA="faSearch"></dees-icon>
</dees-button>
<dees-button size="icon" type="ghost">
<dees-icon iconFA="faEllipsisV"></dees-icon>
</dees-button>
<dees-button size="icon" type="destructive">
<dees-icon iconFA="faTrash"></dees-icon>
</dees-button>
</div>
</dees-panel>
<dees-panel .title=${'4. Button States'} .subtitle=${'Different states to indicate button status and loading conditions'}>
<div class="button-group">
<dees-button status="normal">Normal</dees-button>
<dees-button status="pending">Processing...</dees-button>
<dees-button status="success">Success!</dees-button>
<dees-button status="error">Error!</dees-button>
<dees-button disabled>Disabled</dees-button>
</div>
<div class="button-group" style="margin-top: 16px;">
<dees-button type="secondary" status="pending" size="sm">Small Loading</dees-button>
<dees-button type="outline" status="pending">Default Loading</dees-button>
<dees-button type="destructive" status="pending" size="lg">Large Loading</dees-button>
</div>
</dees-panel>
<dees-panel .title=${'5. Event Handling'} .subtitle=${'Interactive examples with click event handling'}>
<div class="button-group">
<dees-button
@clicked=${() => {
const output = document.querySelector('#click-output');
if (output) {
output.textContent = `Clicked: Default button at ${new Date().toLocaleTimeString()}`;
}
}}
>
Click Me
</dees-button>
<dees-button
type="secondary"
.eventDetailData=${'custom-data-123'}
@clicked=${(e: CustomEvent) => {
const output = document.querySelector('#click-output');
if (output) {
output.textContent = `Clicked: Secondary button with data: ${e.detail.data}`;
}
}}
>
Click with Data
</dees-button>
<dees-button
type="destructive"
@clicked=${async () => {
const output = document.querySelector('#click-output');
if (output) {
output.textContent = 'Processing...';
await new Promise(resolve => setTimeout(resolve, 2000));
output.textContent = 'Action completed!';
}
}}
>
Async Action
</dees-button>
</div>
<div id="click-output" class="demo-output">
<em>Click a button to see the result...</em>
</div>
</dees-panel>
<dees-panel .title=${'6. Form Integration'} .subtitle=${'Buttons working within forms with automatic spacing'}>
<dees-form @formData=${(e: CustomEvent) => {
const output = document.querySelector('#form-output');
if (output) {
output.innerHTML = '<strong>Form submitted with data:</strong><br>' +
JSON.stringify(e.detail.data, null, 2);
}
}}>
<dees-input-text label="Name" key="name" required></dees-input-text>
<dees-input-text label="Email" key="email" type="email" required></dees-input-text>
<dees-input-text label="Message" key="message" isMultiline></dees-input-text>
<dees-button type="secondary">Save Draft</dees-button>
<dees-button type="ghost">Cancel</dees-button>
<dees-form-submit>Submit Form</dees-form-submit>
</dees-form>
<div id="form-output" class="demo-output" style="white-space: pre-wrap;">
<em>Submit the form to see the data...</em>
</div>
</dees-panel>
<dees-panel .title=${'7. Backward Compatibility'} .subtitle=${'Old button types are automatically mapped to new variants'}>
<div class="button-group">
<dees-button type="normal">Normal → Default</dees-button>
<dees-button type="highlighted">Highlighted → Destructive</dees-button>
<dees-button type="discreet">Discreet → Outline</dees-button>
<dees-button type="big">Big → Large Size</dees-button>
</div>
<p style="margin-top: 16px; font-size: 14px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};">
These legacy type values are maintained for backward compatibility but we recommend using the new variant system.
</p>
</dees-panel>
<dees-panel .title=${'8. Advanced Examples'} .subtitle=${'Complex button configurations and real-world use cases'}>
<div class="horizontal-group">
<div class="vertical-group">
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Action Group</h4>
<dees-button type="default" size="sm">
<dees-icon iconFA="faSave"></dees-icon>
Save Changes
</dees-button>
<dees-button type="secondary" size="sm">
<dees-icon iconFA="faUndo"></dees-icon>
Discard
</dees-button>
<dees-button type="ghost" size="sm">
<dees-icon iconFA="faQuestionCircle"></dees-icon>
Help
</dees-button>
</div>
<div class="vertical-group">
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Danger Zone</h4>
<dees-button type="destructive" size="sm">
<dees-icon iconFA="faTrash"></dees-icon>
Delete Account
</dees-button>
<dees-button type="outline" size="sm">
<dees-icon iconFA="faArchive"></dees-icon>
Archive Data
</dees-button>
<dees-button type="ghost" size="sm" disabled>
<dees-icon iconFA="faBan"></dees-icon>
Not Available
</dees-button>
</div>
</div>
<div style="margin-top: 24px;">
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Code Example:</h4>
<div class="code-snippet">
&lt;dees-button type="default" size="sm" @clicked="\${handleClick}"&gt;<br>
&nbsp;&nbsp;&lt;dees-icon iconFA="faSave"&gt;&lt;/dees-icon&gt;<br>
&nbsp;&nbsp;Save Changes<br>
&lt;/dees-button&gt;
</div>
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,4 +1,3 @@
import { demoFunc } from './dees-button.demo.js';
import {
customElement,
html,
@ -12,6 +11,7 @@ import {
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-button.demo.js';
declare global {
interface HTMLElementTagNameMap {
@ -48,140 +48,324 @@ export class DeesButton extends DeesElement {
@property({
type: String
})
public type: 'normal' | 'highlighted' | 'discreet' | 'big' = 'normal';
public type: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' | 'normal' | 'highlighted' | 'discreet' | 'big' = 'default';
@property({
type: String
})
public size: 'default' | 'sm' | 'lg' | 'icon' = 'default';
@property({
type: String
})
public status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
@property({
type: Boolean,
reflect: true
})
public insideForm: boolean = false;
constructor() {
super();
}
public async connectedCallback() {
await super.connectedCallback();
// Auto-detect if inside a form
if (!this.insideForm && this.closest('dees-form')) {
this.insideForm = true;
}
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
display: inline-block;
box-sizing: border-box;
font-family: 'Geist Sans', 'monospace';
font-family: inherit;
}
:host([hidden]) {
display: none;
}
/* Form spacing styles */
:host([inside-form]) {
margin-bottom: 16px;
}
:host([inside-form]:last-child) {
margin-bottom: 0;
}
dees-form[horizontal-layout] :host([inside-form]) {
display: inline-block;
margin-right: 16px;
margin-bottom: 0;
}
dees-form[horizontal-layout] :host([inside-form]:last-child) {
margin-right: 0;
}
.button {
transition: all 0.1s , color 0s;
position: relative;
font-size: 14px;
font-weight: 400;
display: flex;
justify-content: center;
display: inline-flex;
align-items: center;
background: ${cssManager.bdTheme('#fff', '#333')};
box-shadow: ${cssManager.bdTheme('0px 1px 3px rgba(0,0,0,0.3)', 'none')};
border: 1px solid ${cssManager.bdTheme('#eee', '#333')};
border-top: ${cssManager.bdTheme('1px solid #eee', '1px solid #444')};
border-radius: 4px;
height: 40px;
padding: 0px 8px;
min-width: 100px;
justify-content: center;
white-space: nowrap;
border-radius: 6px;
font-weight: 500;
transition: all 0.15s ease;
cursor: pointer;
user-select: none;
color: ${cssManager.bdTheme('#333', ' #ccc')};
max-width: 500px;
outline: none;
letter-spacing: -0.01em;
gap: 8px;
}
.button:hover {
background: #0050b9;
color: #ffffff;
border: 1px solid #0050b9;
border-top: 1px solid #0050b9;
/* Size variants */
.button.size-default {
height: 36px;
padding: 0 16px;
font-size: 14px;
}
.button:active {
background: #0069f2;
border-top: 1px solid #0069f2;
.button.size-sm {
height: 32px;
padding: 0 12px;
font-size: 13px;
}
.button.highlighted {
background: #e4002b;
.button.size-lg {
height: 44px;
padding: 0 24px;
font-size: 16px;
}
.button.size-icon {
height: 36px;
width: 36px;
padding: 0;
}
/* Default variant */
.button.default {
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20.2% 11.8%)')};
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 16.8%)')};
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.button.default:hover:not(.disabled) {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 10.2%)')};
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 20%)')};
}
.button.default:active:not(.disabled) {
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 9%)')};
}
/* Destructive variant */
.button.destructive {
background: hsl(0 84.2% 60.2%);
color: hsl(0 0% 98%);
border: 1px solid transparent;
}
.button.destructive:hover:not(.disabled) {
background: hsl(0 84.2% 56.2%);
}
.button.destructive:active:not(.disabled) {
background: hsl(0 84.2% 52.2%);
}
/* Outline variant */
.button.outline {
background: transparent;
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 21.8%)')};
}
.button.outline:hover:not(.disabled) {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 26.8%)')};
}
.button.outline:active:not(.disabled) {
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
}
/* Secondary variant */
.button.secondary {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
border: 1px solid transparent;
}
.button.secondary:hover:not(.disabled) {
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
}
.button.secondary:active:not(.disabled) {
background: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 11.8%)')};
}
/* Ghost variant */
.button.ghost {
background: transparent;
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
border: 1px solid transparent;
}
.button.ghost:hover:not(.disabled) {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
}
.button.ghost:active:not(.disabled) {
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
}
/* Link variant */
.button.link {
background: transparent;
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8%)')};
border: none;
color: #fff;
text-decoration: underline;
text-decoration-color: transparent;
height: auto;
padding: 0;
}
.button.highlighted:hover {
background: #b50021;
border: none;
color: #fff;
.button.link:hover:not(.disabled) {
text-decoration-color: currentColor;
}
.button.discreet {
background: none;
border: 1px solid #9b9b9e;
color: ${cssManager.bdTheme('#000', '#fff')};
/* Status states */
.button.pending,
.button.success,
.button.error {
pointer-events: none;
padding-left: 36px; /* Space for spinner */
}
.button.discreet:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
.button.size-sm.pending,
.button.size-sm.success,
.button.size-sm.error {
padding-left: 32px;
}
.button.size-lg.pending,
.button.size-lg.success,
.button.size-lg.error {
padding-left: 44px;
}
.button.pending {
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8% / 0.2)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(213.1 93.9% 67.8%)')};
border: 1px solid transparent;
}
.button.success {
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3% / 0.2)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(142.1 70.6% 45.3%)')};
border: 1px solid transparent;
}
.button.error {
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 62.8% 70.6% / 0.2)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 62.8% 70.6%)')};
border: 1px solid transparent;
}
/* Disabled state */
.button.disabled {
background: ${cssManager.bdTheme('#ffffff00', '#11111100')};
border: 1px dashed ${cssManager.bdTheme('#666666', '#666666')};
color: #9b9b9e;
cursor: default;
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Hidden state */
.button.hidden {
display: none;
}
.button.big {
width: 300px;
line-height: 48px;
font-size: 16px;
padding: 0px 48px;
margin-top: 32px;
}
.button.pending {
border: 1px dashed ${cssManager.bdTheme('#0069f2', '#0069f270')};
background: ${cssManager.bdTheme('#0069f2', '#0069f270')};
color: #fff;
}
.button.success {
border: 1px dashed ${cssManager.bdTheme('#689F38', '#8BC34A70')};
background: ${cssManager.bdTheme('#689F38', '#8BC34A70')};
color: #fff;
}
.button.error {
border: 1px dashed ${cssManager.bdTheme('#B71C1C', '#E64A1970')};
background: ${cssManager.bdTheme('#B71C1C', '#E64A1970')};
color: #fff;
/* Focus state */
.button:focus-visible {
outline: 2px solid ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8%)')};
outline-offset: 2px;
}
/* Loading spinner */
dees-spinner {
position: absolute;
left: 10px;
width: 16px;
height: 16px;
}
.button.size-sm dees-spinner {
left: 8px;
width: 14px;
height: 14px;
}
.button.size-lg dees-spinner {
left: 14px;
width: 18px;
height: 18px;
}
/* Icon sizing within buttons */
.button dees-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.button.size-sm dees-icon {
width: 14px;
height: 14px;
}
.button.size-lg dees-icon {
width: 18px;
height: 18px;
}
`,
];
public render(): TemplateResult {
// Map old types to new types for backward compatibility
const typeMap: {[key: string]: string} = {
'normal': 'default',
'highlighted': 'destructive',
'discreet': 'outline',
'big': 'default' // Will use size instead
};
const actualType = typeMap[this.type] || this.type;
const actualSize = this.type === 'big' ? 'lg' : this.size;
return html`
<div
class="button ${this.isHidden ? 'hidden' : 'block'} ${this.type} ${this.status} ${this.disabled
class="button ${this.isHidden ? 'hidden' : ''} ${actualType} size-${actualSize} ${this.status} ${this.disabled
? 'disabled'
: null}"
: ''}"
@click="${this.dispatchClick}"
>
${this.status === 'normal' ? html``: html`
<dees-spinner .bnw=${true} status="${this.status}"></dees-spinner>
<dees-spinner
.bnw=${true}
status="${this.status}"
size="${actualSize === 'sm' ? 14 : actualSize === 'lg' ? 18 : 16}"
></dees-spinner>
`}
<div class="textbox">${this.text ? this.text : this.textContent}</div>
<div class="textbox">${this.text || html`<slot>Button</slot>`}</div>
</div>
`;
}
@ -202,9 +386,6 @@ export class DeesButton extends DeesElement {
}
public async firstUpdated() {
if (!this.textContent) {
this.textContent = 'Button';
this.performUpdate();
}
// Don't set default text here as it interferes with slotted content
}
}

View File

@ -1,21 +1,483 @@
import { html } from '@design.estate/dees-element';
import { html, css, cssManager } from '@design.estate/dees-element';
import type { DeesChartArea } from './dees-chart-area.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
// Initial dataset values
const initialDatasets = {
system: {
label: 'System Usage (%)',
series: [
{
name: 'CPU',
data: [
{ x: new Date(Date.now() - 300000).toISOString(), y: 25 },
{ x: new Date(Date.now() - 240000).toISOString(), y: 30 },
{ x: new Date(Date.now() - 180000).toISOString(), y: 28 },
{ x: new Date(Date.now() - 120000).toISOString(), y: 35 },
{ x: new Date(Date.now() - 60000).toISOString(), y: 32 },
{ x: new Date().toISOString(), y: 38 },
],
},
{
name: 'Memory',
data: [
{ x: new Date(Date.now() - 300000).toISOString(), y: 45 },
{ x: new Date(Date.now() - 240000).toISOString(), y: 48 },
{ x: new Date(Date.now() - 180000).toISOString(), y: 46 },
{ x: new Date(Date.now() - 120000).toISOString(), y: 52 },
{ x: new Date(Date.now() - 60000).toISOString(), y: 50 },
{ x: new Date().toISOString(), y: 55 },
],
},
],
},
};
const initialFormatters = {
system: (val: number) => `${val}%`,
};
return html`
<style>
.demoBox {
position: relative;
background: #000000;
height: 100%;
width: 100%;
padding: 40px;
box-sizing: border-box;
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// Get the chart elements
const chartElement = elementArg.querySelector('#main-chart') as DeesChartArea;
const connectionsChartElement = elementArg.querySelector('#connections-chart') as DeesChartArea;
let intervalId: number;
let connectionsIntervalId: number;
let currentDataset = 'system';
// Y-axis formatters for different datasets
const formatters = {
system: (val: number) => `${val}%`,
network: (val: number) => `${val} Mbps`,
sales: (val: number) => `$${val.toLocaleString()}`,
};
// Time window configuration (in milliseconds)
const TIME_WINDOW = 2 * 60 * 1000; // 2 minutes
const UPDATE_INTERVAL = 1000; // 1 second
const DATA_POINT_INTERVAL = 5000; // Show data points every 5 seconds
// Store previous values for smooth transitions
let previousValues = {
cpu: 30,
memory: 50,
download: 150,
upload: 30,
connections: 150
};
// Generate initial data points for time window
const generateInitialData = (baseValue: number, variance: number, interval: number = DATA_POINT_INTERVAL) => {
const data = [];
const now = Date.now();
const pointCount = Math.floor(TIME_WINDOW / interval);
for (let i = pointCount; i >= 0; i--) {
const timestamp = new Date(now - (i * interval)).toISOString();
const value = baseValue + (Math.random() - 0.5) * variance;
data.push({ x: timestamp, y: Math.round(value) });
}
return data;
};
// Different datasets to showcase
const datasets = {
system: {
label: 'System Usage (%)',
series: [
{
name: 'CPU',
data: generateInitialData(previousValues.cpu, 10),
},
{
name: 'Memory',
data: generateInitialData(previousValues.memory, 8),
},
],
},
network: {
label: 'Network Traffic (Mbps)',
series: [
{
name: 'Download',
data: generateInitialData(previousValues.download, 30),
},
{
name: 'Upload',
data: generateInitialData(previousValues.upload, 10),
},
],
},
sales: {
label: 'Sales Analytics',
series: [
{
name: 'Revenue',
data: [
{ x: '2025-01-01', y: 45000 },
{ x: '2025-01-02', y: 52000 },
{ x: '2025-01-03', y: 48000 },
{ x: '2025-01-04', y: 61000 },
{ x: '2025-01-05', y: 58000 },
{ x: '2025-01-06', y: 65000 },
],
},
{
name: 'Profit',
data: [
{ x: '2025-01-01', y: 12000 },
{ x: '2025-01-02', y: 14000 },
{ x: '2025-01-03', y: 11000 },
{ x: '2025-01-04', y: 18000 },
{ x: '2025-01-05', y: 16000 },
{ x: '2025-01-06', y: 20000 },
],
},
],
},
};
// Generate smooth value transitions
const getNextValue = (current: number, min: number, max: number, maxChange: number = 5) => {
// Add some randomness but keep it close to current value
const change = (Math.random() - 0.5) * maxChange * 2;
let newValue = current + change;
// Apply some "pressure" to move towards center of range
const center = (min + max) / 2;
const pressure = (center - newValue) * 0.1;
newValue += pressure;
// Ensure within bounds
newValue = Math.max(min, Math.min(max, newValue));
return Math.round(newValue);
};
// Track time of last data point
let lastDataPointTime = Date.now();
let connectionsLastUpdate = Date.now();
// Add real-time data
const addRealtimeData = () => {
if (!chartElement) return;
const now = Date.now();
// Only add new data point every DATA_POINT_INTERVAL
const shouldAddPoint = (now - lastDataPointTime) >= DATA_POINT_INTERVAL;
if (shouldAddPoint) {
lastDataPointTime = now;
const newTimestamp = new Date(now).toISOString();
// Generate smooth transitions for new values
if (currentDataset === 'system') {
// Generate new values
previousValues.cpu = getNextValue(previousValues.cpu, 20, 50, 3);
previousValues.memory = getNextValue(previousValues.memory, 40, 70, 2);
// Get current data and add new points
const currentSeries = chartElement.chartSeries.map((series, index) => ({
name: series.name,
data: [
...(series.data as Array<{x: any; y: any}>),
index === 0
? { x: newTimestamp, y: previousValues.cpu }
: { x: newTimestamp, y: previousValues.memory }
]
}));
chartElement.updateSeries(currentSeries, false);
} else if (currentDataset === 'network') {
// Generate new values
previousValues.download = getNextValue(previousValues.download, 100, 200, 10);
previousValues.upload = getNextValue(previousValues.upload, 20, 50, 5);
// Get current data and add new points
const currentSeries = chartElement.chartSeries.map((series, index) => ({
name: series.name,
data: [
...(series.data as Array<{x: any; y: any}>),
index === 0
? { x: newTimestamp, y: previousValues.download }
: { x: newTimestamp, y: previousValues.upload }
]
}));
chartElement.updateSeries(currentSeries, false);
}
}
};
// Update connections chart data
const updateConnections = () => {
if (!connectionsChartElement) return;
const now = Date.now();
const newTimestamp = new Date(now).toISOString();
// Generate new connections value with discrete changes
const change = Math.floor(Math.random() * 21) - 10; // -10 to +10 connections
previousValues.connections = Math.max(50, Math.min(300, previousValues.connections + change));
// Get current data and add new point
const currentSeries = connectionsChartElement.chartSeries;
const newData = [{
name: currentSeries[0]?.name || 'Connections',
data: [
...(currentSeries[0]?.data as Array<{x: any; y: any}> || []),
{ x: newTimestamp, y: previousValues.connections }
]
}];
connectionsChartElement.updateSeries(newData, false);
};
// Switch dataset
const switchDataset = (name: string) => {
currentDataset = name;
const dataset = datasets[name];
chartElement.label = dataset.label;
chartElement.series = dataset.series;
chartElement.yAxisFormatter = formatters[name];
// Set appropriate y-axis scaling
if (name === 'system') {
chartElement.yAxisScaling = 'percentage';
chartElement.yAxisMax = 100;
} else if (name === 'network') {
chartElement.yAxisScaling = 'dynamic';
} else {
chartElement.yAxisScaling = 'dynamic';
}
// Reset last data point time to get fresh data immediately
lastDataPointTime = Date.now() - DATA_POINT_INTERVAL;
};
// Start/stop real-time updates
const startRealtime = () => {
if (!intervalId && (currentDataset === 'system' || currentDataset === 'network')) {
chartElement.realtimeMode = true;
// Only add data every 5 seconds, chart auto-scrolls independently
intervalId = window.setInterval(() => addRealtimeData(), DATA_POINT_INTERVAL);
}
// Start connections updates
if (!connectionsIntervalId) {
connectionsChartElement.realtimeMode = true;
// Update connections every second
connectionsIntervalId = window.setInterval(() => updateConnections(), UPDATE_INTERVAL);
}
};
const stopRealtime = () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = null;
chartElement.realtimeMode = false;
}
// Stop connections updates
if (connectionsIntervalId) {
window.clearInterval(connectionsIntervalId);
connectionsIntervalId = null;
connectionsChartElement.realtimeMode = false;
}
};
// Randomize current data (spike/drop simulation)
const randomizeData = () => {
if (currentDataset === 'system') {
// Simulate CPU/Memory spike
previousValues.cpu = Math.random() > 0.5 ? 85 : 25;
previousValues.memory = Math.random() > 0.5 ? 80 : 45;
} else if (currentDataset === 'network') {
// Simulate network traffic spike
previousValues.download = Math.random() > 0.5 ? 250 : 100;
previousValues.upload = Math.random() > 0.5 ? 80 : 20;
}
// Also spike connections
previousValues.connections = Math.random() > 0.5 ? 280 : 80;
// Force immediate update by resetting timers
lastDataPointTime = 0;
connectionsLastUpdate = 0;
};
// Wire up button click handlers
const buttons = elementArg.querySelectorAll('dees-button');
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'System Usage') {
button.addEventListener('click', () => switchDataset('system'));
} else if (text === 'Network Traffic') {
button.addEventListener('click', () => switchDataset('network'));
} else if (text === 'Sales Data') {
button.addEventListener('click', () => switchDataset('sales'));
} else if (text === 'Start Live') {
button.addEventListener('click', () => startRealtime());
} else if (text === 'Stop Live') {
button.addEventListener('click', () => stopRealtime());
} else if (text === 'Spike Values') {
button.addEventListener('click', () => randomizeData());
}
});
// Update button states based on current dataset
const updateButtonStates = () => {
const buttons = elementArg.querySelectorAll('dees-button');
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'System Usage') {
button.type = currentDataset === 'system' ? 'highlighted' : 'normal';
} else if (text === 'Network Traffic') {
button.type = currentDataset === 'network' ? 'highlighted' : 'normal';
} else if (text === 'Sales Data') {
button.type = currentDataset === 'sales' ? 'highlighted' : 'normal';
}
});
};
// Configure main chart with rolling window
chartElement.rollingWindow = TIME_WINDOW;
chartElement.realtimeMode = false; // Will be enabled when starting live updates
chartElement.yAxisScaling = 'percentage'; // Initial system dataset uses percentage
chartElement.yAxisMax = 100;
chartElement.autoScrollInterval = 1000; // Auto-scroll every second
// Set initial time window
setTimeout(() => {
chartElement.updateTimeWindow();
}, 100);
// Update button states when dataset changes
const originalSwitchDataset = switchDataset;
const switchDatasetWithButtonUpdate = (name: string) => {
originalSwitchDataset(name);
updateButtonStates();
};
// Replace switchDataset with the one that updates buttons
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'System Usage') {
button.removeEventListener('click', () => switchDataset('system'));
button.addEventListener('click', () => switchDatasetWithButtonUpdate('system'));
} else if (text === 'Network Traffic') {
button.removeEventListener('click', () => switchDataset('network'));
button.addEventListener('click', () => switchDatasetWithButtonUpdate('network'));
} else if (text === 'Sales Data') {
button.removeEventListener('click', () => switchDataset('sales'));
button.addEventListener('click', () => switchDatasetWithButtonUpdate('sales'));
}
});
// Initialize connections chart with data
if (connectionsChartElement) {
const initialConnectionsData = generateInitialData(previousValues.connections, 30, UPDATE_INTERVAL);
connectionsChartElement.series = [{
name: 'Connections',
data: initialConnectionsData
}];
// Configure connections chart
connectionsChartElement.rollingWindow = TIME_WINDOW;
connectionsChartElement.realtimeMode = false; // Will be enabled when starting live updates
connectionsChartElement.yAxisScaling = 'fixed';
connectionsChartElement.yAxisMax = 350;
connectionsChartElement.autoScrollInterval = 1000; // Auto-scroll every second
// Set initial time window
setTimeout(() => {
connectionsChartElement.updateTimeWindow();
}, 100);
}
}}>
<style>
${css`
.demoBox {
position: relative;
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
height: 100%;
width: 100%;
padding: 40px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 24px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
}
.chart-container {
flex: 1;
min-height: 400px;
}
.info {
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Geist Sans', sans-serif;
text-align: center;
margin-top: 8px;
}
`}
</style>
<div class="demoBox">
<dees-chart-area
.label=${'System Usage'}
></dees-chart-area>
<div class="controls">
<dees-button-group label="Dataset:">
<dees-button type="highlighted">System Usage</dees-button>
<dees-button>Network Traffic</dees-button>
<dees-button>Sales Data</dees-button>
</dees-button-group>
<dees-button-group label="Real-time:">
<dees-button>Start Live</dees-button>
<dees-button>Stop Live</dees-button>
</dees-button-group>
<dees-button-group label="Actions:">
<dees-button>Spike Values</dees-button>
</dees-button-group>
</div>
<div class="chart-container">
<dees-chart-area
id="main-chart"
.label=${initialDatasets.system.label}
.series=${initialDatasets.system.series}
.yAxisFormatter=${initialFormatters.system}
></dees-chart-area>
</div>
<div class="chart-container" style="margin-top: 20px;">
<dees-chart-area
id="connections-chart"
.label=${'Active Connections'}
.series=${[{
name: 'Connections',
data: [] as Array<{x: any; y: any}>
}]}
.yAxisFormatter=${(val: number) => `${val}`}
></dees-chart-area>
</div>
<div class="info">
Real-time monitoring with 2-minute rolling window •
Updates every second with smooth value transitions •
Click 'Spike Values' to simulate load spikes
</div>
</div>
</dees-demowrapper>
`;
};

View File

@ -6,7 +6,6 @@ import {
html,
property,
state,
type CSSResult,
type TemplateResult,
} from '@design.estate/dees-element';
@ -32,47 +31,133 @@ export class DeesChartArea extends DeesElement {
@property()
public label: string = 'Untitled Chart';
@property({ type: Array })
public series: ApexAxisChartSeries = [];
// Override getter to return internal chart data
get chartSeries(): ApexAxisChartSeries {
return this.internalChartData.length > 0 ? this.internalChartData : this.series;
}
@property({ attribute: false })
public yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`;
@property({ type: Number })
public rollingWindow: number = 0; // 0 means no rolling window
@property({ type: Boolean })
public realtimeMode: boolean = false;
@property({ type: String })
public yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic';
@property({ type: Number })
public yAxisMax: number = 100; // Used when yAxisScaling is 'fixed' or 'percentage'
@property({ type: Number })
public autoScrollInterval: number = 1000; // Auto-scroll interval in milliseconds (0 to disable)
private resizeObserver: ResizeObserver;
private resizeTimeout: number;
private internalChartData: ApexAxisChartSeries = [];
private autoScrollTimer: number | null = null;
private readonly DEBUG_RESIZE = false; // Set to true to enable resize debugging
// Chart color schemes
private readonly CHART_COLORS = {
dark: [
'hsl(217.2 91.2% 59.8%)', // Blue
'hsl(173.4 80.4% 40%)', // Teal
'hsl(280.3 87.4% 66.7%)', // Purple
'hsl(24.6 95% 53.1%)', // Orange
],
light: [
'hsl(222.2 47.4% 51.2%)', // Blue (shadcn primary)
'hsl(142.1 76.2% 36.3%)', // Green (shadcn success)
'hsl(280.3 47.7% 50.2%)', // Purple (muted)
'hsl(20.5 90.2% 48.2%)', // Orange (shadcn destructive variant)
]
};
constructor() {
super();
domtools.elementBasic.setup();
this.resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
if (entry.target.classList.contains('mainbox')) {
this.resizeChart(); // Call resizeChart when the .mainbox size changes
// Debounce resize calls to prevent excessive updates
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeTimeout = window.setTimeout(() => {
// Simply resize if we have a chart, since we're only observing the mainbox
if (this.chart) {
// Log resize event for debugging
if (this.DEBUG_RESIZE && entries.length > 0) {
const entry = entries[0];
console.log('DeesChartArea - Resize detected:', {
width: entry.contentRect.width,
height: entry.contentRect.height
});
}
this.resizeChart();
}
}, 100); // 100ms debounce
});
// Note: ResizeObserver is now set up after chart initialization in firstUpdated()
// to ensure proper timing and avoid race conditions
this.registerGarbageFunction(async () => {
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.stopAutoScroll();
// Critical: Destroy chart instance to prevent memory leak
if (this.chart) {
try {
this.chart.destroy();
this.chart = null;
} catch (error) {
console.error('Error destroying chart:', error);
}
}
});
this.registerStartupFunction(async () => {
this.updateComplete.then(() => {
const mainbox = this.shadowRoot.querySelector('.mainbox');
if (mainbox) {
this.resizeObserver.observe(mainbox); // Start observing the .mainbox element
}
});
});
this.registerGarbageFunction(async () => {
this.resizeObserver.disconnect();
});
}
public async connectedCallback() {
super.connectedCallback();
// Trigger resize when element is connected to DOM
// This helps with dynamically added charts
if (this.chart) {
// Wait a frame for layout to settle
await new Promise(resolve => requestAnimationFrame(resolve));
await this.resizeChart();
}
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
font-family: 'Geist Sans', sans-serif;
color: #ccc;
font-weight: 600;
font-size: 12px;
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: #111;
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 {
@ -80,8 +165,13 @@ export class DeesChartArea extends DeesElement {
top: 0;
left: 0;
width: 100%;
text-align: center;
padding-top: 16px;
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;
@ -89,7 +179,22 @@ export class DeesChartArea extends DeesElement {
left: 0px;
bottom: 0px;
right: 0px;
padding: 32px 16px 16px 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;
}
`,
];
@ -104,52 +209,107 @@ export class DeesChartArea extends DeesElement {
}
public async firstUpdated() {
const domtoolsInstance = await this.domtoolsPromise;
await this.domtoolsPromise;
// Wait for next animation frame to ensure layout is complete
await new Promise(resolve => requestAnimationFrame(resolve));
// Get actual dimensions of the container
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
if (!mainbox || !chartContainer) {
console.error('Chart containers not found');
return;
}
// Calculate initial dimensions
const styleChartContainer = window.getComputedStyle(chartContainer);
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
const initialWidth = mainbox.clientWidth - paddingLeft - paddingRight;
const initialHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
// Use provided series data or default demo data
const chartSeries = this.series.length > 0 ? this.series : [
{
name: 'cpu',
data: [
{ x: '2025-01-15T03:00:00', y: 25 },
{ x: '2025-01-15T07:00:00', y: 30 },
{ x: '2025-01-15T11:00:00', y: 20 },
{ x: '2025-01-15T15:00:00', y: 35 },
{ x: '2025-01-15T19:00:00', y: 25 },
],
},
{
name: 'memory',
data: [
{ x: '2025-01-15T03:00:00', y: 10 },
{ x: '2025-01-15T07:00:00', y: 12 },
{ x: '2025-01-15T11:00:00', y: 10 },
{ x: '2025-01-15T15:00:00', y: 30 },
{ x: '2025-01-15T19:00:00', y: 40 },
],
},
];
// Store internal data
this.internalChartData = chartSeries;
// Get current theme
const isDark = !this.goBright;
const theme = isDark ? 'dark' : 'light';
var options: ApexCharts.ApexOptions = {
series: [
{
name: 'cpu',
data: [
{ x: '2025-01-15T03:00:00', y: 25 },
{ x: '2025-01-15T07:00:00', y: 30 },
{ x: '2025-01-15T11:00:00', y: 20 },
{ x: '2025-01-15T15:00:00', y: 35 },
{ x: '2025-01-15T19:00:00', y: 25 },
],
},
{
name: 'memory',
data: [
{ x: '2025-01-15T03:00:00', y: 10 },
{ x: '2025-01-15T07:00:00', y: 12 },
{ x: '2025-01-15T11:00:00', y: 10 },
{ x: '2025-01-15T15:00:00', y: 30 },
{ x: '2025-01-15T19:00:00', y: 40 },
],
},
],
series: chartSeries,
chart: {
width: 0, // Adjusted for responsive width
height: 0, // Adjusted for responsive height
width: initialWidth || 100, // Use actual width or fallback
height: initialHeight || 100, // Use actual height or fallback
type: 'area',
background: 'transparent', // Transparent background to inherit from container
toolbar: {
show: false, // This line disables the toolbar
},
animations: {
enabled: !this.realtimeMode, // Disable animations in realtime mode
speed: 400,
animateGradually: {
enabled: false, // Disable gradual animation for cleaner updates
delay: 0
},
dynamicAnimation: {
enabled: !this.realtimeMode,
speed: 350
}
},
zoom: {
enabled: false, // Disable zoom for cleaner interaction
},
selection: {
enabled: false, // Disable selection
},
},
dataLabels: {
enabled: false,
},
stroke: {
width: 1,
width: 2,
curve: 'smooth',
},
xaxis: {
type: 'datetime', // Time-series data
labels: {
format: 'hh:mm A', // Time formatting
format: 'HH:mm:ss', // Time formatting with seconds
datetimeUTC: false,
style: {
colors: '#9e9e9e', // Label color
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], // Label color
fontSize: '12px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontWeight: '400',
},
},
axisBorder: {
@ -161,13 +321,14 @@ export class DeesChartArea extends DeesElement {
},
yaxis: {
min: 0,
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax,
labels: {
formatter: function (val: number) {
return `${val} Mbps`; // Format Y-axis labels
},
formatter: this.yAxisFormatter,
style: {
colors: '#9e9e9e', // Label color
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], // Label color
fontSize: '12px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontWeight: '400',
},
},
axisBorder: {
@ -184,17 +345,30 @@ export class DeesChartArea extends DeesElement {
x: {
format: 'dd/MM/yy HH:mm',
},
custom: function ({ series, seriesIndex, dataPointIndex, w }) {
// Get the x value
const xValue = w.globals.labels[dataPointIndex];
custom: ({ series, dataPointIndex, w }: any) => {
// Iterate through each series and get its value
let tooltipContent = `<div style="padding: 10px; background: #1e1e2f; color: white; border-radius: 5px;">`;
tooltipContent += ``; // `<strong>Time:</strong> ${xValue}<br/>`;
// Note: We can't access component instance here, so we'll use w.config.theme.mode
const currentTheme = w.config.theme.mode;
const isDarkMode = currentTheme === 'dark';
const bgColor = isDarkMode ? 'hsl(0 0% 9%)' : 'hsl(0 0% 100%)';
const textColor = isDarkMode ? 'hsl(0 0% 95%)' : 'hsl(0 0% 9%)';
const borderColor = isDarkMode ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 89.8%)';
series.forEach((s, index) => {
// Get formatter from chart config
const formatter = w.config.yaxis[0]?.labels?.formatter || ((val: number) => val.toString());
let tooltipContent = `<div style="padding: 12px; background: ${bgColor}; color: ${textColor}; border-radius: 6px; box-shadow: 0 2px 8px 0 hsl(0 0% 0% / ${isDarkMode ? '0.2' : '0.1'}); border: 1px solid ${borderColor};font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px;">`;
series.forEach((s: number[], index: number) => {
const label = w.globals.seriesNames[index]; // Get series label
const value = s[dataPointIndex]; // Get value at data point
tooltipContent += `<strong>${label}:</strong> ${value} Mbps<br/>`;
const color = w.globals.colors[index];
const formattedValue = formatter(value);
tooltipContent += `<div style="display: flex; align-items: center; gap: 8px; margin: ${index > 0 ? '6px' : '0'} 0;">
<span style="display: inline-block; width: 10px; height: 10px; background: ${color}; border-radius: 2px;"></span>
<span style="font-weight: 500;">${label}:</span>
<span style="margin-left: auto; font-weight: 600;">${formattedValue}</span>
</div>`;
});
tooltipContent += `</div>`;
@ -204,7 +378,7 @@ export class DeesChartArea extends DeesElement {
grid: {
xaxis: {
lines: {
show: true, // This enables the grid lines along the x-axis
show: false, // Hide vertical grid lines for cleaner look
},
},
yaxis: {
@ -212,54 +386,341 @@ export class DeesChartArea extends DeesElement {
show: true,
},
},
borderColor: '#333', // Set the color of the grid lines
borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)', // Very subtle grid lines
strokeDashArray: 0, // Solid line
row: {
colors: [], // This can be used to alternate the shading of the horizontal rows
opacity: 0.1,
},
column: {
colors: [], // For vertical column bands, not needed here but available for customization
opacity: 0.1,
padding: {
top: 10,
right: 20,
bottom: 10,
left: 20,
},
},
fill: {
type: 'gradient', // Gradient fill for the area
gradient: {
shade: 'dark',
shade: isDark ? 'dark' : 'light',
type: 'vertical',
gradientToColors: ['#9c27b0'], // Gradient color ending
shadeIntensity: 0.1,
opacityFrom: isDark ? 0.2 : 0.3,
opacityTo: 0,
stops: [0, 100],
},
},
colors: isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light,
theme: {
mode: theme,
},
};
this.chart = new ApexCharts(this.shadowRoot.querySelector('.chartContainer'), options);
await this.chart.render();
await this.resizeChart();
try {
this.chart = new ApexCharts(this.shadowRoot.querySelector('.chartContainer'), options);
await this.chart.render();
// Give the chart a moment to fully initialize before resizing
await new Promise(resolve => setTimeout(resolve, 100));
await this.resizeChart();
// Ensure resize observer is watching the mainbox
const mainbox = this.shadowRoot.querySelector('.mainbox');
if (mainbox && this.resizeObserver) {
// Disconnect any previous observations
this.resizeObserver.disconnect();
// Start observing the mainbox
this.resizeObserver.observe(mainbox);
if (this.DEBUG_RESIZE) {
console.log('DeesChartArea - ResizeObserver attached to mainbox');
}
}
} catch (error) {
console.error('Failed to initialize chart:', error);
// Optionally, you could set an error state here
// this.chartState = 'error';
// this.errorMessage = 'Failed to initialize chart';
}
}
public async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
// Update chart theme when goBright changes
if (changedProperties.has('goBright') && this.chart) {
await this.updateChartTheme();
}
// Update chart if series data changes
if (changedProperties.has('series') && this.chart && this.series.length > 0) {
await this.updateSeries(this.series);
}
// Update y-axis formatter if it changes
if (changedProperties.has('yAxisFormatter') && this.chart) {
await this.chart.updateOptions({
yaxis: {
labels: {
formatter: this.yAxisFormatter,
},
},
});
}
// Handle realtime mode changes
if (changedProperties.has('realtimeMode') && this.chart) {
await this.chart.updateOptions({
chart: {
animations: {
enabled: !this.realtimeMode,
speed: 400,
animateGradually: {
enabled: false,
delay: 0
},
dynamicAnimation: {
enabled: !this.realtimeMode,
speed: 350
}
}
}
});
// Start/stop auto-scroll based on realtime mode
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
this.startAutoScroll();
} else {
this.stopAutoScroll();
}
}
// Handle auto-scroll interval changes
if (changedProperties.has('autoScrollInterval') && this.chart) {
this.stopAutoScroll();
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
this.startAutoScroll();
}
}
// Handle y-axis scaling changes
if ((changedProperties.has('yAxisScaling') || changedProperties.has('yAxisMax')) && this.chart) {
await this.chart.updateOptions({
yaxis: {
min: 0,
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax
}
});
}
}
public async updateSeries(newSeries: ApexAxisChartSeries, animate: boolean = true) {
if (!this.chart) {
return;
}
try {
// Store the new data first
this.internalChartData = newSeries;
// Handle rolling window if enabled
if (this.rollingWindow > 0 && this.realtimeMode) {
const now = Date.now();
const cutoffTime = now - this.rollingWindow;
// Filter data to only include points within the rolling window
const filteredSeries = newSeries.map(series => ({
name: series.name,
data: (series.data as any[]).filter(point => {
if (typeof point === 'object' && point !== null && 'x' in point) {
return new Date(point.x).getTime() > cutoffTime;
}
return false;
})
}));
// Only update if we have data
if (filteredSeries.some(s => s.data.length > 0)) {
// Handle y-axis scaling first
if (this.yAxisScaling === 'dynamic') {
const allValues = filteredSeries.flatMap(s => (s.data as any[]).map(d => d.y));
if (allValues.length > 0) {
const maxValue = Math.max(...allValues);
const dynamicMax = Math.ceil(maxValue * 1.1);
await this.chart.updateOptions({
yaxis: {
min: 0,
max: dynamicMax
}
}, false, false);
}
}
await this.chart.updateSeries(filteredSeries, false);
}
} else {
await this.chart.updateSeries(newSeries, animate);
}
} catch (error) {
console.error('Failed to update chart series:', error);
}
}
// Update just the x-axis for smooth scrolling in realtime mode
// Public for advanced usage in demos, but typically handled automatically
public async updateTimeWindow() {
if (!this.chart || this.rollingWindow <= 0) {
return;
}
const now = Date.now();
const cutoffTime = now - this.rollingWindow;
await this.chart.updateOptions({
xaxis: {
min: cutoffTime,
max: now,
labels: {
format: 'HH:mm:ss',
datetimeUTC: false,
style: {
colors: [!this.goBright ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
fontSize: '12px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontWeight: '400',
},
},
tickAmount: 6,
}
}, false, false);
}
public async appendData(newData: { data: any[] }[]) {
if (!this.chart) {
return;
}
// Use ApexCharts' appendData method for smoother real-time updates
this.chart.appendData(newData);
}
public async updateOptions(options: ApexCharts.ApexOptions, redrawPaths?: boolean, animate?: boolean) {
if (!this.chart) {
return;
}
return this.chart.updateOptions(options, redrawPaths, animate);
}
public async resizeChart() {
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
if (!this.chart) {
return;
}
// Get computed style of the element
const styleMainbox = window.getComputedStyle(mainbox);
const styleChartContainer = window.getComputedStyle(chartContainer);
if (this.DEBUG_RESIZE) {
console.log('DeesChartArea - resizeChart called');
}
// Extract padding values
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
try {
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
// Calculate the actual width and height to use, subtracting padding
const actualWidth = mainbox.clientWidth - paddingLeft - paddingRight;
const actualHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
if (!mainbox || !chartContainer) {
return;
}
// Force layout recalculation
void mainbox.offsetHeight;
// Get computed style of the element
const styleChartContainer = window.getComputedStyle(chartContainer);
// Extract padding values
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
// Calculate the actual width and height to use, subtracting padding
const actualWidth = mainbox.clientWidth - paddingLeft - paddingRight;
const actualHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
// Validate dimensions
if (actualWidth > 0 && actualHeight > 0) {
if (this.DEBUG_RESIZE) {
console.log('DeesChartArea - Updating chart dimensions:', {
width: actualWidth,
height: actualHeight
});
}
await this.chart.updateOptions({
chart: {
width: actualWidth,
height: actualHeight,
},
}, true, false); // Redraw paths but don't animate
}
} catch (error) {
console.error('Failed to resize chart:', error);
}
}
/**
* Manually trigger a chart resize. Useful when automatic detection doesn't work.
* This is a convenience method that can be called from outside the component.
*/
public async forceResize() {
await this.resizeChart();
}
private startAutoScroll() {
if (this.autoScrollTimer) {
return; // Already running
}
this.autoScrollTimer = window.setInterval(() => {
this.updateTimeWindow();
}, this.autoScrollInterval);
}
private stopAutoScroll() {
if (this.autoScrollTimer) {
window.clearInterval(this.autoScrollTimer);
this.autoScrollTimer = null;
}
}
private async updateChartTheme() {
if (!this.chart) {
return;
}
const isDark = !this.goBright;
const theme = isDark ? 'dark' : 'light';
await this.chart.updateOptions({
chart: {
width: actualWidth,
height: actualHeight,
theme: {
mode: theme,
},
colors: isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light,
xaxis: {
labels: {
style: {
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
},
},
},
yaxis: {
labels: {
style: {
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
},
},
},
grid: {
borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)',
},
fill: {
gradient: {
shade: isDark ? 'dark' : 'light',
opacityFrom: isDark ? 0.2 : 0.3,
},
},
});
}

View File

@ -1,20 +1,170 @@
import { html } from '@design.estate/dees-element';
import type { DeesChartLog } from './dees-chart-log.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
return html`
<style>
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// Get the log element
const logElement = elementArg.querySelector('dees-chart-log') as DeesChartLog;
let intervalId: number;
const serverSources = ['Server', 'Database', 'API', 'Auth', 'Cache', 'Queue', 'WebSocket', 'Scheduler'];
const logTemplates = {
debug: [
'Loading module: {{module}}',
'Cache hit for key: {{key}}',
'SQL query executed in {{time}}ms',
'Request headers: {{headers}}',
'Environment variable loaded: {{var}}',
],
info: [
'Request received: {{method}} {{path}}',
'User {{userId}} authenticated successfully',
'Processing job {{jobId}} from queue',
'Scheduled task "{{task}}" started',
'WebSocket connection established from {{ip}}',
],
warn: [
'Slow query detected: {{query}} ({{time}}ms)',
'Memory usage at {{percent}}%',
'Rate limit approaching for IP {{ip}}',
'Deprecated API endpoint called: {{endpoint}}',
'Certificate expires in {{days}} days',
],
error: [
'Database connection lost: {{error}}',
'Failed to process request: {{error}}',
'Authentication failed for user {{user}}',
'File not found: {{path}}',
'Service unavailable: {{service}}',
],
success: [
'Server started successfully on port {{port}}',
'Database migration completed',
'Backup completed: {{size}} MB',
'SSL certificate renewed',
'Health check passed: all systems operational',
],
};
const generateRandomLog = () => {
const levels: Array<'debug' | 'info' | 'warn' | 'error' | 'success'> = ['debug', 'info', 'warn', 'error', 'success'];
const weights = [0.2, 0.5, 0.15, 0.1, 0.05]; // Weighted probability
const random = Math.random();
let cumulative = 0;
let level: typeof levels[0] = 'info';
for (let i = 0; i < weights.length; i++) {
cumulative += weights[i];
if (random < cumulative) {
level = levels[i];
break;
}
}
const source = serverSources[Math.floor(Math.random() * serverSources.length)];
const templates = logTemplates[level];
const template = templates[Math.floor(Math.random() * templates.length)];
// Replace placeholders with random values
const message = template
.replace('{{module}}', ['express', 'mongoose', 'redis', 'socket.io'][Math.floor(Math.random() * 4)])
.replace('{{key}}', 'user:' + Math.floor(Math.random() * 1000))
.replace('{{time}}', String(Math.floor(Math.random() * 500) + 50))
.replace('{{headers}}', 'Content-Type: application/json, Authorization: Bearer ...')
.replace('{{var}}', ['NODE_ENV', 'DATABASE_URL', 'API_KEY', 'PORT'][Math.floor(Math.random() * 4)])
.replace('{{method}}', ['GET', 'POST', 'PUT', 'DELETE'][Math.floor(Math.random() * 4)])
.replace('{{path}}', ['/api/users', '/api/auth/login', '/api/products', '/health'][Math.floor(Math.random() * 4)])
.replace('{{userId}}', String(Math.floor(Math.random() * 10000)))
.replace('{{jobId}}', 'job_' + Math.random().toString(36).substring(2, 11))
.replace('{{task}}', ['cleanup', 'backup', 'report-generation', 'cache-refresh'][Math.floor(Math.random() * 4)])
.replace('{{ip}}', `192.168.1.${Math.floor(Math.random() * 255)}`)
.replace('{{query}}', 'SELECT * FROM users WHERE ...')
.replace('{{percent}}', String(Math.floor(Math.random() * 30) + 70))
.replace('{{endpoint}}', '/api/v1/legacy')
.replace('{{days}}', String(Math.floor(Math.random() * 30) + 1))
.replace('{{error}}', ['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND'][Math.floor(Math.random() * 3)])
.replace('{{user}}', 'user_' + Math.floor(Math.random() * 1000))
.replace('{{service}}', ['Redis', 'MongoDB', 'ElasticSearch'][Math.floor(Math.random() * 3)])
.replace('{{port}}', String(3000 + Math.floor(Math.random() * 10)))
.replace('{{size}}', String(Math.floor(Math.random() * 500) + 100));
logElement.addLog(level, message, source);
};
const startSimulation = () => {
if (!intervalId) {
// Generate logs at random intervals between 500ms and 2500ms
const scheduleNext = () => {
generateRandomLog();
const nextDelay = Math.random() * 2000 + 500;
intervalId = window.setTimeout(() => {
if (intervalId) {
scheduleNext();
}
}, nextDelay);
};
scheduleNext();
}
};
const stopSimulation = () => {
if (intervalId) {
window.clearTimeout(intervalId);
intervalId = null;
}
};
// Wire up button click handlers
const buttons = elementArg.querySelectorAll('dees-button');
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'Add Single Log') {
button.addEventListener('click', () => generateRandomLog());
} else if (text === 'Start Simulation') {
button.addEventListener('click', () => startSimulation());
} else if (text === 'Stop Simulation') {
button.addEventListener('click', () => stopSimulation());
}
});
}}>
<style>
.demoBox {
position: relative;
background: #000000;
height: 100%;
width: 100%;
padding: 40px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 20px;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.info {
color: #888;
font-size: 12px;
font-family: 'Geist Sans', sans-serif;
}
</style>
<div class="demoBox">
<div class="controls">
<dees-button>Add Single Log</dees-button>
<dees-button>Start Simulation</dees-button>
<dees-button>Stop Simulation</dees-button>
</div>
<div class="info">Simulating realistic server logs with various levels and sources</div>
<dees-chart-log
.label=${'Event Log'}
.label=${'Production Server Logs'}
></dees-chart-log>
</div>
</dees-demowrapper>
`;
};

View File

@ -5,15 +5,12 @@ import {
customElement,
html,
property,
state,
type CSSResult,
type TemplateResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-chart-log.demo.js';
import ApexCharts from 'apexcharts';
declare global {
interface HTMLElementTagNameMap {
@ -21,69 +18,309 @@ declare global {
}
}
export interface ILogEntry {
timestamp: string;
level: 'debug' | 'info' | 'warn' | 'error' | 'success';
message: string;
source?: string;
}
@customElement('dees-chart-log')
export class DeesChartLog extends DeesElement {
public static demo = demoFunc;
// instance
@state()
public chart: ApexCharts;
@property()
public label: string = 'Untitled Chart';
public label: string = 'Server Logs';
@property({ type: Array })
public logEntries: ILogEntry[] = [];
@property({ type: Boolean })
public autoScroll: boolean = true;
@property({ type: Number })
public maxEntries: number = 1000;
private logContainer: HTMLDivElement;
constructor() {
super();
domtools.elementBasic.setup();
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
font-family: 'Geist Sans', sans-serif;
color: #ccc;
font-weight: 600;
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-size: 12px;
line-height: 1.5;
}
.mainbox {
position: relative;
width: 100%;
height: 400px;
background: #222;
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;
padding: 32px 16px 16px 0px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chartTitle {
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: center;
padding-top: 16px;
.header {
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
padding: 12px 16px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.chartContainer {
position: relative;
width: 100%;
.title {
font-weight: 500;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.controls {
display: flex;
gap: 8px;
}
.control-button {
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
padding: 6px 12px;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.control-button: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%)')};
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.control-button.active {
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 93.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')};
}
.logContainer {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
font-size: 12px;
}
.logEntry {
margin-bottom: 4px;
display: flex;
white-space: pre-wrap;
word-break: break-all;
font-variant-numeric: tabular-nums;
}
.timestamp {
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
margin-right: 12px;
flex-shrink: 0;
}
.level {
margin-right: 8px;
padding: 0 6px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
font-size: 10px;
flex-shrink: 0;
}
.level.debug {
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
background: ${cssManager.bdTheme('hsl(0 0% 45.1% / 0.1)', 'hsl(0 0% 63.9% / 0.1)')};
}
.level.info {
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
}
.level.warn {
color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
background: ${cssManager.bdTheme('hsl(25 95% 53% / 0.1)', 'hsl(25 95% 63% / 0.1)')};
}
.level.error {
color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.1)', 'hsl(0 72.2% 50.6% / 0.1)')};
}
.level.success {
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
}
.source {
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
margin-right: 8px;
flex-shrink: 0;
}
.message {
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
flex: 1;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
font-style: italic;
}
/* Custom scrollbar */
.logContainer::-webkit-scrollbar {
width: 8px;
}
.logContainer::-webkit-scrollbar-track {
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 10%)')};
}
.logContainer::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 30%)')};
border-radius: 4px;
}
.logContainer::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 40%)')};
}
`,
];
public render(): TemplateResult {
return html` <div class="mainbox">
<div class="chartTitle">${this.label}</div>
<div class="chartContainer"></div>
</div> `;
return html`
<div class="mainbox">
<div class="header">
<div class="title">${this.label}</div>
<div class="controls">
<button
class="control-button ${this.autoScroll ? 'active' : ''}"
@click=${() => { this.autoScroll = !this.autoScroll; }}
>
Auto Scroll
</button>
<button
class="control-button"
@click=${() => { this.clearLogs(); }}
>
Clear
</button>
</div>
</div>
<div class="logContainer">
${this.logEntries.length === 0
? html`<div class="empty-state">No logs to display</div>`
: this.logEntries.map(entry => this.renderLogEntry(entry))
}
</div>
</div>
`;
}
private renderLogEntry(entry: ILogEntry): TemplateResult {
const timestamp = new Date(entry.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
return html`
<div class="logEntry">
<span class="timestamp">${timestamp}</span>
<span class="level ${entry.level}">${entry.level}</span>
${entry.source ? html`<span class="source">[${entry.source}]</span>` : ''}
<span class="message">${entry.message}</span>
</div>
`;
}
public async firstUpdated() {
const domtoolsInstance = await this.domtoolsPromise;
await this.domtoolsPromise;
this.logContainer = this.shadowRoot.querySelector('.logContainer');
// Initialize with demo server logs
const demoLogs: ILogEntry[] = [
{ timestamp: new Date().toISOString(), level: 'info', message: 'Server started on port 3000', source: 'Server' },
{ timestamp: new Date().toISOString(), level: 'debug', message: 'Loading configuration from /etc/app/config.json', source: 'Config' },
{ timestamp: new Date().toISOString(), level: 'info', message: 'Connected to MongoDB at mongodb://localhost:27017', source: 'Database' },
{ timestamp: new Date().toISOString(), level: 'success', message: 'Database connection established successfully', source: 'Database' },
{ timestamp: new Date().toISOString(), level: 'warn', message: 'No SSL certificate found, using self-signed certificate', source: 'Security' },
{ timestamp: new Date().toISOString(), level: 'info', message: 'API routes initialized: GET /api/users, POST /api/users, DELETE /api/users/:id', source: 'Router' },
{ timestamp: new Date().toISOString(), level: 'debug', message: 'Middleware stack: cors, bodyParser, authentication, errorHandler', source: 'Middleware' },
{ timestamp: new Date().toISOString(), level: 'info', message: 'WebSocket server listening on ws://localhost:3001', source: 'WebSocket' },
];
this.logEntries = demoLogs;
this.scrollToBottom();
}
public async updateLog() {
public async updateLog(entries?: ILogEntry[]) {
if (entries) {
// Add new entries
this.logEntries = [...this.logEntries, ...entries];
// Trim if exceeds max entries
if (this.logEntries.length > this.maxEntries) {
this.logEntries = this.logEntries.slice(-this.maxEntries);
}
// Trigger re-render
this.requestUpdate();
// Auto-scroll if enabled
await this.updateComplete;
if (this.autoScroll) {
this.scrollToBottom();
}
}
}
public clearLogs() {
this.logEntries = [];
this.requestUpdate();
}
private scrollToBottom() {
if (this.logContainer) {
this.logContainer.scrollTop = this.logContainer.scrollHeight;
}
}
public addLog(level: ILogEntry['level'], message: string, source?: string) {
const newEntry: ILogEntry = {
timestamp: new Date().toISOString(),
level,
message,
source
};
this.updateLog([newEntry]);
}
}

View File

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

View File

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

View File

@ -9,49 +9,207 @@ export const demoFunc = () => html`
display: block;
margin: 20px;
}
.demo-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
min-height: 400px;
}
.demo-area {
padding: 40px;
border-radius: 8px;
text-align: center;
cursor: context-menu;
transition: background 0.2s;
}
.demo-area:hover {
background: rgba(0, 0, 0, 0.02);
}
</style>
<dees-button @contextmenu=${(eventArg) => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
{
name: 'copy',
iconName: 'copySolid',
action: async () => {
return null;
},
},
{
name: 'edit',
iconName: 'penToSquare',
action: async () => {
return null;
},
},{
name: 'paste',
iconName: 'pasteSolid',
action: async () => {
return null;
},
},
]);
}}>Right-Click for contextmenu</dees-button>
<dees-contextmenu class="withMargin"></dees-contextmenu>
<dees-contextmenu
class="withMargin"
.menuItems=${[
{
name: 'copy',
iconName: 'copySolid',
action: async () => {},
},
{
name: 'edit',
iconName: 'penToSquare',
action: async () => {},
},{
name: 'paste',
iconName: 'pasteSolid',
action: async () => {},
},
] as plugins.tsclass.website.IMenuItem[]}
></dees-contextmenu>
<div class="demo-container">
<dees-panel heading="Basic Context Menu with Nested Submenus">
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
{
name: 'File',
iconName: 'fileText',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'New', iconName: 'filePlus', shortcut: 'Cmd+N', action: async () => console.log('New file') },
{ name: 'Open', iconName: 'folderOpen', shortcut: 'Cmd+O', action: async () => console.log('Open file') },
{ name: 'Save', iconName: 'save', shortcut: 'Cmd+S', action: async () => console.log('Save') },
{ divider: true },
{ name: 'Export as PDF', iconName: 'download', action: async () => console.log('Export PDF') },
{ name: 'Export as HTML', iconName: 'code', action: async () => console.log('Export HTML') },
]
},
{
name: 'Edit',
iconName: 'edit3',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'Cut', iconName: 'scissors', shortcut: 'Cmd+X', action: async () => console.log('Cut') },
{ name: 'Copy', iconName: 'copy', shortcut: 'Cmd+C', action: async () => console.log('Copy') },
{ name: 'Paste', iconName: 'clipboard', shortcut: 'Cmd+V', action: async () => console.log('Paste') },
{ divider: true },
{ name: 'Find', iconName: 'search', shortcut: 'Cmd+F', action: async () => console.log('Find') },
{ name: 'Replace', iconName: 'repeat', shortcut: 'Cmd+H', action: async () => console.log('Replace') },
]
},
{
name: 'View',
iconName: 'eye',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'Zoom In', iconName: 'zoomIn', shortcut: 'Cmd++', action: async () => console.log('Zoom in') },
{ name: 'Zoom Out', iconName: 'zoomOut', shortcut: 'Cmd+-', action: async () => console.log('Zoom out') },
{ name: 'Reset Zoom', iconName: 'maximize2', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
{ divider: true },
{ name: 'Full Screen', iconName: 'maximize', shortcut: 'F11', action: async () => console.log('Full screen') },
]
},
{ divider: true },
{
name: 'Settings',
iconName: 'settings',
action: async () => console.log('Settings')
},
{
name: 'Help',
iconName: 'helpCircle',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', action: async () => console.log('Shortcuts') },
{ divider: true },
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
]
}
]);
}}>
<h3>Right-click anywhere in this area</h3>
<p>A context menu with nested submenus will appear</p>
</div>
</dees-panel>
<dees-panel heading="Component-Specific Context Menu">
<dees-button style="margin: 20px;" @contextmenu=${(eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
{
name: 'Button Actions',
iconName: 'mousePointer',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'Click', iconName: 'mouse', action: async () => console.log('Click action') },
{ name: 'Double Click', iconName: 'zap', action: async () => console.log('Double click') },
{ name: 'Long Press', iconName: 'clock', action: async () => console.log('Long press') },
]
},
{
name: 'Button State',
iconName: 'toggleLeft',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'Enable', iconName: 'checkCircle', action: async () => console.log('Enable') },
{ name: 'Disable', iconName: 'xCircle', action: async () => console.log('Disable') },
{ divider: true },
{ name: 'Show', iconName: 'eye', action: async () => console.log('Show') },
{ name: 'Hide', iconName: 'eyeOff', action: async () => console.log('Hide') },
]
},
{ divider: true },
{
name: 'Disabled Action',
iconName: 'ban',
disabled: true,
action: async () => console.log('This should not run'),
},
{
name: 'Properties',
iconName: 'settings',
action: async () => console.log('Button properties'),
},
]);
}}>Right-click on this button</dees-button>
</dees-panel>
<dees-panel heading="Advanced Context Menu Example">
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [
{
name: 'Format',
iconName: 'type',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'Bold', iconName: 'bold', shortcut: 'Cmd+B', action: async () => console.log('Bold') },
{ name: 'Italic', iconName: 'italic', shortcut: 'Cmd+I', action: async () => console.log('Italic') },
{ name: 'Underline', iconName: 'underline', shortcut: 'Cmd+U', action: async () => console.log('Underline') },
{ divider: true },
{ name: 'Font Size', iconName: 'type', action: async () => console.log('Font size menu') },
{ name: 'Font Color', iconName: 'palette', action: async () => console.log('Font color menu') },
]
},
{
name: 'Transform',
iconName: 'shuffle',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'To Uppercase', iconName: 'arrowUp', action: async () => console.log('Uppercase') },
{ name: 'To Lowercase', iconName: 'arrowDown', action: async () => console.log('Lowercase') },
{ name: 'Capitalize', iconName: 'type', action: async () => console.log('Capitalize') },
]
},
{ divider: true },
{
name: 'Delete',
iconName: 'trash2',
action: async () => console.log('Delete')
}
]);
}}>
<h3>Advanced Nested Menu Example</h3>
<p>This shows deeply nested submenus and various formatting options</p>
</div>
</dees-panel>
<dees-panel heading="Static Context Menu (Always Visible)">
<dees-contextmenu
class="withMargin"
.menuItems=${[
{
name: 'Project',
iconName: 'folder',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'New Project', iconName: 'folderPlus', shortcut: 'Cmd+Shift+N', action: async () => console.log('New project') },
{ name: 'Open Project', iconName: 'folderOpen', shortcut: 'Cmd+Shift+O', action: async () => console.log('Open project') },
{ divider: true },
{ name: 'Recent Projects', iconName: 'clock', action: async () => {}, submenu: [
{ name: 'Project Alpha', action: async () => console.log('Open Alpha') },
{ name: 'Project Beta', action: async () => console.log('Open Beta') },
{ name: 'Project Gamma', action: async () => console.log('Open Gamma') },
]},
]
},
{
name: 'Tools',
iconName: 'tool',
action: async () => {}, // Parent items with submenus still need an action
submenu: [
{ name: 'Terminal', iconName: 'terminal', shortcut: 'Cmd+T', action: async () => console.log('Terminal') },
{ name: 'Console', iconName: 'monitor', shortcut: 'Cmd+K', action: async () => console.log('Console') },
{ divider: true },
{ name: 'Extensions', iconName: 'package', action: async () => console.log('Extensions') },
]
},
{ divider: true },
{
name: 'Preferences',
iconName: 'sliders',
action: async () => console.log('Preferences'),
},
]}
></dees-contextmenu>
</dees-panel>
</div>
`;

View File

@ -1,4 +1,3 @@
import * as colors from './00colors.js';
import * as plugins from './00plugins.js';
import { demoFunc } from './dees-contextmenu.demo.js';
import {
@ -15,6 +14,8 @@ import {
import * as domtools from '@design.estate/dees-domtools';
import { DeesWindowLayer } from './dees-windowlayer.js';
import { zIndexLayers } from './00zindex.js';
import './dees-icon.js';
declare global {
interface HTMLElementTagNameMap {
@ -30,7 +31,7 @@ export class DeesContextmenu extends DeesElement {
// STATIC
// This will store all the accumulated menu items
public static contextMenuDeactivated = false;
public static accumulatedMenuItems: plugins.tsclass.website.IMenuItem[] = [];
public static accumulatedMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[] = [];
// Add a global event listener for the right-click context menu
public static initializeGlobalListener() {
@ -40,18 +41,23 @@ export class DeesContextmenu extends DeesElement {
}
event.preventDefault();
// Get the target element of the right-click
let target: EventTarget | null = event.target;
// Clear previously accumulated items
DeesContextmenu.accumulatedMenuItems = [];
// Traverse up the DOM tree to accumulate menu items
while (target) {
if ((target as any).getContextMenuItems) {
DeesContextmenu.accumulatedMenuItems.push(...(target as any).getContextMenuItems());
// Use composedPath to properly traverse shadow DOM boundaries
const path = event.composedPath();
// Traverse the composed path to accumulate menu items
for (const element of path) {
if ((element as any).getContextMenuItems) {
const items = (element as any).getContextMenuItems();
if (items && items.length > 0) {
if (DeesContextmenu.accumulatedMenuItems.length > 0) {
DeesContextmenu.accumulatedMenuItems.push({ divider: true });
}
DeesContextmenu.accumulatedMenuItems.push(...items);
}
}
target = (target as Node).parentNode;
}
// Open the context menu with the accumulated items
@ -60,7 +66,7 @@ export class DeesContextmenu extends DeesElement {
}
// allows opening of a contextmenu with options
public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: plugins.tsclass.website.IMenuItem[]) {
public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[]) {
if (this.contextMenuDeactivated) {
return;
}
@ -68,32 +74,69 @@ export class DeesContextmenu extends DeesElement {
eventArg.stopPropagation();
const contextMenu = new DeesContextmenu();
contextMenu.style.position = 'fixed';
contextMenu.style.zIndex = '2000';
contextMenu.style.top = `${eventArg.clientY.toString()}px`;
contextMenu.style.left = `${eventArg.clientX.toString()}px`;
contextMenu.style.zIndex = String(zIndexLayers.overlay.contextMenu);
contextMenu.style.opacity = '0';
contextMenu.style.transform = 'scale(0.95,0.95)';
contextMenu.style.transformOrigin = 'top left';
contextMenu.style.transform = 'scale(0.95) translateY(-10px)';
contextMenu.menuItems = menuItemsArg;
contextMenu.windowLayer = await DeesWindowLayer.createAndShow();
contextMenu.windowLayer.addEventListener('click', async () => {
await contextMenu.destroy();
contextMenu.windowLayer.addEventListener('click', async (event) => {
// Check if click is on the context menu or its submenus
const clickedElement = event.target as HTMLElement;
const isContextMenu = clickedElement.closest('dees-contextmenu');
if (!isContextMenu) {
await contextMenu.destroy();
}
})
document.body.append(contextMenu);
// Get dimensions after adding to DOM
await domtools.plugins.smartdelay.delayFor(0);
const rect = contextMenu.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
// Calculate position
let top = eventArg.clientY;
let left = eventArg.clientX;
// Adjust if menu would go off right edge
if (left + rect.width > windowWidth) {
left = windowWidth - rect.width - 10;
}
// Adjust if menu would go off bottom edge
if (top + rect.height > windowHeight) {
top = windowHeight - rect.height - 10;
}
// Ensure menu doesn't go off left or top edge
if (left < 10) left = 10;
if (top < 10) top = 10;
contextMenu.style.top = `${top}px`;
contextMenu.style.left = `${left}px`;
contextMenu.style.transformOrigin = 'top left';
// Animate in
await domtools.plugins.smartdelay.delayFor(0);
contextMenu.style.opacity = '1';
contextMenu.style.transform = 'scale(1,1)';
contextMenu.style.transform = 'scale(1) translateY(0)';
}
// INSTANCE
@property({
type: Array,
})
public menuItems: plugins.tsclass.website.IMenuItem[] = [];
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[]; divider?: never } | { divider: true })[] = [];
windowLayer: DeesWindowLayer;
private submenu: DeesContextmenu | null = null;
private submenuTimeout: any = null;
private parentMenu: DeesContextmenu | null = null;
constructor() {
super();
this.tabIndex = 0;
}
/**
@ -104,40 +147,79 @@ export class DeesContextmenu extends DeesElement {
css`
:host {
display: block;
transition: all 0.1s;
transition: opacity 0.2s, transform 0.2s;
outline: none;
}
.mainbox {
color: ${cssManager.bdTheme('#222', '#ccc')};
font-size: 14px;
width: 200px;
border: 1px solid ${cssManager.bdTheme('#fff', '#ffffff10')};
min-height: 34px;
border-radius: 3px;
background: ${cssManager.bdTheme('#fff', '#222')};
box-shadow: 0px 1px 4px ${cssManager.bdTheme('#00000020', '#000000')};
min-width: 200px;
max-width: 280px;
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)'
)};
user-select: none;
padding: 4px;
padding: 4px 0;
font-size: 12px;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.mainbox .menuitem {
padding: 4px 8px;
border-radius: 3px;
.menuitem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: default;
transition: background 0.1s;
line-height: 1;
position: relative;
}
.mainbox .menuitem dees-icon {
display: inline-block;
margin-right: 8px;
width: 14px;
transform: translateY(2px);
.menuitem:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.mainbox .menuitem:hover {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
.menuitem.has-submenu::after {
content: '';
position: absolute;
right: 8px;
font-size: 16px;
opacity: 0.5;
}
.mainbox .menuitem:active {
background: #ffffff05;
.menuitem:active:not(.has-submenu) {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
}
.menuitem.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.menuitem dees-icon {
font-size: 14px;
opacity: 0.7;
}
.menuitem-text {
flex: 1;
}
.menuitem-shortcut {
font-size: 11px;
color: ${cssManager.bdTheme('#999', '#666')};
margin-left: auto;
opacity: 0.7;
}
.menu-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
`,
];
@ -146,10 +228,26 @@ export class DeesContextmenu extends DeesElement {
return html`
<div class="mainbox">
${this.menuItems.map((menuItemArg) => {
if ('divider' in menuItemArg && menuItemArg.divider) {
return html`<div class="menu-divider"></div>`;
}
const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: any };
const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0;
return html`
<div class="menuitem" @click=${() => this.handleClick(menuItemArg)}>
<dees-icon .iconFA=${(menuItemArg.iconName as any) || 'minus'}></dees-icon
>${menuItemArg.name}
<div
class="menuitem ${menuItem.disabled ? 'disabled' : ''} ${hasSubmenu ? 'has-submenu' : ''}"
@click=${() => !menuItem.disabled && !hasSubmenu && this.handleClick(menuItem)}
@mouseenter=${() => this.handleMenuItemHover(menuItem, hasSubmenu)}
@mouseleave=${() => this.handleMenuItemLeave()}
>
${menuItem.iconName ? html`
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
` : ''}
<span class="menuitem-text">${menuItem.name}</span>
${menuItem.shortcut && !hasSubmenu ? html`
<span class="menuitem-shortcut">${menuItem.shortcut}</span>
` : ''}
</div>
`;
})}
@ -158,8 +256,8 @@ export class DeesContextmenu extends DeesElement {
DeesContextmenu.contextMenuDeactivated = true;
this.destroy();
}}>
<dees-icon .iconFA=${'xmark'}></dees-icon
>allow native context
<dees-icon .icon="lucide:x"></dees-icon>
<span class="menuitem-text">Allow native context</span>
</div>
` : html``}
</div>
@ -167,22 +265,191 @@ export class DeesContextmenu extends DeesElement {
}
public async firstUpdated() {
// Focus on the menu for keyboard navigation
this.focus();
// Add keyboard event listeners
this.addEventListener('keydown', this.handleKeydown);
}
public async handleClick(menuItem: plugins.tsclass.website.IMenuItem) {
private handleKeydown = (event: KeyboardEvent) => {
const menuItems = Array.from(this.shadowRoot.querySelectorAll('.menuitem:not(.disabled)'));
const currentIndex = menuItems.findIndex(item => item.matches(':hover'));
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex = currentIndex + 1 < menuItems.length ? currentIndex + 1 : 0;
(menuItems[nextIndex] as HTMLElement).dispatchEvent(new MouseEvent('mouseenter'));
break;
case 'ArrowUp':
event.preventDefault();
const prevIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : menuItems.length - 1;
(menuItems[prevIndex] as HTMLElement).dispatchEvent(new MouseEvent('mouseenter'));
break;
case 'Enter':
event.preventDefault();
if (currentIndex >= 0) {
(menuItems[currentIndex] as HTMLElement).click();
}
break;
case 'Escape':
event.preventDefault();
this.destroy();
break;
}
}
public async handleClick(menuItem: plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }) {
menuItem.action();
await this.destroy();
// Close all menus in the chain (this menu and all parent menus)
await this.destroyAll();
}
private async handleMenuItemHover(menuItem: plugins.tsclass.website.IMenuItem & { submenu?: any }, hasSubmenu: boolean) {
// Clear any existing timeout
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
// Hide any existing submenu if hovering a different item
if (this.submenu) {
await this.hideSubmenu();
}
// Show submenu if this item has one
if (hasSubmenu && menuItem.submenu) {
this.submenuTimeout = setTimeout(() => {
this.showSubmenu(menuItem);
}, 200); // Small delay to prevent accidental triggers
}
}
private handleMenuItemLeave() {
// Add a delay before hiding to allow moving to submenu
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
}
this.submenuTimeout = setTimeout(() => {
if (this.submenu && !this.submenu.matches(':hover')) {
this.hideSubmenu();
}
}, 300);
}
private async showSubmenu(menuItem: plugins.tsclass.website.IMenuItem & { submenu?: any }) {
if (!menuItem.submenu || menuItem.submenu.length === 0) return;
// Find the menu item element
const menuItems = Array.from(this.shadowRoot.querySelectorAll('.menuitem'));
const menuItemElement = menuItems.find(el => el.querySelector('.menuitem-text')?.textContent === menuItem.name) as HTMLElement;
if (!menuItemElement) return;
// Create submenu
this.submenu = new DeesContextmenu();
this.submenu.menuItems = menuItem.submenu;
this.submenu.parentMenu = this;
this.submenu.style.position = 'fixed';
this.submenu.style.zIndex = String(parseInt(this.style.zIndex) + 1);
this.submenu.style.opacity = '0';
this.submenu.style.transform = 'scale(0.95)';
// Don't create a window layer for submenus
document.body.append(this.submenu);
// Position submenu
await domtools.plugins.smartdelay.delayFor(0);
const itemRect = menuItemElement.getBoundingClientRect();
const menuRect = this.getBoundingClientRect();
const submenuRect = this.submenu.getBoundingClientRect();
const windowWidth = window.innerWidth;
let left = menuRect.right - 4; // Slight overlap
let top = itemRect.top;
// Check if submenu would go off right edge
if (left + submenuRect.width > windowWidth - 10) {
// Show on left side instead
left = menuRect.left - submenuRect.width + 4;
}
// Adjust vertical position if needed
if (top + submenuRect.height > window.innerHeight - 10) {
top = window.innerHeight - submenuRect.height - 10;
}
this.submenu.style.left = `${left}px`;
this.submenu.style.top = `${top}px`;
// Animate in
await domtools.plugins.smartdelay.delayFor(0);
this.submenu.style.opacity = '1';
this.submenu.style.transform = 'scale(1)';
// Handle submenu hover
this.submenu.addEventListener('mouseenter', () => {
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
});
this.submenu.addEventListener('mouseleave', () => {
this.handleMenuItemLeave();
});
}
private async hideSubmenu() {
if (!this.submenu) return;
await this.submenu.destroy();
this.submenu = null;
}
public async destroy() {
if (this.windowLayer) {
// Clear timeout
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
// Destroy submenu first
if (this.submenu) {
await this.submenu.destroy();
this.submenu = null;
}
// Only destroy window layer if this is not a submenu
if (this.windowLayer && !this.parentMenu) {
this.windowLayer.destroy();
}
this.style.opacity = '0';
this.style.transform = 'scale(0.95,0,95)';
this.style.transform = 'scale(0.95) translateY(-10px)';
await domtools.plugins.smartdelay.delayFor(100);
this.parentElement.removeChild(this);
if (this.parentElement) {
this.parentElement.removeChild(this);
}
}
/**
* Destroys this menu and all parent menus in the chain
*/
public async destroyAll() {
// First destroy parent menus if they exist
if (this.parentMenu) {
await this.parentMenu.destroyAll();
} else {
// If we're at the top level, just destroy this menu
await this.destroy();
}
}
}

View File

@ -0,0 +1,191 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => {
return html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
// Set initial widgets
grid.widgets = [
{
id: 'metrics1',
x: 0,
y: 0,
w: 3,
h: 2,
title: 'Revenue',
icon: 'lucide:dollarSign',
content: html`
<div style="padding: 20px;">
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
<div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
</div>
`
},
{
id: 'metrics2',
x: 3,
y: 0,
w: 3,
h: 2,
title: 'Users',
icon: 'lucide:users',
content: html`
<div style="padding: 20px;">
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
<div style="color: #3b82f6; font-size: 14px; margin-top: 8px;"> 5.2% from last week</div>
</div>
`
},
{
id: 'chart1',
x: 6,
y: 0,
w: 6,
h: 4,
title: 'Analytics',
icon: 'lucide:lineChart',
content: html`
<div style="padding: 20px; height: 100%; display: flex; align-items: center; justify-content: center;">
<div style="text-align: center; color: #71717a;">
<dees-icon .icon=${'lucide:lineChart'} style="font-size: 48px; margin-bottom: 16px;"></dees-icon>
<div>Chart visualization area</div>
</div>
</div>
`
}
];
// Configure grid
grid.cellHeight = 80;
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
grid.enableAnimation = true;
grid.showGridLines = false;
let widgetCounter = 4;
// Control buttons
const buttons = elementArg.querySelectorAll('dees-button');
buttons.forEach(button => {
const text = button.textContent?.trim();
if (text === 'Toggle Animation') {
button.addEventListener('click', () => {
grid.enableAnimation = !grid.enableAnimation;
});
} else if (text === 'Toggle Grid Lines') {
button.addEventListener('click', () => {
grid.showGridLines = !grid.showGridLines;
});
} else if (text === 'Add Widget') {
button.addEventListener('click', () => {
const newWidget = {
id: `widget${widgetCounter++}`,
x: 0,
y: 0,
w: 3,
h: 2,
autoPosition: true,
title: `Widget ${widgetCounter - 1}`,
icon: 'lucide:package',
content: html`
<div style="padding: 20px; text-align: center;">
<div style="color: #71717a;">New widget content</div>
<div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(Math.random() * 1000)}</div>
</div>
`
};
grid.addWidget(newWidget, true);
});
} else if (text === 'Compact Grid') {
button.addEventListener('click', () => {
grid.compact();
});
} else if (text === 'Toggle Edit Mode') {
button.addEventListener('click', () => {
grid.editable = !grid.editable;
button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
});
}
});
// Listen to grid events
grid.addEventListener('widget-move', (e: CustomEvent) => {
console.log('Widget moved:', e.detail.widget);
});
grid.addEventListener('widget-resize', (e: CustomEvent) => {
console.log('Widget resized:', e.detail.widget);
});
}}>
<style>
${css`
.demoBox {
position: relative;
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
height: 100%;
width: 100%;
padding: 40px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 24px;
}
.demo-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.demo-controls dees-button {
flex-shrink: 0;
}
.grid-container-wrapper {
flex: 1;
min-height: 600px;
position: relative;
}
.info {
color: ${cssManager.bdTheme('#71717a', '#71717a')};
font-size: 12px;
font-family: 'Geist Sans', sans-serif;
text-align: center;
}
`}
</style>
<div class="demoBox">
<div class="demo-controls">
<dees-button-group label="Animation:">
<dees-button>Toggle Animation</dees-button>
</dees-button-group>
<dees-button-group label="Display:">
<dees-button>Toggle Grid Lines</dees-button>
</dees-button-group>
<dees-button-group label="Actions:">
<dees-button>Add Widget</dees-button>
<dees-button>Compact Grid</dees-button>
</dees-button-group>
<dees-button-group label="Mode:">
<dees-button>Toggle Edit Mode</dees-button>
</dees-button-group>
</div>
<div class="grid-container-wrapper">
<dees-dashboardgrid id="dashboardGrid"></dees-dashboardgrid>
</div>
<div class="info">
Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning
</div>
</div>
</dees-demowrapper>
`;
};

View File

@ -0,0 +1,813 @@
import * as plugins from './00plugins.js';
import {
DeesElement,
type TemplateResult,
property,
customElement,
html,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import './dees-icon.js';
import { demoFunc } from './dees-dashboardgrid.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-dashboardgrid': DeesDashboardgrid;
}
}
export interface IDashboardWidget {
id: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
content: TemplateResult | string;
title?: string;
icon?: string;
noMove?: boolean;
noResize?: boolean;
locked?: boolean;
autoPosition?: boolean; // Auto-position widget in first available space
}
@customElement('dees-dashboardgrid')
export class DeesDashboardgrid extends DeesElement {
// STATIC
public static demo = demoFunc;
// INSTANCE
@property({ type: Array })
public widgets: IDashboardWidget[] = [];
@property({ type: Number })
public cellHeight: number = 80;
@property({ type: Object })
public margin: number | { top?: number; right?: number; bottom?: number; left?: number } = 10;
@property({ type: Number })
public columns: number = 12;
@property({ type: Boolean })
public editable: boolean = true;
@property({ type: Boolean, reflect: true })
public enableAnimation: boolean = true;
@property({ type: String })
public cellHeightUnit: 'px' | 'em' | 'rem' | 'auto' = 'px';
@property({ type: Boolean })
public rtl: boolean = false; // Right-to-left support
@property({ type: Boolean })
public showGridLines: boolean = false;
@state()
private draggedWidget: IDashboardWidget | null = null;
@state()
private draggedElement: HTMLElement | null = null;
@state()
private dragOffsetX: number = 0;
@state()
private dragOffsetY: number = 0;
@state()
private dragMouseX: number = 0;
@state()
private dragMouseY: number = 0;
@state()
private placeholderPosition: { x: number; y: number } | null = null;
@state()
private resizingWidget: IDashboardWidget | null = null;
@state()
private resizeStartW: number = 0;
@state()
private resizeStartH: number = 0;
@state()
private resizeStartX: number = 0;
@state()
private resizeStartY: number = 0;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
position: relative;
}
.grid-container {
position: relative;
width: 100%;
min-height: 400px;
box-sizing: border-box;
}
.grid-widget {
position: absolute;
will-change: auto;
}
:host([enableanimation]) .grid-widget {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.grid-widget.dragging {
z-index: 1000;
transition: none !important;
opacity: 0.8;
cursor: grabbing;
pointer-events: none;
will-change: transform;
}
.grid-widget.placeholder {
pointer-events: none;
z-index: 1;
}
.grid-widget.placeholder .widget-content {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
box-shadow: none;
}
.grid-widget.resizing {
transition: none !important;
}
.widget-content {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
box-shadow: ${cssManager.bdTheme(
'0 1px 3px rgba(0, 0, 0, 0.1)',
'0 1px 3px rgba(0, 0, 0, 0.3)'
)};
transition: box-shadow 0.2s ease;
}
.grid-widget:hover .widget-content {
box-shadow: ${cssManager.bdTheme(
'0 4px 12px rgba(0, 0, 0, 0.15)',
'0 4px 12px rgba(0, 0, 0, 0.4)'
)};
}
.grid-widget.dragging .widget-content {
box-shadow: ${cssManager.bdTheme(
'0 16px 48px rgba(0, 0, 0, 0.25)',
'0 16px 48px rgba(0, 0, 0, 0.6)'
)};
transform: scale(1.05);
}
.widget-header {
padding: 12px 16px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
cursor: grab;
user-select: none;
}
.widget-header:hover {
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
}
.widget-header:active {
cursor: grabbing;
}
.widget-header.locked {
cursor: default;
}
.widget-header.locked:hover {
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
}
.widget-header dees-icon {
font-size: 16px;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
}
.widget-body {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.widget-body.has-header {
top: 45px;
}
/* Resize handles */
.resize-handle {
position: absolute;
background: transparent;
z-index: 10;
}
.resize-handle:hover {
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
opacity: 0.3;
}
.resize-handle-e {
cursor: ew-resize;
width: 12px;
right: -6px;
top: 10%;
height: 80%;
}
.resize-handle-s {
cursor: ns-resize;
height: 12px;
width: 80%;
bottom: -6px;
left: 10%;
}
.resize-handle-se {
cursor: se-resize;
width: 20px;
height: 20px;
right: -2px;
bottom: -2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.resize-handle-se::after {
content: '';
position: absolute;
right: 4px;
bottom: 4px;
width: 6px;
height: 6px;
border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
}
.grid-widget:hover .resize-handle-se {
opacity: 0.7;
}
.resize-handle-se:hover {
opacity: 1 !important;
}
.resize-handle-se:hover::after {
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
/* Placeholder */
.grid-placeholder {
position: absolute;
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
opacity: 0.1;
border-radius: 8px;
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
transition: all 0.2s ease;
pointer-events: none;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
text-align: center;
padding: 32px;
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
/* Grid lines */
.grid-lines {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: -1;
}
.grid-line-vertical {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
opacity: 0.3;
}
.grid-line-horizontal {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
opacity: 0.3;
}
`,
];
public render(): TemplateResult {
if (this.widgets.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:layoutGrid'}></dees-icon>
<div>No widgets configured</div>
<div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div>
</div>
`;
}
const margins = this.getMargins();
const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 4);
const cellHeightValue = this.getCellHeight();
const gridHeight = maxY * cellHeightValue + (maxY + 1) * margins.vertical;
return html`
<div class="grid-container" style="height: ${gridHeight}px;">
${this.showGridLines ? this.renderGridLines(gridHeight) : ''}
${this.widgets.map(widget => this.renderWidget(widget))}
${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''}
</div>
`;
}
private renderGridLines(gridHeight: number): TemplateResult {
const margins = this.getMargins();
const cellHeightValue = this.getCellHeight();
// Convert margin to percentage for consistent calculation
const containerWidth = this.getBoundingClientRect().width;
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
const verticalLines = [];
const horizontalLines = [];
// Vertical lines
for (let i = 0; i <= this.columns; i++) {
const left = i * cellWidth + i * marginHorizontalPercent;
verticalLines.push(html`
<div class="grid-line-vertical" style="left: ${left}%;"></div>
`);
}
// Horizontal lines
const numHorizontalLines = Math.ceil(gridHeight / (cellHeightValue + margins.vertical));
for (let i = 0; i <= numHorizontalLines; i++) {
const top = i * cellHeightValue + i * margins.vertical;
horizontalLines.push(html`
<div class="grid-line-horizontal" style="top: ${top}px;"></div>
`);
}
return html`
<div class="grid-lines">
${verticalLines}
${horizontalLines}
</div>
`;
}
private renderWidget(widget: IDashboardWidget): TemplateResult {
const isDragging = this.draggedWidget?.id === widget.id;
const isResizing = this.resizingWidget?.id === widget.id;
const isLocked = widget.locked || !this.editable;
const margins = this.getMargins();
const cellHeightValue = this.getCellHeight();
// Convert margin to percentage of container width for consistent calculation
const containerWidth = this.getBoundingClientRect().width;
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
const left = widget.x * cellWidth + (widget.x + 1) * marginHorizontalPercent;
const top = widget.y * cellHeightValue + (widget.y + 1) * margins.vertical;
const width = widget.w * cellWidth + (widget.w - 1) * marginHorizontalPercent;
const height = widget.h * cellHeightValue + (widget.h - 1) * margins.vertical;
// Apply transform when dragging for smooth movement
let transform = '';
if (isDragging && this.draggedElement) {
const containerRect = this.getBoundingClientRect();
const translateX = this.dragMouseX - containerRect.left - this.dragOffsetX - (left / 100 * containerRect.width);
const translateY = this.dragMouseY - containerRect.top - this.dragOffsetY - top;
transform = `transform: translate(${translateX}px, ${translateY}px);`;
}
return html`
<div
class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
style="
${this.rtl ? 'right' : 'left'}: ${left}%;
top: ${top}px;
width: ${width}%;
height: ${height}px;
${transform}
"
data-widget-id="${widget.id}"
>
<div class="widget-content">
${widget.title ? html`
<div
class="widget-header ${isLocked ? 'locked' : ''}"
@mousedown=${!isLocked && !widget.noMove ? (e: MouseEvent) => this.startDrag(e, widget) : null}
>
${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : ''}
${widget.title}
</div>
` : ''}
<div class="widget-body ${widget.title ? 'has-header' : ''}">
${widget.content}
</div>
${!isLocked && !widget.noResize ? html`
<div class="resize-handle resize-handle-e" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'e')}></div>
<div class="resize-handle resize-handle-s" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 's')}></div>
<div class="resize-handle resize-handle-se" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'se')}></div>
` : ''}
</div>
</div>
`;
}
private renderPlaceholder(): TemplateResult {
if (!this.placeholderPosition || !this.draggedWidget) return html``;
const margins = this.getMargins();
const cellHeightValue = this.getCellHeight();
// Convert margin to percentage of container width for consistent calculation
const containerWidth = this.getBoundingClientRect().width;
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
const left = this.placeholderPosition.x * cellWidth + (this.placeholderPosition.x + 1) * marginHorizontalPercent;
const top = this.placeholderPosition.y * cellHeightValue + (this.placeholderPosition.y + 1) * margins.vertical;
const width = this.draggedWidget.w * cellWidth + (this.draggedWidget.w - 1) * marginHorizontalPercent;
const height = this.draggedWidget.h * cellHeightValue + (this.draggedWidget.h - 1) * margins.vertical;
return html`
<div
class="grid-widget placeholder"
style="
${this.rtl ? 'right' : 'left'}: ${left}%;
top: ${top}px;
width: ${width}%;
height: ${height}px;
"
>
<div class="widget-content"></div>
</div>
`;
}
private startDrag(e: MouseEvent, widget: IDashboardWidget) {
e.preventDefault();
this.draggedWidget = widget;
this.draggedElement = (e.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement;
const rect = this.draggedElement.getBoundingClientRect();
this.dragOffsetX = e.clientX - rect.left;
this.dragOffsetY = e.clientY - rect.top;
// Initialize mouse position
this.dragMouseX = e.clientX;
this.dragMouseY = e.clientY;
// Initialize placeholder at current widget position
this.placeholderPosition = { x: widget.x, y: widget.y };
document.addEventListener('mousemove', this.handleDrag);
document.addEventListener('mouseup', this.endDrag);
this.requestUpdate();
}
private handleDrag = (e: MouseEvent) => {
if (!this.draggedWidget || !this.draggedElement) return;
// Update mouse position for smooth dragging
this.dragMouseX = e.clientX;
this.dragMouseY = e.clientY;
const containerRect = this.getBoundingClientRect();
const margins = this.getMargins();
const cellHeightValue = this.getCellHeight();
// Get widget position relative to grid container
const mouseX = e.clientX - containerRect.left - this.dragOffsetX;
const mouseY = e.clientY - containerRect.top - this.dragOffsetY;
// Use pixel calculations for accuracy
const totalWidth = containerRect.width;
const totalMarginWidth = margins.horizontal * (this.columns + 1);
const availableWidth = totalWidth - totalMarginWidth;
const cellWidthPx = availableWidth / this.columns;
// Calculate grid X position
// Account for the initial margin and then repeating pattern of cell+margin
let gridX = 0;
if (mouseX > margins.horizontal) {
const adjustedX = mouseX - margins.horizontal;
const cellPlusMargin = cellWidthPx + margins.horizontal;
gridX = Math.floor(adjustedX / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
}
// Calculate grid Y position
let gridY = 0;
if (mouseY > margins.vertical) {
const adjustedY = mouseY - margins.vertical;
const cellPlusMargin = cellHeightValue + margins.vertical;
gridY = Math.floor(adjustedY / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
}
const clampedX = Math.max(0, Math.min(gridX, this.columns - this.draggedWidget.w));
const clampedY = Math.max(0, gridY);
// Update placeholder position instead of widget position during drag
if (!this.placeholderPosition ||
clampedX !== this.placeholderPosition.x ||
clampedY !== this.placeholderPosition.y) {
const collision = this.checkCollision(this.draggedWidget, clampedX, clampedY);
if (!collision) {
this.placeholderPosition = { x: clampedX, y: clampedY };
this.requestUpdate();
}
}
};
private endDrag = () => {
// Apply final position from placeholder
if (this.draggedWidget && this.placeholderPosition) {
this.draggedWidget.x = this.placeholderPosition.x;
this.draggedWidget.y = this.placeholderPosition.y;
this.dispatchEvent(new CustomEvent('widget-move', {
detail: { widget: this.draggedWidget },
bubbles: true,
composed: true,
}));
}
// Clear drag state
this.draggedWidget = null;
this.draggedElement = null;
this.placeholderPosition = null;
this.dragMouseX = 0;
this.dragMouseY = 0;
document.removeEventListener('mousemove', this.handleDrag);
document.removeEventListener('mouseup', this.endDrag);
this.requestUpdate();
};
private startResize(e: MouseEvent, widget: IDashboardWidget, handle: string) {
e.preventDefault();
e.stopPropagation();
this.resizingWidget = widget;
this.resizeStartW = widget.w;
this.resizeStartH = widget.h;
this.resizeStartX = e.clientX;
this.resizeStartY = e.clientY;
const handleResize = (e: MouseEvent) => {
if (!this.resizingWidget) return;
const containerRect = this.getBoundingClientRect();
const margins = this.getMargins();
const cellHeightValue = this.getCellHeight();
const cellWidth = (containerRect.width - margins.horizontal * (this.columns + 1)) / this.columns;
const deltaX = e.clientX - this.resizeStartX;
const deltaY = e.clientY - this.resizeStartY;
if (handle.includes('e')) {
const newW = Math.round(this.resizeStartW + deltaX / (cellWidth + margins.horizontal));
const maxW = widget.maxW || (this.columns - this.resizingWidget.x);
this.resizingWidget.w = Math.max(widget.minW || 1, Math.min(newW, maxW));
}
if (handle.includes('s')) {
const newH = Math.round(this.resizeStartH + deltaY / (cellHeightValue + margins.vertical));
const maxH = widget.maxH || Infinity;
this.resizingWidget.h = Math.max(widget.minH || 1, Math.min(newH, maxH));
}
this.requestUpdate();
this.dispatchEvent(new CustomEvent('widget-resize', {
detail: { widget: this.resizingWidget },
bubbles: true,
composed: true,
}));
};
const endResize = () => {
this.resizingWidget = null;
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', endResize);
};
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', endResize);
}
public removeWidget(widgetId: string) {
this.widgets = this.widgets.filter(w => w.id !== widgetId);
}
public updateWidget(widgetId: string, updates: Partial<IDashboardWidget>) {
this.widgets = this.widgets.map(w =>
w.id === widgetId ? { ...w, ...updates } : w
);
}
public getLayout(): Array<{ id: string; x: number; y: number; w: number; h: number }> {
return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
}
public setLayout(layout: Array<{ id: string; x: number; y: number; w: number; h: number }>) {
this.widgets = this.widgets.map(widget => {
const layoutItem = layout.find(l => l.id === widget.id);
return layoutItem ? { ...widget, ...layoutItem } : widget;
});
}
public lockGrid() {
this.editable = false;
}
public unlockGrid() {
this.editable = true;
}
private getMargins(): { horizontal: number; vertical: number; top: number; right: number; bottom: number; left: number } {
if (typeof this.margin === 'number') {
return {
horizontal: this.margin,
vertical: this.margin,
top: this.margin,
right: this.margin,
bottom: this.margin,
left: this.margin,
};
}
const margins = {
top: this.margin.top ?? 10,
right: this.margin.right ?? 10,
bottom: this.margin.bottom ?? 10,
left: this.margin.left ?? 10,
};
return {
...margins,
horizontal: (margins.left + margins.right) / 2,
vertical: (margins.top + margins.bottom) / 2,
};
}
private getCellHeight(): number {
if (this.cellHeightUnit === 'auto') {
// Calculate square cells based on container width
const containerWidth = this.getBoundingClientRect().width;
const margins = this.getMargins();
const cellWidth = (containerWidth - margins.horizontal * (this.columns + 1)) / this.columns;
return cellWidth;
}
return this.cellHeight;
}
private checkCollision(widget: IDashboardWidget, newX: number, newY: number): boolean {
const widgets = this.widgets.filter(w => w.id !== widget.id);
for (const other of widgets) {
if (newX < other.x + other.w &&
newX + widget.w > other.x &&
newY < other.y + other.h &&
newY + widget.h > other.y) {
return true;
}
}
return false;
}
public addWidget(widget: IDashboardWidget, autoPosition = false) {
if (autoPosition || widget.autoPosition) {
// Find first available position
const position = this.findAvailablePosition(widget.w, widget.h);
widget.x = position.x;
widget.y = position.y;
}
this.widgets = [...this.widgets, widget];
}
private findAvailablePosition(width: number, height: number): { x: number; y: number } {
// Try to find space starting from top-left
for (let y = 0; y < 100; y++) { // Reasonable limit
for (let x = 0; x <= this.columns - width; x++) {
const testWidget = { id: 'test', x, y, w: width, h: height, content: '' } as IDashboardWidget;
if (!this.checkCollision(testWidget, x, y)) {
return { x, y };
}
}
}
// If no space found, place at bottom
const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 0);
return { x: 0, y: maxY };
}
public compact(direction: 'vertical' | 'horizontal' = 'vertical') {
const sortedWidgets = [...this.widgets].sort((a, b) => {
if (direction === 'vertical') {
if (a.y !== b.y) return a.y - b.y;
return a.x - b.x;
} else {
if (a.x !== b.x) return a.x - b.x;
return a.y - b.y;
}
});
for (const widget of sortedWidgets) {
if (widget.locked || widget.noMove) continue;
if (direction === 'vertical') {
// Move up as far as possible
while (widget.y > 0 && !this.checkCollision(widget, widget.x, widget.y - 1)) {
widget.y--;
}
} else {
// Move left as far as possible
while (widget.x > 0 && !this.checkCollision(widget, widget.x - 1, widget.y)) {
widget.x--;
}
}
}
this.requestUpdate();
}
}

View File

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

View File

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

View File

@ -3,47 +3,162 @@ import * as tsclass from '@tsclass/tsclass';
export const demoFunc = () => html` <style>
.demo {
background: ${cssManager.bdTheme('#eeeeeb', '#000000')};
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
display: block;
content: '';
padding: 40px;
}
.demo-grid {
display: grid;
gap: 24px;
max-width: 800px;
margin: 0 auto;
}
.demo-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.demo-title {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
margin-bottom: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.demo-note {
font-size: 12px;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
margin-bottom: 24px;
text-align: center;
font-style: italic;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
</style>
<div class="demo">
<dees-dataview-statusobject
.statusObject=${{
id: '1',
name: 'Demo Item',
combinedStatus: 'partly_ok',
combinedStatusText: 'partly_ok',
details: [
{
name: 'Detail 1',
value: 'Value 1',
status: 'ok',
statusText: 'OK',
},
{
name: 'Detail 2',
value: 'Value 2',
status: 'partly_ok',
statusText: 'partly_ok',
},
{
name: 'Detail 3',
value: 'Value 3',
status: 'not_ok',
statusText: 'not_ok',
},
{
name: 'Detail 4',
value:
'Value 4 jhdkfjhalskdfjhfdjskalsdkfjhfdjskalskdjfhjdkslaksjdhfjdkslaskdfjhfjdkslaskdjfhjdskalskdjhfdjskalskdjfhdjskl',
status: 'ok',
statusText: 'OK',
},
],
} as tsclass.code.IStatusObject}
>
</dees-dataview-statusobject>
<div class="demo-note">
Right-click on any detail row to copy the value, key, or key:value combination
</div>
<div class="demo-grid">
<div class="demo-section">
<div class="demo-title">Service Health Status</div>
<dees-dataview-statusobject
.statusObject=${{
id: '1',
name: 'API Gateway Service',
combinedStatus: 'ok',
combinedStatusText: 'All systems operational',
details: [
{
name: 'Response Time',
value: '45ms (avg)',
status: 'ok',
statusText: 'Within normal range',
},
{
name: 'Uptime',
value: '99.99% (30 days)',
status: 'ok',
statusText: 'Excellent uptime',
},
{
name: 'Active Connections',
value: '1,234 / 10,000',
status: 'ok',
statusText: 'Normal load',
},
{
name: 'SSL Certificate',
value: 'Valid until 2024-12-31',
status: 'ok',
statusText: 'Certificate valid',
},
],
} as tsclass.code.IStatusObject}
>
</dees-dataview-statusobject>
</div>
<div class="demo-section">
<div class="demo-title">Database Cluster Status</div>
<dees-dataview-statusobject
.statusObject=${{
id: '2',
name: 'PostgreSQL Cluster',
combinedStatus: 'partly_ok',
combinedStatusText: 'Minor issues detected',
details: [
{
name: 'Primary Node',
value: 'db-primary-01 (healthy)',
status: 'ok',
statusText: 'Operating normally',
},
{
name: 'Replica Lag',
value: '2.5 seconds',
status: 'partly_ok',
statusText: 'Slightly elevated',
},
{
name: 'Disk Usage',
value: '78% (312GB / 400GB)',
status: 'partly_ok',
statusText: 'Approaching threshold',
},
{
name: 'Connection Pool',
value: '89 / 100 connections',
status: 'ok',
statusText: 'Within limits',
},
],
} as tsclass.code.IStatusObject}
>
</dees-dataview-statusobject>
</div>
<div class="demo-section">
<div class="demo-title">Build Pipeline Status</div>
<dees-dataview-statusobject
.statusObject=${{
id: '3',
name: 'CI/CD Pipeline',
combinedStatus: 'not_ok',
combinedStatusText: 'Build failure',
details: [
{
name: 'Last Build',
value: 'Build #1234 - Failed',
status: 'not_ok',
statusText: 'Test failures',
},
{
name: 'Failed Tests',
value: '3 tests failed: auth.spec.ts, user.spec.ts, api.spec.ts',
status: 'not_ok',
statusText: 'Unit test failures',
},
{
name: 'Code Coverage',
value: '82.5% (target: 85%)',
status: 'partly_ok',
statusText: 'Below target',
},
{
name: 'Build Duration',
value: '12m 34s',
status: 'ok',
statusText: 'Normal duration',
},
],
} as tsclass.code.IStatusObject}
>
</dees-dataview-statusobject>
</div>
</div>
</div>`;

View File

@ -15,6 +15,7 @@ import {
} from '@design.estate/dees-element';
import * as tsclass from '@tsclass/tsclass';
import { DeesContextmenu } from './dees-contextmenu.js';
declare global {
interface HTMLElementTagNameMap {
@ -31,109 +32,128 @@ export class DeesDataviewStatusobject extends DeesElement {
public static styles = [
cssManager.defaultStyles,
css`
:host {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
.mainbox {
border-radius: 8px;
background: ${cssManager.bdTheme('#fff', '#1b1b1b')};
box-shadow: 0px 1px 3px #00000030;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
box-shadow: 0 1px 3px 0 hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
min-height: 48px;
color: ${cssManager.bdTheme('#000', '#fff')};
border-top: ${cssManager.bdTheme('none', '1px solid #ffffff10')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
cursor: default;
overflow: hidden;
}
.heading {
display: grid;
align-items: center;
grid-template-columns: 40px auto 120px;
grid-template-columns: 48px auto 100px;
height: 56px;
padding: 0 16px;
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
h1 {
display: block;
margin: 0px;
padding: 0px;
height: 48px;
text-transform: uppercase;
font-size: 12px;
line-height: 48px;
padding: 0px 12px;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.statusdot {
height: 8px;
width: 8px;
border-radius: 6px;
background: grey;
height: 10px;
width: 10px;
border-radius: 50%;
background: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
margin: auto;
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(0 0% 63.9% / 0.2)', 'hsl(0 0% 45.1% / 0.2)')};
transition: all 0.2s ease;
}
.copyMain {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444')};
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
text-align: center;
padding: 4px;
border-radius: 3px;
margin-right: 16px;
color: ${cssManager.bdTheme('#333', '#ffffff80')};
padding: 6px 12px;
border-radius: 6px;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
user-select: none;
cursor: pointer;
transition: all 0.15s ease;
}
.copyMain:hover {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
border: 1px solid ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
color: #fff;
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.copyMain:active {
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
border: 1px solid ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
color: #fff;
background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 14.9%)')};
transform: scale(0.98);
}
.statusdot.ok {
background: green;
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.2)', 'hsl(142.1 70.6% 45.3% / 0.2)')};
}
.statusdot.not_ok{
background: red;
.statusdot.not_ok {
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.2)', 'hsl(0 72.2% 50.6% / 0.2)')};
}
.statusdot.partly_ok {
background: orange;
background: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(25 95% 53% / 0.2)', 'hsl(25 95% 63% / 0.2)')};
}
.detail {
min-height: 60px;
align-items: center;
display: grid;
grid-template-columns: 40px auto;
border-top: 1px dotted ${cssManager.bdTheme('#e0e0e0', '#282828')};
transition: all 0.2s;
grid-template-columns: 48px auto;
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 94%)', 'hsl(0 0% 14.9%)')};
transition: background-color 0.15s ease;
padding-right: 16px;
cursor: context-menu;
}
.detail:hover {
background: #ffffff05;
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
}
.detail:active {
background: #ffffff10;
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
}
.detail .detailsText {
padding-top: 8px;
padding-bottom: 8px;
padding-right: 8px;
padding: 12px;
word-break: break-all;
}
.detail .detailsText .label {
font-size: 12px;
color: #ffffff80
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}
margin-bottom: 2px;
letter-spacing: -0.01em;
}
.detail .detailsText .value {
font-size: 14px;
font-family: 'Intel One Mono', 'Geist Mono';
font-family: 'Intel One Mono', 'Geist Mono', monospace;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
line-height: 1.5;
}
`,
];
@ -143,12 +163,40 @@ export class DeesDataviewStatusobject extends DeesElement {
<div class="mainbox">
<div class="heading">
<div class="statusdot ${this.statusObject?.combinedStatus}"></div>
<h1>${this.statusObject?.name || 'no status object assigned'}</h1>
<div class="copyMain">Copy as JSON</div>
<h1>${this.statusObject?.name || 'No status object assigned'}</h1>
<div class="copyMain" @click=${this.handleCopyAsJson}>Copy JSON</div>
</div>
${this.statusObject?.details?.map((detailArg) => {
return html`
<div class="detail">
<div
class="detail"
@contextmenu=${(event: MouseEvent) => {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Copy Value',
iconName: 'lucideCopy',
action: async () => {
await this.copyToClipboard(detailArg.value, 'Value');
},
},
{
name: 'Copy Key',
iconName: 'lucideKey',
action: async () => {
await this.copyToClipboard(detailArg.name, 'Key');
},
},
{
name: 'Copy Key:Value',
iconName: 'lucideCopyPlus',
action: async () => {
await this.copyToClipboard(`${detailArg.name}: ${detailArg.value}`, 'Key:Value');
},
},
]);
}}
>
<div class="statusdot ${detailArg.status}"></div>
<div class="detailsText">
<div class="label">${detailArg.name}</div>
@ -162,4 +210,42 @@ export class DeesDataviewStatusobject extends DeesElement {
}
async firstUpdated() {}
private async copyToClipboard(text: string, type: string = 'Text') {
try {
await navigator.clipboard.writeText(text);
console.log(`${type} copied to clipboard`);
// You could add visual feedback here if needed
} catch (err) {
console.error(`Failed to copy ${type}:`, err);
}
}
private async handleCopyAsJson() {
if (!this.statusObject) return;
try {
await navigator.clipboard.writeText(JSON.stringify(this.statusObject, null, 2));
// Show feedback
const button = this.shadowRoot.querySelector('.copyMain') as HTMLElement;
const originalText = button.textContent;
button.textContent = 'Copied!';
// Apply success styles based on theme
const isDark = !this.goBright;
button.style.background = isDark ? 'hsl(142.1 70.6% 45.3% / 0.1)' : 'hsl(142.1 76.2% 36.3% / 0.1)';
button.style.borderColor = isDark ? 'hsl(142.1 70.6% 45.3%)' : 'hsl(142.1 76.2% 36.3%)';
button.style.color = isDark ? 'hsl(142.1 70.6% 45.3%)' : 'hsl(142.1 76.2% 36.3%)';
setTimeout(() => {
button.textContent = originalText;
button.style.background = '';
button.style.borderColor = '';
button.style.color = '';
}, 1500);
} catch (err) {
console.error('Failed to copy:', err);
}
}
}

View File

@ -0,0 +1,3 @@
import { html } from '@design.estate/dees-element';
export const demoFunc = () => html`<dees-form-submit>Submit Form</dees-form-submit>`;

View File

@ -1,3 +1,4 @@
import { demoFunc } from './dees-form-submit.demo.js';
import {
customElement,
html,
@ -5,9 +6,8 @@ import {
css,
cssManager,
property,
type CSSResult,
} from '@design.estate/dees-element';
import { DeesForm } from './dees-form.js';
import type { DeesForm } from './dees-form.js';
declare global {
interface HTMLElementTagNameMap {
@ -17,7 +17,7 @@ declare global {
@customElement('dees-form-submit')
export class DeesFormSubmit extends DeesElement {
public static demo = () => html`<dees-form-submit>This is a sloted text</dees-form-submit>`;
public static demo = demoFunc;
@property({
type: Boolean,
@ -44,11 +44,11 @@ export class DeesFormSubmit extends DeesElement {
public render() {
return html`
<dees-button
status=${this.status}
@click=${this.submit}
.disabled=${this.disabled}
.text=${this.text ? this.text : this.textContent}
status="${this.status}"
@click="${this.submit}"
?disabled="${this.disabled}"
>
${this.text || html`<slot></slot>`}
</dees-button>
`;
}
@ -58,13 +58,15 @@ export class DeesFormSubmit extends DeesElement {
return;
}
const parentElement: DeesForm = this.parentElement as DeesForm;
parentElement.gatherAndDispatch();
if (parentElement && parentElement.gatherAndDispatch) {
parentElement.gatherAndDispatch();
}
}
public async focus() {
const domtools = await this.domtoolsPromise;
if (!this.disabled) {
domtools.convenience.smartdelay.delayFor(0);
await domtools.convenience.smartdelay.delayFor(0);
this.submit();
}
}

View File

@ -1,69 +1,187 @@
import { html, domtools, cssManager } from '@design.estate/dees-element';
import { html, css, domtools, cssManager } from '@design.estate/dees-element';
import type { DeesForm } from './dees-form.js';
import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => html`
<style>
.demoContainer {
max-width: 400px;
margin: 24px auto;
padding: 16px;
background: ${cssManager.bdTheme('#eeeeeb', '#111')};
box-shadow: 0px 1px 3px #00000030;
}
</style>
<div class="demoContainer">
<dees-form
style="display: block; margin:auto; max-width: 500px; padding: 20px"
@formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget;
form.setStatus('pending', 'authenticating...');
await domtools.plugins.smartdelay.delayFor(1000);
form.setStatus('success', 'authenticated!');
}}
>
<dees-input-dropdown
.label=${'title'}
.options=${[
{ option: 'option 1', key: 'option1' },
{ option: 'option 2', key: 'option2' },
{ option: 'option 3', key: 'option3' },
]}
></dees-input-dropdown>
<dees-input-multiselect
.label=${'title'}
.options=${[
{ option: 'option 1', key: 'option1' },
{ option: 'option 2', key: 'option2' },
{ option: 'option 3', key: 'option3' },
]}></dees-input-multiselect>
<dees-input-typelist
.label=${'a type list'}
></dees-input-typelist>
<dees-input-text .required="${true}" key="hello1" label="a text" .description=${`
This is an awesome description.
`}></dees-input-text>
<dees-input-text .required="${true}" key="hello2" label="also a text"></dees-input-text>
<dees-input-text
.required="${true}"
key="hello3"
label="a password"
isPasswordBool
></dees-input-text>
<dees-input-checkbox
.required="${true}"
key="hello3"
label="another text"
></dees-input-checkbox>
<dees-input-iban></dees-input-iban>
<dees-input-multitoggle
.label=${'multi select'}
.options=${['option 1', 'option 2', 'option 3']}
.selectedOption=${'option 1'}
></dees-input-multitoggle>
<dees-input-fileupload
.label=${'attachments'}
></dees-input-fileupload>
<dees-form-submit>Submit</dees-form-submit>
</dees-form>
</div>
<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;
}
`}
</style>
<div class="demo-container">
<dees-panel .heading="Complete Form Example" .description="A comprehensive form with various input types, validation, and form submission handling">
<dees-form
@formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget;
form.setStatus('pending', 'Processing...');
await domtools.plugins.smartdelay.delayFor(2000);
form.setStatus('success', 'Form submitted successfully!');
await domtools.plugins.smartdelay.delayFor(2000);
form.reset();
}}
>
<dees-input-text
.required=${true}
key="firstName"
label="First Name"
.description=${'Your given name'}
></dees-input-text>
<dees-input-text
.required=${true}
key="lastName"
label="Last Name"
></dees-input-text>
<dees-input-text
.required=${true}
key="email"
label="Email Address"
.description=${'We will use this to contact you'}
></dees-input-text>
<dees-input-dropdown
.required=${true}
key="country"
.label=${'Country'}
.options=${[
{ option: 'United States', key: 'us' },
{ option: 'Canada', key: 'ca' },
{ option: 'Germany', key: 'de' },
{ option: 'France', key: 'fr' },
{ option: 'United Kingdom', key: 'uk' },
]}
></dees-input-dropdown>
<dees-input-text
.required=${true}
key="password"
label="Password"
isPasswordBool
.description=${'Minimum 8 characters'}
></dees-input-text>
<dees-input-checkbox
.required=${true}
key="terms"
label="I agree to the Terms and Conditions"
></dees-input-checkbox>
<dees-input-checkbox
key="newsletter"
label="Send me promotional emails"
.value=${true}
></dees-input-checkbox>
<dees-form-submit>Create Account</dees-form-submit>
</dees-form>
</dees-panel>
<dees-panel .heading="Horizontal Form Layout" .description="Compact form with inputs arranged horizontally - perfect for filters and quick forms">
<dees-form horizontal-layout>
<dees-input-text
key="search"
label="Search"
></dees-input-text>
<dees-input-dropdown
key="category"
.label=${'Category'}
.enableSearch=${false}
.options=${[
{ option: 'All', key: 'all' },
{ option: 'Products', key: 'products' },
{ option: 'Services', key: 'services' },
{ option: 'Support', key: 'support' },
]}
></dees-input-dropdown>
<dees-input-dropdown
key="sort"
.label=${'Sort By'}
.enableSearch=${false}
.options=${[
{ option: 'Newest', key: 'newest' },
{ option: 'Popular', key: 'popular' },
{ option: 'Price: Low to High', key: 'price_asc' },
{ option: 'Price: High to Low', key: 'price_desc' },
]}
></dees-input-dropdown>
<dees-input-checkbox
key="inStock"
label="In Stock Only"
.value=${true}
></dees-input-checkbox>
</dees-form>
</dees-panel>
<dees-panel .heading="Advanced Form Features" .description="Form with specialized input types and complex validation">
<dees-form
@formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget;
const data = eventArg.detail.data;
console.log('Form data:', data);
form.setStatus('success', 'Data logged to console!');
}}
>
<dees-input-iban
key="iban"
label="IBAN"
.required=${true}
></dees-input-iban>
<dees-input-phone
key="phone"
label="Phone Number"
.required=${true}
></dees-input-phone>
<dees-input-multitoggle
key="preferences"
.label=${'Notification Preferences'}
.options=${['Email', 'SMS', 'Push', 'In-App']}
.selectedOption=${'Email'}
></dees-input-multitoggle>
<dees-input-multiselect
key="interests"
.label=${'Areas of Interest'}
.options=${[
{ option: 'Technology', key: 'tech' },
{ option: 'Design', key: 'design' },
{ option: 'Business', key: 'business' },
{ option: 'Marketing', key: 'marketing' },
{ option: 'Sales', key: 'sales' },
]}
></dees-input-multiselect>
<dees-input-fileupload
key="documents"
.label=${'Upload Documents'}
.description=${'PDF, DOC, or DOCX files up to 10MB'}
></dees-input-fileupload>
<dees-form-submit>Submit Application</dees-form-submit>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -4,34 +4,50 @@ import {
type TemplateResult,
DeesElement,
type CSSResult,
property,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputCheckbox } from './dees-input-checkbox.js';
import { DeesInputText } from './dees-input-text.js';
import { DeesInputQuantitySelector } from './dees-input-quantityselector.js';
import { DeesInputRadio } from './dees-input-radio.js';
import { DeesInputRadiogroup } from './dees-input-radiogroup.js';
import { DeesInputDropdown } from './dees-input-dropdown.js';
import { DeesInputFileupload } from './dees-input-fileupload.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 { demoFunc } from './dees-form.demo.js';
import { DeesInputIban } from './dees-input-iban.js';
// Unified set for form input types
const FORM_INPUT_TYPES = [
DeesInputCheckbox,
DeesInputDropdown,
DeesInputFileupload,
DeesInputIban,
DeesInputText,
DeesInputMultitoggle,
DeesInputPhone,
DeesInputQuantitySelector,
DeesInputRadio,
DeesInputRadiogroup,
DeesInputText,
DeesInputTypelist,
DeesTable,
];
export type TFormInputElement =
| DeesInputCheckbox
| DeesInputDropdown
| DeesInputFileupload
| DeesInputIban
| DeesInputText
| DeesInputMultitoggle
| DeesInputPhone
| DeesInputQuantitySelector
| DeesInputRadio
| DeesInputRadiogroup
| DeesInputText
| DeesInputTypelist
| DeesTable<any>;
declare global {
@ -48,6 +64,13 @@ export class DeesForm extends DeesElement {
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
public readyDeferred = domtools.plugins.smartpromise.defer();
/**
* Controls the layout mode of child input components
* When true, sets all child inputs to horizontal layout
*/
@property({ type: Boolean, reflect: true, attribute: 'horizontal-layout' })
public horizontalLayout: boolean = false;
public render(): TemplateResult {
return html`
<style>
@ -62,6 +85,7 @@ export class DeesForm extends DeesElement {
public async firstUpdated() {
const formChildren = this.getFormElements();
this.updateRequiredStatus();
this.updateChildrenLayoutMode();
for (const child of formChildren) {
child.changeSubject.subscribe(async () => {
@ -107,13 +131,17 @@ export class DeesForm extends DeesElement {
*/
public async collectFormData() {
const children = this.getFormElements();
const valueObject: { [key: string]: string | number | boolean | any[] } = {};
const valueObject: { [key: string]: string | number | boolean | any[] | File[] | { option: string; key: string; payload?: any } } = {};
for (const child of children) {
if (!child.key) {
console.log(`form element with label "${child.label}" has no key. skipping.`);
continue;
}
valueObject[child.key] = child.value;
}
return valueObject;
}
@ -202,4 +230,28 @@ export class DeesForm extends DeesElement {
}
});
}
/**
* Updates the layout mode of child input components based on form's horizontalLayout property
*/
private updateChildrenLayoutMode() {
const formChildren = this.getFormElements();
for (const child of formChildren) {
if ('layoutMode' in child) {
// The child's auto mode will detect this form's horizontal-layout attribute
(child as any).layoutMode = 'auto';
}
}
}
/**
* Called when properties change
*/
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('horizontalLayout')) {
this.updateChildrenLayoutMode();
}
}
}

View File

@ -0,0 +1,14 @@
import { html } from '@design.estate/dees-element';
export function demoFunc() {
return html`
<dees-heading level="1">This is a H1 heading</dees-heading>
<dees-heading level="2">This is a H2 heading</dees-heading>
<dees-heading level="3">This is a H3 heading</dees-heading>
<dees-heading level="4">This is a H4 heading</dees-heading>
<dees-heading level="5">This is a H5 heading</dees-heading>
<dees-heading level="6">This is a H6 heading</dees-heading>
<dees-heading level="hr">This is an hr heading</dees-heading>
<dees-heading level="hr-small">This is an hr small heading</dees-heading>
`;
}

View File

@ -0,0 +1,116 @@
import {
customElement,
html,
css,
property,
cssManager,
type TemplateResult,
DeesElement,
type CSSResult,
} from '@design.estate/dees-element';
import { demoFunc } from './dees-heading.demo.js';
import { cssCalSansFontFamily } from './00fonts.js';
declare global {
interface HTMLElementTagNameMap {
'dees-heading': DeesHeading;
}
}
@customElement('dees-heading')
export class DeesHeading extends DeesElement {
// demo
public static demo = demoFunc;
// properties
/**
* Heading level: 1-6 for h1-h6, or 'hr' for horizontal rule style
*/
@property({ type: String, reflect: true })
public level: '1' | '2' | '3' | '4' | '5' | '6' | 'hr' | 'hr-small' = '1';
// STATIC STYLES
public static styles: CSSResult[] = [
cssManager.defaultStyles,
css`
/* Heading styles */
h1, h2, h3, h4, h5, h6 {
margin: 16px 0 8px;
font-weight: 600;
color: ${cssManager.bdTheme('#000', '#fff')};
}
h1 { font-size: 32px; font-family: ${cssCalSansFontFamily}; letter-spacing: 0.025em;}
h2 { font-size: 28px; }
h3 { font-size: 24px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 14px; }
/* Horizontal rule style heading */
.heading-hr {
display: flex;
align-items: center;
text-align: center;
margin: 16px 0;
color: ${cssManager.bdTheme('#000', '#fff')};
}
/* Fade lines toward and away from text for hr style */
.heading-hr::before {
content: '';
flex: 1;
height: 1px;
/* fade in toward center */
background: ${cssManager.bdTheme(
'linear-gradient(to right, transparent, #ccc)',
'linear-gradient(to right, transparent, #333)'
)};
margin: 0 8px;
}
.heading-hr::after {
content: '';
flex: 1;
height: 1px;
/* fade out away from center */
background: ${cssManager.bdTheme(
'linear-gradient(to right, #ccc, transparent)',
'linear-gradient(to right, #333, transparent)'
)};
margin: 0 8px;
}
/* Small hr variant with reduced margins */
.heading-hr.heading-hr-small {
margin: 8px 0;
font-size: 12px;
}
.heading-hr.heading-hr-small::before,
.heading-hr.heading-hr-small::after {
margin: 0 8px;
}
`,
];
// INSTANCE
public render(): TemplateResult {
switch (this.level) {
case '1':
return html`<h1><slot></slot></h1>`;
case '2':
return html`<h2><slot></slot></h2>`;
case '3':
return html`<h3><slot></slot></h3>`;
case '4':
return html`<h4><slot></slot></h4>`;
case '5':
return html`<h5><slot></slot></h5>`;
case '6':
return html`<h6><slot></slot></h6>`;
case 'hr':
return html`<div class="heading-hr"><slot></slot></div>`;
case 'hr-small':
return html`<div class="heading-hr heading-hr-small"><slot></slot></div>`;
default:
return html`<h1><slot></slot></h1>`;
}
}
}

View File

@ -0,0 +1,184 @@
import {
DeesElement,
property,
css,
type CSSResult,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
/**
* Base class for all dees-input components
* Provides unified margin system and layout mode support
*/
export abstract class DeesInputBase<T = any> extends DeesElement {
/**
* Layout mode for the input component
* - vertical: Traditional form layout (label on top)
* - horizontal: Inline layout (label position configurable)
* - auto: Detect from parent context
*/
@property({ type: String })
public layoutMode: 'vertical' | 'horizontal' | 'auto' = 'auto';
/**
* Position of the label relative to the input
*/
@property({ type: String })
public labelPosition: 'top' | 'left' | 'right' | 'none' = 'top';
/**
* Common properties for all inputs
*/
@property({ type: String })
public key: string;
@property({ type: String })
public label: string;
@property({ type: Boolean })
public required: boolean = false;
@property({ type: Boolean })
public disabled: boolean = false;
@property({ type: String })
public description: string;
/**
* Common styles for all input components
*/
public static get baseStyles(): CSSResult[] {
return [
css`
/* CSS Variables for consistent spacing */
:host {
--dees-input-spacing-unit: 8px;
--dees-input-vertical-gap: calc(var(--dees-input-spacing-unit) * 2); /* 16px */
--dees-input-horizontal-gap: calc(var(--dees-input-spacing-unit) * 2); /* 16px */
--dees-input-label-gap: var(--dees-input-spacing-unit); /* 8px */
}
/* Default vertical stacking mode (for forms) */
:host {
display: block;
margin: 0;
margin-bottom: var(--dees-input-vertical-gap);
}
/* Last child in container should have no bottom margin */
:host(:last-child) {
margin-bottom: 0;
}
/* Horizontal layout mode - activated by attribute */
:host([layout-mode="horizontal"]) {
display: inline-block;
margin: 0;
margin-right: var(--dees-input-horizontal-gap);
margin-bottom: 0;
}
:host([layout-mode="horizontal"]:last-child) {
margin-right: 0;
}
/* Auto mode - inherit from parent dees-form if present */
/* Label position variations */
:host([label-position="left"]) .input-wrapper {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--dees-input-label-gap);
align-items: center;
}
:host([label-position="right"]) .input-wrapper {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--dees-input-label-gap);
align-items: center;
}
:host([label-position="top"]) .input-wrapper {
display: block;
}
:host([label-position="none"]) dees-label {
display: none;
}
`,
];
}
/**
* Subject for value changes that all inputs should implement
*/
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<T>();
/**
* Called when the element is connected to the DOM
* Sets up layout mode detection
*/
async connectedCallback() {
await super.connectedCallback();
this.detectLayoutMode();
}
/**
* Detects the appropriate layout mode based on parent context
*/
private detectLayoutMode() {
if (this.layoutMode !== 'auto') {
this.setAttribute('layout-mode', this.layoutMode);
return;
}
// Check if parent is a form with horizontal layout
const parentForm = this.closest('dees-form');
if (parentForm && parentForm.hasAttribute('horizontal-layout')) {
this.setAttribute('layout-mode', 'horizontal');
} else {
this.setAttribute('layout-mode', 'vertical');
}
}
/**
* Updates the layout mode attribute when property changes
*/
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('layoutMode')) {
this.detectLayoutMode();
}
if (changedProperties.has('labelPosition')) {
this.setAttribute('label-position', this.labelPosition);
}
}
/**
* Standard method for freezing input (disabling)
*/
public async freeze() {
this.disabled = true;
}
/**
* Standard method for unfreezing input (enabling)
*/
public async unfreeze() {
this.disabled = false;
}
/**
* Abstract method that child classes must implement to get their value
*/
public abstract getValue(): any;
/**
* Abstract method that child classes must implement to set their value
*/
public abstract setValue(value: any): void;
}

View File

@ -0,0 +1,307 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
import type { DeesInputCheckbox } from './dees-input-checkbox.js';
import './dees-button.js';
export const demoFunc = () => html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
// Get all checkboxes for demo interactions
const checkboxes = elementArg.querySelectorAll('dees-input-checkbox');
// Example of programmatic interaction
const selectAllBtn = elementArg.querySelector('#select-all-btn');
const clearAllBtn = elementArg.querySelector('#clear-all-btn');
if (selectAllBtn && clearAllBtn) {
selectAllBtn.addEventListener('click', () => {
checkboxes.forEach((checkbox: DeesInputCheckbox) => {
if (!checkbox.disabled && checkbox.key?.startsWith('feature')) {
checkbox.value = true;
}
});
});
clearAllBtn.addEventListener('click', () => {
checkboxes.forEach((checkbox: DeesInputCheckbox) => {
if (!checkbox.disabled && checkbox.key?.startsWith('feature')) {
checkbox.value = false;
}
});
});
}
}}>
<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;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.horizontal-checkboxes {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.interactive-section {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
.output-text {
font-family: monospace;
font-size: 13px;
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(210 40% 80%)')};
padding: 8px;
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')};
border-radius: 4px;
min-height: 24px;
}
.form-section {
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 8px;
padding: 20px;
margin-top: 16px;
}
.button-group {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.feature-list {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 16.8%)')};
border-radius: 6px;
padding: 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic Checkboxes'} .subtitle=${'Simple checkbox examples with various labels'}>
<div class="checkbox-group">
<dees-input-checkbox
.label=${'I agree to the Terms and Conditions'}
.value=${true}
.key=${'terms'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Subscribe to newsletter'}
.value=${false}
.key=${'newsletter'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Enable notifications'}
.value=${false}
.description=${'Receive email updates about your account'}
.key=${'notifications'}
></dees-input-checkbox>
</div>
</dees-panel>
<dees-panel .title=${'Checkbox States'} .subtitle=${'Different checkbox states and configurations'}>
<div class="checkbox-group">
<dees-input-checkbox
.label=${'Default state'}
.value=${false}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Checked state'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Disabled unchecked'}
.value=${false}
.disabled=${true}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Disabled checked'}
.value=${true}
.disabled=${true}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Required checkbox'}
.required=${true}
.key=${'required'}
></dees-input-checkbox>
</div>
</dees-panel>
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Checkboxes arranged horizontally for compact forms'}>
<div class="horizontal-checkboxes">
<dees-input-checkbox
.label=${'Option A'}
.value=${false}
.layoutMode=${'horizontal'}
.key=${'optionA'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Option B'}
.value=${true}
.layoutMode=${'horizontal'}
.key=${'optionB'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Option C'}
.value=${false}
.layoutMode=${'horizontal'}
.key=${'optionC'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Option D'}
.value=${true}
.layoutMode=${'horizontal'}
.key=${'optionD'}
></dees-input-checkbox>
</div>
</dees-panel>
<dees-panel .title=${'Feature Selection Example'} .subtitle=${'Common use case for feature toggles with batch operations'}>
<div class="button-group">
<dees-button id="select-all-btn" type="secondary">Select All</dees-button>
<dees-button id="clear-all-btn" type="secondary">Clear All</dees-button>
</div>
<div class="feature-list">
<div class="checkbox-group">
<dees-input-checkbox
.label=${'Dark Mode Support'}
.value=${true}
.key=${'feature1'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Email Notifications'}
.value=${true}
.key=${'feature2'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Two-Factor Authentication'}
.value=${false}
.key=${'feature3'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'API Access'}
.value=${true}
.key=${'feature4'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Advanced Analytics'}
.value=${false}
.key=${'feature5'}
></dees-input-checkbox>
</div>
</div>
</dees-panel>
<dees-panel .title=${'Privacy Settings Example'} .subtitle=${'Checkboxes in a typical form context'}>
<div class="form-section">
<h4 class="section-title">Privacy Preferences</h4>
<div class="checkbox-group">
<dees-input-checkbox
.label=${'Share analytics data'}
.value=${true}
.description=${'Help us improve by sharing anonymous usage data'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Personalized recommendations'}
.value=${true}
.description=${'Get suggestions based on your activity'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Marketing communications'}
.value=${false}
.description=${'Receive promotional emails and special offers'}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Third-party integrations'}
.value=${false}
.description=${'Allow approved partners to access your data'}
></dees-input-checkbox>
</div>
</div>
</dees-panel>
<dees-panel .title=${'Interactive Example'} .subtitle=${'Click checkboxes to see value changes'}>
<div class="checkbox-group">
<dees-input-checkbox
.label=${'Feature toggle'}
.value=${false}
@changeSubject=${(event: CustomEvent) => {
const output = document.querySelector('#checkbox-output');
if (output && event.detail) {
const isChecked = event.detail.getValue();
output.textContent = `Feature is ${isChecked ? 'enabled' : 'disabled'}`;
}
}}
></dees-input-checkbox>
<dees-input-checkbox
.label=${'Debug mode'}
.value=${false}
@changeSubject=${(event: CustomEvent) => {
const output = document.querySelector('#debug-output');
if (output && event.detail) {
const isChecked = event.detail.getValue();
output.textContent = `Debug mode: ${isChecked ? 'ON' : 'OFF'}`;
}
}}
></dees-input-checkbox>
</div>
<div class="interactive-section">
<div id="checkbox-output" class="output-text">Feature is disabled</div>
<div id="debug-output" class="output-text" style="margin-top: 8px;">Debug mode: OFF</div>
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,14 +1,14 @@
import {
customElement,
DeesElement,
type TemplateResult,
property,
html,
css,
cssManager,
type CSSResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-checkbox.demo.js';
import { cssGeistFontFamily } from './00fonts.js';
declare global {
interface HTMLElementTagNameMap {
@ -17,148 +17,162 @@ declare global {
}
@customElement('dees-input-checkbox')
export class DeesInputCheckbox extends DeesElement {
export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
// STATIC
public static demo = () => html`<dees-input-checkbox></dees-input-checkbox>`;
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property({
type: String,
reflect: true,
})
public key: string;
@property({
type: String,
})
public label: string = 'Label';
@property({
type: Boolean,
})
public value: boolean = false;
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean
})
public disabled: boolean = false;
constructor() {
super();
this.labelPosition = 'right'; // Checkboxes default to label on the right
}
public render(): TemplateResult {
return html`
${domtools.elementBasic.styles}
<style>
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
position: relative;
margin: 20px 0px;
cursor: default;
}
:host(:hover) {
filter: ${cssManager.bdTheme('brightness(0.95)', 'brightness(1.1)')};
font-family: ${cssGeistFontFamily};
}
.maincontainer {
display: grid;
grid-template-columns: 25px auto;
padding: 5px 0px;
color: ${this.goBright ? '#333' : '#ccc'};
}
.maincontainer:hover {
${this.goBright ? '#000' : '#ccc'};
}
.label {
margin-left: 15px;
line-height: 25px;
font-size: 14px;
font-weight: normal;
}
input:focus {
outline: none;
border-bottom: 1px solid #e4002b;
display: inline-flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
}
.checkbox {
transition: all 0.1s;
box-sizing: border-box;
border: 1px solid ${this.goBright ? '#CCC' : '#999'};
border-radius: 2px;
height: 24px;
width: 24px;
display: inline-block;
background: ${this.goBright ? '#fafafa' : '#222'};
position: relative;
height: 18px;
width: 18px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
transition: all 0.15s ease;
margin-top: 1px;
}
.maincontainer:hover .checkbox {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.checkbox.selected {
background: #0050b9;
border: 1px solid #0050b9;
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
}
.checkbox:focus-visible {
outline: none;
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
}
/* Checkmark using Lucide icon style */
.checkbox .checkmark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.15s ease;
}
.checkbox.selected .checkmark {
opacity: 1;
}
.checkbox .checkmark svg {
width: 12px;
height: 12px;
stroke: white;
stroke-width: 3;
}
/* Disabled state */
.maincontainer.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.checkbox.disabled {
background: none;
border: 1px dashed ${cssManager.bdTheme('#666666', '#666666')};
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.checkbox .checkmark {
display: inline-block;
width: 22px;
height: 22px;
-ms-transform: rotate(45deg); /* IE 9 */
-webkit-transform: rotate(45deg); /* Chrome, Safari, Opera */
transform: rotate(45deg);
/* Label */
.label-container {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.checkbox .checkmark_stem {
position: absolute;
width: 3px;
height: 9px;
background-color: #fff;
left: 11px;
top: 6px;
.checkbox-label {
font-size: 14px;
font-weight: 500;
line-height: 20px;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
transition: color 0.15s ease;
letter-spacing: -0.01em;
}
.checkbox .checkmark_kick {
position: absolute;
width: 3px;
height: 3px;
background-color: #fff;
left: 8px;
top: 12px;
.maincontainer:hover .checkbox-label {
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.checkbox.disabled .checkmark_stem, .checkbox.disabled .checkmark_kick {
background-color: ${cssManager.bdTheme('#333', '#fff')};
.maincontainer.disabled:hover .checkbox-label {
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
}
img {
padding: 4px;
/* Description */
.description-text {
font-size: 12px;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
line-height: 1.5;
}
</style>
<div class="maincontainer" @click="${this.toggleSelected}">
<div class="checkbox ${this.value ? 'selected' : ''} ${this.disabled ? 'disabled' : ''}" tabindex="0">
${this.value
? html`
<span class="checkmark">
<div class="checkmark_stem"></div>
<div class="checkmark_kick"></div>
</span>
`
: html``}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<div class="maincontainer ${this.disabled ? 'disabled' : ''}" @click="${this.toggleSelected}">
<div
class="checkbox ${this.value ? 'selected' : ''} ${this.disabled ? 'disabled' : ''}"
tabindex="${this.disabled ? '-1' : '0'}"
@keydown="${this.handleKeydown}"
>
${this.value
? html`
<span class="checkmark">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
`
: html``}
</div>
<div class="label-container">
${this.label ? html`<div class="checkbox-label">${this.label}</div>` : ''}
${this.description ? html`<div class="description-text">${this.description}</div>` : ''}
</div>
</div>
<div class="label">${this.label}</div>
</div>
`;
}
@ -177,10 +191,25 @@ export class DeesInputCheckbox extends DeesElement {
this.changeSubject.next(this);
}
public getValue(): boolean {
return this.value;
}
public setValue(value: boolean): void {
this.value = value;
}
public focus(): void {
const checkboxDiv = this.shadowRoot.querySelector('.checkbox');
if (checkboxDiv) {
(checkboxDiv as any).focus();
}
}
private handleKeydown(event: KeyboardEvent) {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
this.toggleSelected();
}
}
}

View File

@ -1,27 +1,221 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
import './dees-form.js';
import './dees-form-submit.js';
export const demoFunc = () => html`
<dees-input-dropdown
.options=${[
{option: 'option 1', key: 'option1'},
{option: 'option 2', key: 'option2'},
{option: 'option 3', key: 'option3'}
]}
></dees-input-dropdown>
<dees-input-dropdown
.enableSearch=${false}
.options=${[
{option: 'option 1', key: 'option1'},
{option: 'option 2', key: 'option2'},
{option: 'option 3', key: 'option3'}
]}
></dees-input-dropdown>
<div style="height: 300px"></div>
<dees-input-dropdown
.options=${[
{option: 'option 1', key: 'option1'},
{option: 'option 2', key: 'option2'},
{option: 'option 3', key: 'option3'}
]}
></dees-input-dropdown>
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.spacer {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic Dropdowns'} .subtitle=${'Standard dropdown with search functionality and various options'}>
<dees-input-dropdown
.label=${'Select Country'}
.options=${[
{ option: 'United States', key: 'us' },
{ option: 'Canada', key: 'ca' },
{ option: 'Germany', key: 'de' },
{ option: 'France', key: 'fr' },
{ option: 'United Kingdom', key: 'uk' },
{ option: 'Australia', key: 'au' },
{ option: 'Japan', key: 'jp' },
{ option: 'Brazil', key: 'br' }
]}
.selectedOption=${{ option: 'United States', key: 'us' }}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Select Role'}
.options=${[
{ option: 'Administrator', key: 'admin' },
{ option: 'Editor', key: 'editor' },
{ option: 'Viewer', key: 'viewer' },
{ option: 'Guest', key: 'guest' }
]}
></dees-input-dropdown>
</dees-panel>
<dees-panel .title=${'2. Without Search'} .subtitle=${'Dropdown with search functionality disabled for simpler selection'}>
<dees-input-dropdown
.label=${'Priority Level'}
.enableSearch=${false}
.options=${[
{ option: 'High', key: 'high' },
{ option: 'Medium', key: 'medium' },
{ option: 'Low', key: 'low' }
]}
.selectedOption=${{ option: 'Medium', key: 'medium' }}
></dees-input-dropdown>
</dees-panel>
<dees-panel .title=${'3. Horizontal Layout'} .subtitle=${'Multiple dropdowns in a horizontal layout for compact forms'}>
<div class="horizontal-group">
<dees-input-dropdown
.label=${'Department'}
.layoutMode=${'horizontal'}
.options=${[
{ option: 'Engineering', key: 'eng' },
{ option: 'Design', key: 'design' },
{ option: 'Marketing', key: 'marketing' },
{ option: 'Sales', key: 'sales' }
]}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Team Size'}
.layoutMode=${'horizontal'}
.enableSearch=${false}
.options=${[
{ option: '1-5', key: 'small' },
{ option: '6-20', key: 'medium' },
{ option: '21-50', key: 'large' },
{ option: '50+', key: 'xlarge' }
]}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Location'}
.layoutMode=${'horizontal'}
.options=${[
{ option: 'Remote', key: 'remote' },
{ option: 'On-site', key: 'onsite' },
{ option: 'Hybrid', key: 'hybrid' }
]}
></dees-input-dropdown>
</div>
</dees-panel>
<dees-panel .title=${'4. States'} .subtitle=${'Different states and configurations'}>
<dees-input-dropdown
.label=${'Required Field'}
.required=${true}
.options=${[
{ option: 'Option A', key: 'a' },
{ option: 'Option B', key: 'b' },
{ option: 'Option C', key: 'c' }
]}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Disabled Dropdown'}
.disabled=${true}
.options=${[
{ option: 'Cannot Select', key: 'disabled' }
]}
.selectedOption=${{ option: 'Cannot Select', key: 'disabled' }}
></dees-input-dropdown>
</dees-panel>
<div class="spacer">
(Spacer to test dropdown positioning)
</div>
<dees-panel .title=${'5. Bottom Positioning'} .subtitle=${'Dropdown that opens upward when near bottom of viewport'}>
<dees-input-dropdown
.label=${'Opens Upward'}
.options=${[
{ option: 'First Option', key: 'first' },
{ option: 'Second Option', key: 'second' },
{ option: 'Third Option', key: 'third' },
{ option: 'Fourth Option', key: 'fourth' },
{ option: 'Fifth Option', key: 'fifth' }
]}
></dees-input-dropdown>
</dees-panel>
<dees-panel .title=${'6. Event Handling & Payload'} .subtitle=${'Dropdown with payload data and change event handling'}>
<dees-input-dropdown
.label=${'Select Product'}
.options=${[
{ option: 'Basic Plan', key: 'basic', payload: { price: 9.99, features: ['Feature A'] } },
{ option: 'Pro Plan', key: 'pro', payload: { price: 19.99, features: ['Feature A', 'Feature B'] } },
{ option: 'Enterprise Plan', key: 'enterprise', payload: { price: 49.99, features: ['Feature A', 'Feature B', 'Feature C'] } }
]}
@change=${(e: CustomEvent) => {
const output = document.querySelector('#selection-output');
if (output && e.detail.value) {
output.innerHTML = `
<strong>Selected:</strong> ${e.detail.value.option}<br>
<strong>Key:</strong> ${e.detail.value.key}<br>
<strong>Price:</strong> $${e.detail.value.payload?.price || 'N/A'}<br>
<strong>Features:</strong> ${e.detail.value.payload?.features?.join(', ') || 'N/A'}
`;
}
}}
></dees-input-dropdown>
<div id="selection-output" style="margin-top: 16px; padding: 12px; background: rgba(0, 105, 242, 0.1); border-radius: 4px; font-size: 14px;">
<em>Select a product to see details...</em>
</div>
</dees-panel>
<dees-panel .title=${'7. Form Integration'} .subtitle=${'Dropdown working within a form with validation'}>
<dees-form>
<dees-input-dropdown
.label=${'Project Type'}
.key=${'projectType'}
.required=${true}
.options=${[
{ option: 'Web Application', key: 'web' },
{ option: 'Mobile Application', key: 'mobile' },
{ option: 'Desktop Application', key: 'desktop' },
{ option: 'API Service', key: 'api' }
]}
></dees-input-dropdown>
<dees-input-dropdown
.label=${'Development Framework'}
.key=${'framework'}
.required=${true}
.options=${[
{ option: 'React', key: 'react', payload: { type: 'web' } },
{ option: 'Vue.js', key: 'vue', payload: { type: 'web' } },
{ option: 'Angular', key: 'angular', payload: { type: 'web' } },
{ option: 'React Native', key: 'react-native', payload: { type: 'mobile' } },
{ option: 'Flutter', key: 'flutter', payload: { type: 'mobile' } },
{ option: 'Electron', key: 'electron', payload: { type: 'desktop' } }
]}
></dees-input-dropdown>
<dees-form-submit .text=${'Create Project'}></dees-form-submit>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`

View File

@ -1,17 +1,16 @@
import {
customElement,
DeesElement,
type TemplateResult,
property,
state,
html,
css,
cssManager,
type CSSResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-input-dropdown.demo.js';
import { DeesWindowLayer } from './dees-windowlayer.js';
import { DeesInputBase } from './dees-input-base.js';
import { cssGeistFontFamily } from './00fonts.js';
declare global {
interface HTMLElementTagNameMap {
@ -20,20 +19,10 @@ declare global {
}
@customElement('dees-input-dropdown')
export class DeesInputDropdown extends DeesElement {
export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property({
type: String,
reflect: true,
})
public label: string = 'Label';
@property()
public key: string;
@property()
public options: { option: string; key: string; payload?: any }[] = [];
@ -41,21 +30,20 @@ export class DeesInputDropdown extends DeesElement {
@property()
public selectedOption: { option: string; key: string; payload?: any } = null;
@property({
type: Boolean,
})
public required: boolean = false;
// Add value property for form compatibility
public get value() {
return this.selectedOption;
}
public set value(val: { option: string; key: string; payload?: any }) {
this.selectedOption = val;
}
@property({
type: Boolean,
})
public enableSearch: boolean = true;
@property({
type: Boolean,
})
public disabled: boolean = false;
@state()
public opensToTop: boolean = false;
@ -68,7 +56,11 @@ export class DeesInputDropdown extends DeesElement {
@state()
public isOpened = false;
@state()
private searchValue: string = '';
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
@ -76,189 +68,280 @@ export class DeesInputDropdown extends DeesElement {
}
:host {
font-family: Roboto;
font-family: ${cssGeistFontFamily};
position: relative;
display: block;
color: ${cssManager.bdTheme('#222', '#fff')};
margin-bottom: 24px;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
}
.maincontainer {
display: block;
}
.label {
font-size: 14px;
margin-bottom: 8px;
position: relative;
}
.selectedBox {
user-select: none;
position: relative;
max-width: 420px;
width: 100%;
height: 40px;
line-height: 40px;
padding: 0px 8px;
background: ${cssManager.bdTheme('#fafafa', '#222222')};
box-shadow: ${cssManager.bdTheme('0px 1px 4px rgba(0,0,0,0.3)', 'none')};
border-radius: 3px;
border-top: ${cssManager.bdTheme('1px solid #CCC', '1px solid #ffffff10')};
border-bottom: ${cssManager.bdTheme('1px solid #CCC', '1px solid #222')};
transition: all 0.2s ease;
font-size: 16px;
color: ${cssManager.bdTheme('#222', '#ccc')};
line-height: 38px;
padding: 0 40px 0 12px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
transition: all 0.15s ease;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selectedBox:hover {
filter: ${cssManager.bdTheme('brightness(0.95)', 'brightness(1.1)')};
.selectedBox:hover:not(.disabled) {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.accentBottom {
filter: none !important;
.selectedBox:focus-visible {
outline: none;
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
}
.accentTop {
filter: none !important;
.selectedBox.disabled {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
cursor: not-allowed;
opacity: 0.5;
}
/* Dropdown arrow */
.selectedBox::after {
content: '';
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
transition: transform 0.15s ease;
}
.selectedBox.open::after {
transform: translateY(-50%) rotate(180deg);
}
.selectionBox {
will-change: transform;
will-change: transform, opacity;
pointer-events: none;
transition: all 0.2s ease;
transition: all 0.15s ease;
opacity: 0;
background: ${cssManager.bdTheme('#ffffff', '#222222')};
max-width: 420px;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
transform: translateY(-8px) scale(0.98);
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1), 0 2px 4px -2px hsl(0 0% 0% / 0.1);
min-height: 40px;
border-radius: 8px;
padding: 4px 8px;
max-height: 300px;
overflow: hidden;
border-radius: 6px;
position: absolute;
user-select: none;
margin: 3px 0px 0px 0px;
border-top: ${cssManager.bdTheme('1px solid #CCC', '1px solid #ffffff10')};
margin-top: 4px;
z-index: 50;
left: 0;
right: 0;
}
.selectionBox.top {
transform: translate(0px, 4px);
bottom: calc(100% + 4px);
top: auto;
margin-top: 0;
margin-bottom: 4px;
transform: translateY(8px) scale(0.98);
}
.selectionBox.bottom {
transform: translate(0px, -4px);
top: 100%;
}
.selectionBox.show {
pointer-events: all;
transform: scale(1, 1) translate(0px, 0px);
transform: translateY(0) scale(1);
opacity: 1;
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.8);
}
/* Options container */
.options-container {
max-height: 250px;
overflow-y: auto;
padding: 4px;
}
/* Options */
.option {
transition: all 0.1s;
transition: all 0.15s ease;
line-height: 32px;
padding: 0px 4px;
border-radius: 3px;
margin: 4px 0px;
padding: 0 8px;
border-radius: 4px;
margin: 2px 0;
cursor: pointer;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
}
.option.highlighted {
border-left: 2px solid #0069f2;
padding-left: 6px;
background: #ffffff20;
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
}
.option:hover {
color: #fff;
padding-left: 8px;
background: #0069f2;
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.search.top {
padding-top: 4px;
/* No options message */
.no-options {
padding: 8px;
text-align: center;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
font-style: italic;
}
/* Search */
.search {
padding: 4px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin-bottom: 4px;
}
.search.bottom {
padding-bottom: 4px;
border-bottom: none;
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin-bottom: 0;
margin-top: 4px;
}
.search input {
display: block;
background: none;
border: none;
height: 24px;
color: inherit;
text-align: left;
font-size: 12px;
font-weight: 600;
width: 100%;
margin: auto;
height: 32px;
padding: 0 8px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 4px;
color: inherit;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease;
}
.search.top input {
border-bottom: 1px dotted #333;
}
.search.bottom input {
border-top: 1px dotted #333;
.search input::placeholder {
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
}
.search input:focus {
outline: none;
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
}
/* Scrollbar styling */
.options-container::-webkit-scrollbar {
width: 8px;
}
.options-container::-webkit-scrollbar-track {
background: transparent;
}
.options-container::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 4px;
}
.options-container::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
`,
];
public render(): TemplateResult {
return html`
<div class="maincontainer" @keydown="${this.isOpened ? this.handleKeyDown : undefined}">
${this.label ? html`<div class="label">${this.label}</div>` : html``}
<div class="selectionBox">
${this.enableSearch && !this.opensToTop
? html`
<div class="search top">
<input type="text" placeholder="Search" @input="${this.handleSearch}" />
</div>
`
: null}
${this.filteredOptions.map((option, index) => {
const isHighlighted = this.highlightedIndex === index;
return html`
<div
class="option ${isHighlighted ? 'highlighted' : ''}"
@click=${() => {
this.updateSelection(option);
}}
>
${option.option}
</div>
`;
})}
${this.enableSearch && this.opensToTop
? html`
<div class="search bottom">
<input type="text" placeholder="Search" @input="${this.handleSearch}" />
</div>
`
: null}
</div>
<div
class="selectedBox"
@click="${(event) => {
if (!this.isElevated) {
this.toggleSelectionBox();
} else {
this.updateSelection(this.selectedOption);
}
}}"
>
${this.selectedOption?.option || 'Select...'}
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
<div class="maincontainer">
<div
class="selectedBox ${this.isOpened ? 'open' : ''} ${this.disabled ? 'disabled' : ''}"
@click="${() => !this.disabled && this.toggleSelectionBox()}"
tabindex="${this.disabled ? '-1' : '0'}"
@keydown="${this.handleSelectedBoxKeydown}"
>
${this.selectedOption?.option || 'Select an option'}
</div>
<div class="selectionBox ${this.isOpened ? 'show' : ''} ${this.opensToTop ? 'top' : 'bottom'}">
${this.enableSearch
? html`
<div class="search">
<input
type="text"
placeholder="Search options..."
.value="${this.searchValue}"
@input="${this.handleSearch}"
@click="${(e: Event) => e.stopPropagation()}"
@keydown="${this.handleSearchKeydown}"
/>
</div>
`
: null}
<div class="options-container">
${this.filteredOptions.length === 0
? html`<div class="no-options">No options found</div>`
: this.filteredOptions.map((option, index) => {
const isHighlighted = this.highlightedIndex === index;
return html`
<div
class="option ${isHighlighted ? 'highlighted' : ''}"
@click="${() => this.updateSelection(option)}"
@mouseenter="${() => this.highlightedIndex = index}"
>
${option.option}
</div>
`;
})
}
</div>
</div>
</div>
</div>
`;
}
firstUpdated() {
this.selectedOption = this.selectedOption || null;
this.filteredOptions = this.options; // Initialize filteredOptions
async connectedCallback() {
super.connectedCallback();
this.handleClickOutside = this.handleClickOutside.bind(this);
}
public async updateSelection(selectedOption) {
firstUpdated() {
this.selectedOption = this.selectedOption || null;
this.filteredOptions = this.options;
}
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('options')) {
this.filteredOptions = this.options;
}
}
public async updateSelection(selectedOption: { option: string; key: string; payload?: any }) {
this.selectedOption = selectedOption;
this.isOpened = false;
this.searchValue = '';
this.filteredOptions = this.options;
this.highlightedIndex = 0;
this.dispatchEvent(
new CustomEvent('selectedOption', {
@ -266,110 +349,118 @@ export class DeesInputDropdown extends DeesElement {
bubbles: true,
})
);
if (this.isElevated) {
this.toggleSelectionBox();
}
this.changeSubject.next(this);
}
private isElevated: boolean = false;
private windowOverlay: DeesWindowLayer;
private handleClickOutside = (event: MouseEvent) => {
const path = event.composedPath();
if (!path.includes(this)) {
this.isOpened = false;
this.searchValue = '';
this.filteredOptions = this.options;
document.removeEventListener('click', this.handleClickOutside);
}
};
public async toggleSelectionBox() {
this.isOpened = !this.isOpened;
const domtoolsInstance = await this.domtoolsPromise;
const selectedBox: HTMLElement = this.shadowRoot.querySelector('.selectedBox');
const selectionBox: HTMLElement = this.shadowRoot.querySelector('.selectionBox');
if (!this.isElevated) {
this.windowOverlay = await DeesWindowLayer.createAndShow({
blur: false,
});
const elevatedDropdown = new DeesInputDropdown();
elevatedDropdown.isElevated = true;
elevatedDropdown.label = this.label;
elevatedDropdown.enableSearch = this.enableSearch;
elevatedDropdown.required = this.required;
elevatedDropdown.disabled = this.disabled;
elevatedDropdown.style.position = 'fixed';
elevatedDropdown.style.top = this.getBoundingClientRect().top + 'px';
elevatedDropdown.style.left = this.getBoundingClientRect().left + 'px';
elevatedDropdown.style.width = this.clientWidth + 'px';
elevatedDropdown.options = this.options;
elevatedDropdown.selectedOption = this.selectedOption;
elevatedDropdown.highlightedIndex = elevatedDropdown.selectedOption ? elevatedDropdown.options.indexOf(
elevatedDropdown.options.find((option) => option.key === this.selectedOption.key)
) : -1;
console.log(elevatedDropdown.options);
console.log(elevatedDropdown.selectedOption);
console.log(elevatedDropdown.highlightedIndex);
this.windowOverlay.appendChild(elevatedDropdown);
await domtoolsInstance.convenience.smartdelay.delayFor(0);
elevatedDropdown.toggleSelectionBox();
const destroyOverlay = async () => {
(elevatedDropdown.shadowRoot.querySelector('.selectionBox') as HTMLElement).style.opacity =
'0';
elevatedDropdown.removeEventListener('selectedOption', handleSelection);
this.windowOverlay.removeEventListener('clicked', destroyOverlay);
this.windowOverlay.destroy();
};
const handleSelection = async (event) => {
await this.updateSelection(elevatedDropdown.selectedOption);
destroyOverlay();
};
elevatedDropdown.addEventListener('selectedOption', handleSelection);
this.windowOverlay.addEventListener('clicked', destroyOverlay);
} else {
if (!selectionBox.classList.contains('show')) {
selectionBox.style.width = selectedBox.clientWidth + 'px';
const spaceData = selectedBox.getBoundingClientRect();
if (300 > window.innerHeight - spaceData.bottom) {
this.opensToTop = true;
selectedBox.classList.add('accentTop');
selectionBox.classList.add('top');
selectionBox.style.bottom = selectedBox.clientHeight + 2 + 'px';
} else {
selectedBox.classList.add('accentBottom');
selectionBox.classList.add('bottom');
this.opensToTop = false;
const labelOffset = this.label ? 24 : 0;
selectionBox.style.top = selectedBox.clientHeight + labelOffset + 'px';
}
await domtoolsInstance.convenience.smartdelay.delayFor(0);
const searchInput = selectionBox.querySelector('input');
searchInput?.focus();
selectionBox.classList.add('show');
} else {
selectedBox.style.pointerEvents = 'none';
selectionBox.classList.remove('show');
// selectedBox.style.opacity = '0';
if (this.isOpened) {
// Check available space and set position
const selectedBox = this.shadowRoot.querySelector('.selectedBox') as HTMLElement;
const rect = selectedBox.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
// Determine if we should open upwards
this.opensToTop = spaceBelow < 300 && spaceAbove > spaceBelow;
// Focus search input if present
await this.updateComplete;
const searchInput = this.shadowRoot.querySelector('.search input') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
// Add click outside listener
setTimeout(() => {
document.addEventListener('click', this.handleClickOutside);
}, 0);
} else {
// Cleanup
this.searchValue = '';
this.filteredOptions = this.options;
document.removeEventListener('click', this.handleClickOutside);
}
}
private handleSearch(event: Event): void {
const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
const searchTerm = (event.target as HTMLInputElement).value;
this.searchValue = searchTerm;
const searchLower = searchTerm.toLowerCase();
this.filteredOptions = this.options.filter((option) =>
option.option.toLowerCase().includes(searchTerm)
option.option.toLowerCase().includes(searchLower)
);
this.highlightedIndex = 0; // Reset highlighted index
this.highlightedIndex = 0;
}
private handleKeyDown(event: KeyboardEvent): void {
if (!this.isOpened) {
console.log('discarded key event. Check why this function is called.');
return;
}
const key = event.key;
const maxIndex = this.filteredOptions.length - 1;
if (key === 'ArrowDown') {
event.preventDefault();
this.highlightedIndex = this.highlightedIndex + 1 > maxIndex ? 0 : this.highlightedIndex + 1;
event.preventDefault();
} else if (key === 'ArrowUp') {
event.preventDefault();
this.highlightedIndex = this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1;
event.preventDefault();
} else if (key === 'Enter') {
this.updateSelection(this.filteredOptions[this.highlightedIndex]);
event.preventDefault();
if (this.filteredOptions[this.highlightedIndex]) {
this.updateSelection(this.filteredOptions[this.highlightedIndex]);
}
} else if (key === 'Escape') {
event.preventDefault();
this.isOpened = false;
}
}
private handleSearchKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
this.handleKeyDown(event);
}
}
private handleSelectedBoxKeydown(event: KeyboardEvent) {
if (this.disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggleSelectionBox();
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!this.isOpened) {
this.toggleSelectionBox();
}
} else if (event.key === 'Escape') {
event.preventDefault();
if (this.isOpened) {
this.isOpened = false;
}
}
}
public getValue(): { option: string; key: string; payload?: any } {
return this.selectedOption;
}
public setValue(value: { option: string; key: string; payload?: any }): void {
this.selectedOption = value;
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleClickOutside);
}
}

View File

@ -0,0 +1,204 @@
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>
`;

View File

@ -2,6 +2,8 @@ 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,
@ -23,23 +25,9 @@ declare global {
}
@customElement('dees-input-fileupload')
export class DeesInputFileupload extends DeesElement {
public static demo = () =>
html`<dees-input-fileupload .label=${'Attachments'}></dees-input-fileupload>`;
export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property({
type: String,
})
public label: string = null;
@property({
type: String,
reflect: true,
})
public key: string;
@property({
attribute: false,
@ -49,170 +37,471 @@ export class DeesInputFileupload extends DeesElement {
@property()
public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean,
})
public disabled: 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: grid;
margin: 10px 0px;
margin-bottom: 24px;
color: ${cssManager.bdTheme('#333', '#ccc')};
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: 3px;
padding: 8px;
background: ${cssManager.bdTheme('#fafafa', '#222222')};
color: ${cssManager.bdTheme('#333', '#ccc')};
border-top: 1px solid #ffffff10;
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: 2px;
right: 2px;
left: 2px;
bottom: 2px;
transform: scale3d(0.98, 0.9, 1);
top: 1px;
right: 1px;
left: 1px;
bottom: 1px;
transform: scale3d(0.98, 0.95, 1);
position: absolute;
content: '';
display: block;
border: 2px dashed rgba(255, 255, 255, 0);
transition: all 0.2s;
border: 2px dashed transparent;
border-radius: 5px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
background: #00000000;
}
.maincontainer.dragOver::after {
transform: scale3d(1, 1, 1);
border: 2px dashed rgba(255, 255, 255, 0.3);
background: #00000080;
background: transparent;
}
.label {
font-size: 14px;
margin-bottom: 8px;
.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: 8px;
max-width: 600px;
background: ${cssManager.bdTheme('#fafafa', '#333333')};
border-radius: 3px;
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;
cursor: default;
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 {
color: #fff;
background: ${unsafeCSS(colors.dark.blue)};
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: 48px auto;
background: #333;
padding: 8px 8px 8px 0px;
margin-bottom: 8px;
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: 3px;
color: ${cssManager.bdTheme('#666', '#ccc')};
font-family: 'Geist Sans', sans-serif;
border-radius: 6px;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
cursor: default;
transition: all 0.2s;
border-top: 1px solid #ffffff10;
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:last-child {
margin-bottom: 8px;
.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: 16px;
font-size: 20px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
}
.uploadCandidate:hover {
background: #393939;
.uploadCandidate.image-file .icon {
color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
}
.uploadCandidate .description {
.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;
border-left: 1px solid #ffffff10;
padding-left: 8px;
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;
}
`,
];
public render(): TemplateResult {
const hasFiles = this.value.length > 0;
const showClearAll = hasFiles && this.value.length > 1;
return html`
<div class="hidden">
<input type="file"></div>
</div>
${this.label ? html`<div class="label">${this.label}</div>` : null}
<div class="maincontainer ${this.state === 'dragOver' ? 'dragOver' : ''}">
${this.value.map(
(fileArg) => html`
<div class="uploadCandidate" @contextmenu=${eventArg => {
DeesContextmenu.openContextMenuWithOptions(eventArg, [{
iconName: 'trash',
name: 'Remove',
action: async () => {
this.value.splice(this.value.indexOf(fileArg), 1);
this.requestUpdate();
}
}]);
}}>
<div class="icon">
<dees-icon .iconFA=${'paperclip'}></dees-icon>
</div>
<div class="description">
<span style="font-weight: 600">${fileArg.name}</span><br />
<span style="font-weight: 400">${fileArg.size}</span>
</div>
</div> `
)}
<div class="uploadButton" @click=${
this.openFileSelector
}>
${this.buttonText}
<div class="input-wrapper">
${this.label ? html`
<dees-label .label=${this.label}></dees-label>
` : ''}
<div class="hidden">
<input
type="file"
?multiple=${this.multiple}
accept="${this.accept}"
>
</div>
<div class="maincontainer ${this.state === 'dragOver' ? 'dragOver' : ''}">
${hasFiles ? html`
${showClearAll ? html`
<div class="clear-all-button">
<button @click=${this.clearAll}>Clear All</button>
</div>
` : ''}
<div class="files-container">
${this.value.map((fileArg) => {
const fileType = this.getFileType(fileArg);
const isImage = fileType === 'image';
return html`
<div class="uploadCandidate ${fileType}-file">
<div class="icon">
${isImage && this.canShowPreview(fileArg) ? html`
<img class="image-preview" src="${URL.createObjectURL(fileArg)}" alt="${fileArg.name}">
` : html`
<dees-icon .iconName=${this.getFileIcon(fileArg)}></dees-icon>
`}
</div>
<div class="info">
<div class="filename" title="${fileArg.name}">${fileArg.name}</div>
<div class="filesize">${this.formatFileSize(fileArg.size)}</div>
</div>
<div class="actions">
<button
class="remove-button"
@click=${() => this.removeFile(fileArg)}
title="Remove file"
>
<dees-icon .iconName=${'lucide:x'}></dees-icon>
</button>
</div>
</div>
`;
})}
</div>
` : html`
<div class="drop-hint">
<dees-icon .iconName=${'lucide:cloud-upload'}></dees-icon>
<div>Drag files here or click to browse</div>
</div>
`}
<div class="uploadButton" @click=${this.openFileSelector}>
<dees-icon .iconName=${'lucide:upload'}></dees-icon>
${this.buttonText}
</div>
</div>
${this.description ? html`
<div class="description-text">${this.description}</div>
` : ''}
${this.validationState === 'invalid' && this.validationMessage ? html`
<div class="validation-message">${this.validationMessage}</div>
` : ''}
</div>
`;
}
private validationMessage: string = '';
// Utility methods
private formatFileSize(bytes: number): string {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
private getFileType(file: File): string {
const type = file.type.toLowerCase();
if (type.startsWith('image/')) return 'image';
if (type === 'application/pdf') return 'pdf';
if (type.includes('word') || type.includes('document')) return 'doc';
if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet';
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
if (type.startsWith('video/')) return 'video';
if (type.startsWith('audio/')) return 'audio';
if (type.includes('zip') || type.includes('compressed')) return 'archive';
return 'file';
}
private getFileIcon(file: File): string {
const type = this.getFileType(file);
const iconMap = {
'image': 'lucide:image',
'pdf': 'lucide:file-text',
'doc': 'lucide:file-text',
'spreadsheet': 'lucide:table',
'presentation': 'lucide:presentation',
'video': 'lucide:video',
'audio': 'lucide:music',
'archive': 'lucide:archive',
'file': 'lucide:file'
};
return iconMap[type] || 'lucide:file';
}
private canShowPreview(file: File): boolean {
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; // 5MB limit for previews
}
private validateFile(file: File): boolean {
// Check file size
if (this.maxSize > 0 && file.size > this.maxSize) {
this.validationMessage = `File "${file.name}" exceeds maximum size of ${this.formatFileSize(this.maxSize)}`;
this.validationState = 'invalid';
return false;
}
// Check file type
if (this.accept) {
const acceptedTypes = this.accept.split(',').map(s => s.trim());
let isAccepted = false;
for (const acceptType of acceptedTypes) {
if (acceptType.startsWith('.')) {
// Extension check
if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) {
isAccepted = true;
break;
}
} else if (acceptType.endsWith('/*')) {
// MIME type wildcard check
const mimePrefix = acceptType.slice(0, -2);
if (file.type.startsWith(mimePrefix)) {
isAccepted = true;
break;
}
} else if (file.type === acceptType) {
// Exact MIME type check
isAccepted = true;
break;
}
}
if (!isAccepted) {
this.validationMessage = `File type not accepted. Please upload: ${this.accept}`;
this.validationState = 'invalid';
return false;
}
}
return true;
}
public async openFileSelector() {
if (this.disabled) return;
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
inputFile.click();
this.state = 'idle';
this.buttonText = 'Upload more files...';
}
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) {
@ -221,46 +510,134 @@ export class DeesInputFileupload extends DeesElement {
this.changeSubject.next(this);
}
public firstUpdated() {
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
inputFile.addEventListener('change', (event: Event) => {
inputFile.addEventListener('change', async (event: Event) => {
const target = event.target as HTMLInputElement;
for (const file of Array.from(target.files)) {
this.value.push(file);
}
this.requestUpdate();
console.log(`Got ${this.value.length} files!`);
const newFiles = Array.from(target.files);
await this.addFiles(newFiles);
// Reset the input value to allow selecting the same file again if needed
target.value = '';
});
// lets handle drag and drop
// Handle drag and drop
const dropArea = this.shadowRoot.querySelector('.maincontainer');
const handlerFunction = (eventArg: DragEvent) => {
const handlerFunction = async (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
switch (eventArg.type) {
case 'dragenter':
case 'dragover':
this.state = 'dragOver';
this.buttonText = 'release to upload file...';
break;
case 'dragleave':
this.state = 'idle';
this.buttonText = 'Upload File...';
// 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';
this.buttonText = 'Upload more files...';
const files = Array.from(eventArg.dataTransfer.files);
await this.addFiles(files);
break;
}
console.log(eventArg);
for (const file of Array.from(eventArg.dataTransfer.files)) {
this.value.push(file);
this.requestUpdate();
}
console.log(`Got ${this.value.length} files!`);
};
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();
}
}
}

View File

@ -1,3 +1,80 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html`<dees-input-iban .label=${'IBAN'}></dees-input-iban>`;
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;
}
.payment-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic IBAN Input'} .subtitle=${'International Bank Account Number with automatic formatting'}>
<dees-input-iban
.label=${'Bank Account IBAN'}
.description=${'Enter your International Bank Account Number'}
></dees-input-iban>
<dees-input-iban
.label=${'Verified IBAN'}
.description=${'This IBAN has been verified'}
.value=${'DE89370400440532013000'}
></dees-input-iban>
</dees-panel>
<dees-panel .title=${'Payment Information'} .subtitle=${'IBAN input with horizontal layout for payment forms'}>
<div class="payment-group">
<dees-input-text
.label=${'Account Holder'}
.layoutMode=${'horizontal'}
.value=${'John Doe'}
></dees-input-text>
<dees-input-iban
.label=${'IBAN'}
.layoutMode=${'horizontal'}
.value=${'GB82WEST12345698765432'}
></dees-input-iban>
</div>
</dees-panel>
<dees-panel .title=${'Validation & States'} .subtitle=${'Required fields and disabled states'}>
<dees-input-iban
.label=${'Payment Account'}
.description=${'Required for processing payments'}
.required=${true}
></dees-input-iban>
<dees-input-iban
.label=${'Locked IBAN'}
.description=${'This IBAN cannot be changed'}
.value=${'FR1420041010050500013M02606'}
.disabled=${true}
></dees-input-iban>
</dees-panel>
<dees-panel .title=${'Bank Transfer Form'} .subtitle=${'Complete form example with IBAN validation'}>
<dees-form>
<dees-input-text .label=${'Recipient Name'} .required=${true}></dees-input-text>
<dees-input-iban .label=${'Recipient IBAN'} .required=${true}></dees-input-iban>
<dees-input-text .label=${'Transfer Reference'} .description=${'Optional reference for the transfer'}></dees-input-text>
<dees-input-text .label=${'Amount'} .inputType=${'number'} .required=${true}></dees-input-text>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,18 +1,19 @@
import {
customElement,
DeesElement,
type TemplateResult,
state,
html,
domtools,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
import * as ibantools from 'ibantools';
import { demoFunc } from './dees-input-iban.demo.js';
@customElement('dees-input-iban')
export class DeesInputIban extends DeesElement {
export class DeesInputIban extends DeesInputBase<DeesInputIban> {
// STATIC
public static demo = demoFunc;
@ -23,60 +24,44 @@ export class DeesInputIban extends DeesElement {
@state()
enteredIbanIsValid: boolean = false;
@property({
type: Boolean,
})
public disabled = false;
@property({
type: Boolean,
})
public required = false;
@property({
type: String,
})
public label = '';
@property({
type: String,
})
public key = '';
@property({
type: String,
})
public value = '';
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesInputIban>();
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
/* IBAN input specific styles can go here */
`,
];
public render(): TemplateResult {
return html`
<style>
input[type='text'] {
line-height: 20px;
padding: 5px;
width: 250px;
}
</style>
<dees-input-text
.label=${'IBAN'}
.value=${this.value}
@input=${(eventArg: InputEvent) => {
this.validateIban(eventArg);
}}
></dees-input-text>
<div class="input-wrapper">
<dees-label .label=${this.label || 'IBAN'} .description=${this.description}></dees-label>
<dees-input-text
.value=${this.value}
.disabled=${this.disabled}
.required=${this.required}
.placeholder=${'DE89 3704 0044 0532 0130 00'}
@input=${(eventArg: InputEvent) => {
this.validateIban(eventArg);
}}
></dees-input-text>
</div>
`;
}
public async firstUpdated() {
const deesInputText = this.shadowRoot.querySelector('dees-input-text');
deesInputText.disabled = this.disabled;
deesInputText.required = this.required;
deesInputText.changeSubject.subscribe(valueArg => {
this.value = valueArg.value;
this.changeSubject.next(this);
})
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
const deesInputText = this.shadowRoot.querySelector('dees-input-text') as any;
if (deesInputText && deesInputText.changeSubject) {
deesInputText.changeSubject.subscribe(() => {
this.changeSubject.next(this);
});
}
}
public async validateIban(eventArg: InputEvent): Promise<void> {
@ -95,4 +80,13 @@ export class DeesInputIban extends DeesElement {
const deesInputText = this.shadowRoot.querySelector('dees-input-text');
deesInputText.validationText = `IBAN is valid: ${this.enteredIbanIsValid}`;
}
public getValue(): string {
return this.value;
}
public setValue(value: string): void {
this.value = value;
this.enteredString = ibantools.friendlyFormatIBAN(value) || '';
}
}

View File

@ -1,14 +1,163 @@
import { html } from '@design.estate/dees-element';
import { html, css, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html`
<dees-input-multitoggle
.options=${['option 1', 'option 2', 'a longer option with multiple words']}
.selectedOption=${'option 2'}
></dees-input-multitoggle>
<dees-input-multitoggle
.type=${'boolean'}
.booleanTrueName=${'enabled'}
.booleanFalseName=${'disabled'}
.selectedOption=${'true'}
></dees-input-multitoggle>
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 32px;
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
min-height: 100vh;
}
.section {
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.section-description {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 24px;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
`}
</style>
<div class="demo-container">
<div class="section">
<div class="section-title">Multi-Option Toggle</div>
<div class="section-description">Select from multiple options with a smooth sliding indicator animation.</div>
<dees-input-multitoggle
.label=${'Display Mode'}
.description=${'Choose how content is displayed'}
.options=${['List View', 'Grid View', 'Compact']}
.selectedOption=${'Grid View'}
></dees-input-multitoggle>
<br><br>
<dees-input-multitoggle
.label=${'T-Shirt Size'}
.description=${'Select your preferred size'}
.options=${['XS', 'S', 'M', 'L', 'XL', 'XXL']}
.selectedOption=${'M'}
></dees-input-multitoggle>
</div>
<div class="section">
<div class="section-title">Boolean Toggle</div>
<div class="section-description">Simple on/off switches with customizable labels for clearer context.</div>
<dees-input-multitoggle
.label=${'Notifications'}
.description=${'Enable or disable push notifications'}
.type=${'boolean'}
.selectedOption=${'true'}
></dees-input-multitoggle>
<br><br>
<dees-input-multitoggle
.label=${'Theme Mode'}
.description=${'Switch between light and dark theme'}
.type=${'boolean'}
.booleanTrueName=${'Dark'}
.booleanFalseName=${'Light'}
.selectedOption=${'Dark'}
></dees-input-multitoggle>
</div>
<div class="section">
<div class="section-title">Settings Grid</div>
<div class="section-description">Configuration options arranged in a responsive grid layout.</div>
<div class="settings-grid">
<dees-input-multitoggle
.label=${'Auto-Save'}
.type=${'boolean'}
.booleanTrueName=${'Enabled'}
.booleanFalseName=${'Disabled'}
.selectedOption=${'Enabled'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Language'}
.options=${['English', 'German', 'French', 'Spanish']}
.selectedOption=${'English'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Quality'}
.options=${['Low', 'Medium', 'High', 'Ultra']}
.selectedOption=${'High'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'Privacy'}
.type=${'boolean'}
.booleanTrueName=${'Private'}
.booleanFalseName=${'Public'}
.selectedOption=${'Private'}
></dees-input-multitoggle>
</div>
</div>
<div class="section">
<div class="section-title">States & Form Integration</div>
<div class="section-description">Examples of disabled states and integration within forms.</div>
<dees-input-multitoggle
.label=${'Account Type'}
.description=${'This setting is locked'}
.options=${['Free', 'Pro', 'Enterprise']}
.selectedOption=${'Enterprise'}
.disabled=${true}
></dees-input-multitoggle>
<br><br>
<dees-form>
<dees-input-text .label=${'Project Name'} .required=${true}></dees-input-text>
<dees-input-multitoggle
.label=${'Visibility'}
.type=${'boolean'}
.booleanTrueName=${'Public'}
.booleanFalseName=${'Private'}
.selectedOption=${'Private'}
></dees-input-multitoggle>
<dees-input-multitoggle
.label=${'License'}
.options=${['MIT', 'Apache 2.0', 'GPL v3', 'Proprietary']}
.selectedOption=${'MIT'}
></dees-input-multitoggle>
</dees-form>
</div>
</div>
</dees-demowrapper>
`;

View File

@ -1,16 +1,16 @@
import {
customElement,
DeesElement,
type TemplateResult,
state,
html,
domtools,
property,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesInputBase } from './dees-input-base.js';
const { demoFunc } = await import('./dees-input-multitoggle.demo.js');
import * as colors from './00colors.js'
import { demoFunc } from './dees-input-multitoggle.demo.js';
declare global {
interface HTMLElementTagNameMap {
@ -19,18 +19,9 @@ declare global {
}
@customElement('dees-input-multitoggle')
export class DeesInputMultitoggle extends DeesElement {
export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
public static demo = demoFunc;
@property({
type: String,
})
public label: string;
@property({
type: String,
})
public description: string;
@property()
type: 'boolean' | 'multi' | 'single' = 'multi';
@ -49,75 +40,111 @@ export class DeesInputMultitoggle extends DeesElement {
@property()
selectedOption: string = '';
@property()
@property({ type: Boolean })
boolValue: boolean = false;
// Add value property for form compatibility
public get value(): string | boolean {
if (this.type === 'boolean') {
return this.selectedOption === this.booleanTrueName;
}
return this.selectedOption;
}
public set value(val: string | boolean) {
if (this.type === 'boolean' && typeof val === 'boolean') {
this.selectedOption = val ? this.booleanTrueName : this.booleanFalseName;
} else {
this.selectedOption = val as string;
}
this.requestUpdate();
// Defer indicator update to next frame if component not yet updated
if (this.hasUpdated) {
requestAnimationFrame(() => {
this.setIndicator();
});
}
}
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
color: ${cssManager.bdTheme('#333', '#ccc')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
user-select: none;
margin: 8px 0px 24px 0px;
}
.label {
font-size: 14px;
margin-bottom: 8px;
}
.selections {
position: relative;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
background: ${cssManager.bdTheme('#fff', '#222')};
width: min-content;
border-radius: 20px;
height: 32px;
border-top: 1px solid #ffffff10;
display: inline-flex;
align-items: center;
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
padding: 4px;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.option {
color: #ccc;
position: relative;
padding: 0px 16px;
line-height: 32px;
cursor: default;
width: min-content; /* Make the width as per the content */
white-space: nowrap; /* Prevent text wrapping */
transition: all 0.1s;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
transition: color 0.2s ease;
font-size: 14px;
transform: translateY(-1px);
font-weight: 500;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
line-height: 1;
z-index: 2;
}
.option:hover {
color: #fff;
color: ${cssManager.bdTheme('#18181b', '#e4e4e7')};
}
.option.selected {
color: #fff;
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.indicator {
opacity: 0;
position: absolute;
height: 24px;
left: 4px;
top: 3px;
border-radius: 16px;
background: #0050b9;
min-width: 36px;
height: calc(100% - 8px);
top: 4px;
border-radius: 6px;
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.15)', 'rgba(59, 130, 246, 0.15)')};
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.indicator.no-transition {
transition: none;
}
:host([disabled]) .selections {
opacity: 0.5;
cursor: not-allowed;
}
:host([disabled]) .option {
cursor: not-allowed;
pointer-events: none;
}
:host([disabled]) .indicator {
background: ${cssManager.bdTheme('rgba(113, 113, 122, 0.15)', 'rgba(113, 113, 122, 0.15)')};
}
`,
];
public render(): TemplateResult {
return html`
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<div class="mainbox">
<div class="selections">
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<div class="mainbox">
<div class="selections">
<div class="indicator"></div>
${this.options.map(
(option) =>
@ -125,35 +152,112 @@ export class DeesInputMultitoggle extends DeesElement {
${option}
</div> `
)}
</div>
</div>
</div>
`;
}
public async firstUpdated() {
public async connectedCallback() {
await super.connectedCallback();
// Initialize boolean options early
if (this.type === 'boolean' && this.options.length === 0) {
this.options = [this.booleanTrueName || 'true', this.booleanFalseName || 'false'];
// Set default selection for boolean if not set
if (!this.selectedOption) {
this.selectedOption = this.booleanFalseName || 'false';
}
}
// Set default selection to first option if not set
if (!this.selectedOption && this.options.length > 0) {
this.selectedOption = this.options[0];
}
}
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
// Update boolean options if they changed
if (this.type === 'boolean') {
this.options = [this.booleanTrueName || 'true', this.booleanFalseName || 'false'];
}
// Wait for the next frame to ensure DOM is fully rendered
await this.updateComplete;
// Wait for fonts to load
if (document.fonts) {
await document.fonts.ready;
}
// Wait one more frame after fonts are loaded
await new Promise(resolve => requestAnimationFrame(resolve));
// Now set the indicator
this.setIndicator();
}
public async handleSelection(optionArg: string) {
if (this.disabled) return;
this.selectedOption = optionArg;
this.requestUpdate();
this.changeSubject.next(this);
await this.updateComplete;
this.setIndicator();
}
private indicatorInitialized = false;
public async setIndicator() {
const indicator: HTMLDivElement = this.shadowRoot.querySelector('.indicator');
const selectedIndex = this.options.indexOf(this.selectedOption);
// If no valid selection, hide indicator
if (selectedIndex === -1 || !indicator) {
if (indicator) {
indicator.style.opacity = '0';
}
return;
}
const option: HTMLDivElement = this.shadowRoot.querySelector(
`.option:nth-child(${this.options.indexOf(this.selectedOption) + 2})`
`.option:nth-child(${selectedIndex + 2})`
);
if (indicator && option) {
indicator.style.width = `${option.clientWidth - 8}px`;
indicator.style.left = `${option.offsetLeft + 4}px`;
// Only disable transition for the very first positioning
if (!this.indicatorInitialized) {
indicator.classList.add('no-transition');
this.indicatorInitialized = true;
// Remove the no-transition class after a brief delay
setTimeout(() => {
indicator.classList.remove('no-transition');
}, 50);
}
indicator.style.width = `${option.clientWidth}px`;
indicator.style.left = `${option.offsetLeft}px`;
indicator.style.opacity = '1';
}
setTimeout(() => {
indicator.style.transition = 'all 0.1s';
}, 100);
}
public getValue(): string | boolean {
if (this.type === 'boolean') {
return this.selectedOption === this.booleanTrueName;
}
return this.selectedOption;
}
public setValue(value: string | boolean): void {
if (this.type === 'boolean' && typeof value === 'boolean') {
this.selectedOption = value ? (this.booleanTrueName || 'true') : (this.booleanFalseName || 'false');
} else {
this.selectedOption = value as string;
}
this.requestUpdate();
if (this.hasUpdated) {
requestAnimationFrame(() => {
this.setIndicator();
});
}
}
}

View File

@ -1,3 +1,80 @@
import { html } from '@design.estate/dees-element';
import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html`<dees-input-phone .label=${'Phone Number'}></dees-input-phone>`;
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;
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic Phone Input'} .subtitle=${'Automatic formatting for phone numbers'}>
<dees-input-phone
.label=${'Phone Number'}
.description=${'Enter your phone number with country code'}
.value=${'5551234567'}
></dees-input-phone>
<dees-input-phone
.label=${'Contact Phone'}
.description=${'Required for account verification'}
.required=${true}
.placeholder=${'+1 (555) 000-0000'}
></dees-input-phone>
</dees-panel>
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Phone inputs arranged horizontally'}>
<div class="horizontal-group">
<dees-input-phone
.label=${'Mobile'}
.layoutMode=${'horizontal'}
.value=${'4155551234'}
></dees-input-phone>
<dees-input-phone
.label=${'Office'}
.layoutMode=${'horizontal'}
.placeholder=${'+1 (800) 555-0000'}
></dees-input-phone>
</div>
</dees-panel>
<dees-panel .title=${'International Numbers'} .subtitle=${'Supports formatting for numbers with country codes'}>
<dees-input-phone
.label=${'International Contact'}
.description=${'Automatically formats international numbers'}
.value=${'441234567890'}
></dees-input-phone>
<dees-input-phone
.label=${'Emergency Contact'}
.value=${'911'}
.disabled=${true}
></dees-input-phone>
</dees-panel>
<dees-panel .title=${'Form Integration'} .subtitle=${'Phone input as part of a contact form'}>
<dees-form>
<dees-input-text .label=${'Full Name'} .required=${true}></dees-input-text>
<dees-input-phone .label=${'Phone Number'} .required=${true}></dees-input-phone>
<dees-input-text .label=${'Email'} .inputType=${'email'}></dees-input-text>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,14 +1,14 @@
import {
customElement,
DeesElement,
type TemplateResult,
property,
state,
html,
css,
unsafeCSS,
cssManager,
type CSSResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-phone.demo.js';
declare global {
@ -18,12 +18,116 @@ declare global {
}
@customElement('dees-input-phone')
export class DeesInputPhone extends DeesElement {
export class DeesInputPhone extends DeesInputBase<DeesInputPhone> {
// STATIC
public static demo = demoFunc;
// INSTANCE
public render() {
return html`<div>Phone Input</div>`;
@state()
protected formattedPhone: string = '';
@property({ type: String })
public value: string = '';
@property({ type: String })
public placeholder: string = '+1 (555) 123-4567';
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
/* Phone input specific styles can go here */
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<dees-input-text
.value=${this.formattedPhone}
.disabled=${this.disabled}
.required=${this.required}
.placeholder=${this.placeholder}
@input=${(event: InputEvent) => this.handlePhoneInput(event)}
></dees-input-text>
</div>
`;
}
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
// Initialize formatted phone from value
if (this.value) {
this.formattedPhone = this.formatPhoneNumber(this.value);
}
// Subscribe to the inner input's changes
const innerInput = this.shadowRoot.querySelector('dees-input-text') as any;
if (innerInput && innerInput.changeSubject) {
innerInput.changeSubject.subscribe(() => {
this.changeSubject.next(this);
});
}
}
private handlePhoneInput(event: InputEvent) {
const input = event.target as HTMLInputElement;
const cleanedValue = this.cleanPhoneNumber(input.value);
const formatted = this.formatPhoneNumber(cleanedValue);
// Update the input with formatted value
if (input.value !== formatted) {
const cursorPosition = input.selectionStart || 0;
input.value = formatted;
// Try to maintain cursor position intelligently
const newCursorPos = this.calculateCursorPosition(cleanedValue, formatted, cursorPosition);
input.setSelectionRange(newCursorPos, newCursorPos);
}
this.formattedPhone = formatted;
this.value = cleanedValue;
this.changeSubject.next(this);
}
private cleanPhoneNumber(value: string): string {
// Remove all non-numeric characters
return value.replace(/\D/g, '');
}
private formatPhoneNumber(value: string): string {
// Basic US phone number formatting
// This can be enhanced to support international formats
const cleaned = this.cleanPhoneNumber(value);
if (cleaned.length === 0) return '';
if (cleaned.length <= 3) return cleaned;
if (cleaned.length <= 6) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
if (cleaned.length <= 10) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
// For numbers longer than 10 digits, format as international
return `+${cleaned.slice(0, cleaned.length - 10)} (${cleaned.slice(-10, -7)}) ${cleaned.slice(-7, -4)}-${cleaned.slice(-4)}`;
}
private calculateCursorPosition(cleaned: string, formatted: string, oldPos: number): number {
// Simple cursor position calculation
// Count how many formatting characters are before the cursor
let formattingChars = 0;
for (let i = 0; i < oldPos && i < formatted.length; i++) {
if (!/\d/.test(formatted[i])) {
formattingChars++;
}
}
return Math.min(oldPos + formattingChars, formatted.length);
}
public getValue(): string {
return this.value;
}
public setValue(value: string): void {
this.value = value;
this.formattedPhone = this.formatPhoneNumber(value);
}
}

View File

@ -0,0 +1,208 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import './dees-shopping-productcard.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;
}
.shopping-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.cart-summary {
margin-top: 24px;
padding: 20px;
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 21.8%)')};
border-radius: 8px;
}
.cart-summary-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
}
.cart-total {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
margin-top: 16px;
border-top: 2px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic Quantity Selector'} .subtitle=${'Simple quantity input with increment/decrement buttons'}>
<dees-input-quantityselector
.label=${'Quantity'}
.description=${'Select the desired quantity'}
.value=${1}
></dees-input-quantityselector>
<dees-input-quantityselector
.label=${'Items in Cart'}
.description=${'Adjust the quantity of items'}
.value=${3}
></dees-input-quantityselector>
</dees-panel>
<dees-panel .title=${'Shopping Cart'} .subtitle=${'Modern e-commerce product cards with interactive quantity selectors'} .runAfterRender=${async (elementArg: HTMLElement) => {
const updateCartSummary = () => {
const card1 = elementArg.querySelector('#headphones-qty') as any;
const card2 = elementArg.querySelector('#mouse-qty') as any;
const card3 = elementArg.querySelector('#keyboard-qty') as any;
const qty1 = card1?.quantity || 0;
const qty2 = card2?.quantity || 0;
const qty3 = card3?.quantity || 0;
const price1 = 349.99 * qty1;
const price2 = 99.99 * qty2;
const price3 = 79.99 * qty3;
const total = price1 + price2 + price3;
const summary = elementArg.querySelector('#cart-summary-content');
if (summary) {
summary.innerHTML = `
${qty1 > 0 ? `<div class="cart-item">
<span>Sony WH-1000XM5 (${qty1})</span>
<span>$${price1.toFixed(2)}</span>
</div>` : ''}
${qty2 > 0 ? `<div class="cart-item">
<span>Logitech MX Master 3S (${qty2})</span>
<span>$${price2.toFixed(2)}</span>
</div>` : ''}
${qty3 > 0 ? `<div class="cart-item">
<span>Keychron K2 (${qty3})</span>
<span>$${price3.toFixed(2)}</span>
</div>` : ''}
${total === 0 ? '<div class="cart-item" style="text-align: center; color: #999;">Your cart is empty</div>' : ''}
<div class="cart-total">
<span>Total</span>
<span>$${total.toFixed(2)}</span>
</div>
`;
}
};
// Initial update
setTimeout(updateCartSummary, 100);
// Set up listeners
elementArg.querySelectorAll('dees-shopping-productcard').forEach(card => {
card.addEventListener('quantityChange', updateCartSummary);
});
}}>
<div class="shopping-grid">
<dees-shopping-productcard
id="headphones-qty"
.productData=${{
name: 'Sony WH-1000XM5 Wireless Headphones',
category: 'Audio',
description: 'Industry-leading noise canceling with Auto NC Optimizer',
price: 349.99,
originalPrice: 399.99,
iconName: 'lucide:headphones'
}}
.quantity=${1}
></dees-shopping-productcard>
<dees-shopping-productcard
id="mouse-qty"
.productData=${{
name: 'Logitech MX Master 3S',
category: 'Accessories',
description: 'Performance wireless mouse with ultra-fast scrolling',
price: 99.99,
iconName: 'lucide:mouse-pointer'
}}
.quantity=${2}
></dees-shopping-productcard>
<dees-shopping-productcard
id="keyboard-qty"
.productData=${{
name: 'Keychron K2 Wireless Mechanical Keyboard',
category: 'Keyboards',
description: 'Compact 75% layout with hot-swappable switches',
price: 79.99,
originalPrice: 94.99,
iconName: 'lucide:keyboard'
}}
.quantity=${1}
></dees-shopping-productcard>
</div>
<div class="cart-summary">
<h3 class="cart-summary-title">Order Summary</h3>
<div id="cart-summary-content">
<!-- Content will be dynamically updated -->
</div>
</div>
</dees-panel>
<dees-panel .title=${'Required & Disabled States'} .subtitle=${'Different states for validation and restrictions'}>
<dees-input-quantityselector
.label=${'Number of Licenses'}
.description=${'Select how many licenses you need'}
.required=${true}
.value=${1}
></dees-input-quantityselector>
<dees-input-quantityselector
.label=${'Fixed Quantity'}
.description=${'This quantity cannot be changed'}
.disabled=${true}
.value=${5}
></dees-input-quantityselector>
</dees-panel>
<dees-panel .title=${'Order Form'} .subtitle=${'Complete order form with quantity selection'}>
<dees-form>
<dees-input-text .label=${'Customer Name'} .required=${true}></dees-input-text>
<dees-input-dropdown
.label=${'Product'}
.options=${['Basic Plan', 'Pro Plan', 'Enterprise Plan']}
.required=${true}
></dees-input-dropdown>
<dees-input-quantityselector
.label=${'Quantity'}
.description=${'Number of licenses'}
.value=${1}
></dees-input-quantityselector>
<dees-input-text
.label=${'Special Instructions'}
.inputType=${'textarea'}
></dees-input-text>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,5 +1,7 @@
import { customElement, property, html, type TemplateResult, DeesElement, type CSSResult, } from '@design.estate/dees-element';
import { customElement, property, html, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-quantityselector.demo.js';
declare global {
interface HTMLElementTagNameMap {
@ -8,111 +10,177 @@ declare global {
}
@customElement('dees-input-quantityselector')
export class DeesInputQuantitySelector extends DeesElement {
public static demo = () => html`<dees-input-quantityselector></dees-input-quantityselector>`;
export class DeesInputQuantitySelector extends DeesInputBase<DeesInputQuantitySelector> {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property()
public label: string = 'Label';
@property({
type: String,
reflect: true,
})
public key: string;
@property({
type: Number
})
public value: number = 1;
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean
})
public disabled: boolean = false;
constructor() {
super();
}
public render(): TemplateResult {
return html`
${domtools.elementBasic.styles}
<style>
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
width: 110px;
width: auto;
user-select: none;
}
.maincontainer {
transition: all 0.1s;
.quantity-container {
transition: all 0.15s ease;
font-size: 14px;
display: grid;
grid-template-columns: 33% 34% 33%;
text-align: center;
background:none;
line-height: 40px;
padding: 0px;
min-width: 100px;
color: ${this.goBright ? '#666' : '#CCC'};
border: ${this.goBright ? '1px solid #333' : '1px solid #CCC'};
border-radius: 4px;
display: inline-flex;
align-items: center;
background: transparent;
height: 40px;
padding: 0;
min-width: 120px;
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;
overflow: hidden;
}
.mainContainer:hover {
color: ${this.goBright ? '#333' : '#fff'};
border: ${this.goBright ? '1px solid #333' : '1px solid #fff'};
.quantity-container.disabled {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
opacity: 0.5;
pointer-events: none;
}
.minus {
padding-left: 5px;
.quantity-container:hover:not(.disabled) {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.plus {
padding-right: 5px;
.quantity-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)')};
}
.selector {
text-align: center;
font-size: 20px;
flex: 0 0 40px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
position: relative;
}
.selector: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%)')};
}
.selector:active {
background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 11%)')};
}
.selector.minus {
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.selector.plus {
border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.quantity {
flex: 1;
text-align: center;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.006em;
}
/* Keyboard navigation focus styles */
.selector:focus {
outline: none;
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
z-index: 1;
}
</style>
/* Min value state */
.quantity-container[data-min="true"] .selector.minus {
opacity: 0.3;
cursor: not-allowed;
}
<div class="maincontainer">
<div class="selector minus" @click="${() => {this.decrease();}}">-</div>
<div class="quantity">${this.value}</div>
<div class="selector plus" @click="${() => {this.increase();}}">+</div>
.quantity-container[data-min="true"] .selector.minus:hover {
background: transparent;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
${this.label ? html`<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>` : ''}
<div
class="quantity-container ${this.disabled ? 'disabled' : ''}"
data-min="${this.value <= 0}"
>
<div
class="selector minus"
@click="${() => {this.decrease();}}"
tabindex="${this.disabled ? '-1' : '0'}"
@keydown="${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.decrease();
}
}}"
role="button"
aria-label="Decrease quantity"
></div>
<div class="quantity" aria-live="polite" aria-atomic="true">${this.value}</div>
<div
class="selector plus"
@click="${() => {this.increase();}}"
tabindex="${this.disabled ? '-1' : '0'}"
@keydown="${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.increase();
}
}}"
role="button"
aria-label="Increase quantity"
>+</div>
</div>
</div>
`;
}
public increase () {
this.value++;
this.changeSubject.next(this);
public increase() {
if (!this.disabled) {
this.value++;
this.changeSubject.next(this);
}
}
public decrease () {
if (this.value > 0) {
public decrease() {
if (!this.disabled && this.value > 0) {
this.value--;
} else {
// nothing to do here
this.changeSubject.next(this);
}
this.changeSubject.next(this);
}
public getValue(): number {
return this.value;
}
public setValue(value: number): void {
this.value = value;
}
}

View File

@ -1,127 +0,0 @@
import {customElement, DeesElement, type TemplateResult, property, html, type CSSResult,} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
declare global {
interface HTMLElementTagNameMap {
'dees-input-radio': DeesInputRadio;
}
}
@customElement('dees-input-radio')
export class DeesInputRadio extends DeesElement {
public static demo = () => html`<dees-input-radio></dees-input-radio>`;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject();
@property({
type: String,
reflect: true,
})
public key: string;
@property()
public label: string = 'Label';
@property()
public value: boolean = false;
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean
})
public disabled: boolean = false;
constructor() {
super();
}
public render(): TemplateResult {
return html `
<style>
* {
box-sizing: border-box;
}
:host {
display: block;
position: relative;
margin: 20px 0px;
}
.maincontainer {
transition: all 0.3s;
display: grid;
grid-template-columns: 25px auto;
padding: 5px 0px;
color: #ccc;
}
.maincontainer:hover {
color: #fff;
}
.label {
margin-left: 15px;
line-height: 25px;
font-size: 14px;
font-weight: normal;
}
input:focus {
outline: none;
border-bottom: 1px solid #e4002b;
}
.checkbox {
transition: all 0.3s;
box-sizing: border-box;
border-radius: 20px;
border: 1px solid #999;
height: 24px;
width: 24px;
display: inline-block;
background: #222;
}
.checkbox.selected {
background: #0050b9;
border: 1px solid #0050b9;
}
.maincontainer:hover .checkbox.selected {
background: #03A9F4;
}
.innercircle {
transition: all 0.3s;
margin: 6px 0px 0px 6px;
background: #222;
width: 10px;
height: 10px;
border-radius: 10px;
}
</style>
<div class="maincontainer" @click="${this.toggleSelected}">
<div class="checkbox ${this.value ? 'selected' : ''}">
${this.value ? html`<div class="innercircle"></div>`: html``}
</div>
<div class="label">${this.label}</div>
</div>
`;
}
public async toggleSelected () {
this.value = !this.value;
this.dispatchEvent(new CustomEvent('newValue', {
detail: this.value,
bubbles: true
}));
this.changeSubject.next(this);
}
}

View File

@ -0,0 +1,200 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.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;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.result-display {
margin-top: 16px;
padding: 12px;
background: rgba(0, 105, 242, 0.1);
border-radius: 4px;
font-family: monospace;
font-size: 14px;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic Radio Groups'} .subtitle=${'Simple string options for common use cases'}>
<div class="demo-grid">
<dees-input-radiogroup
.label=${'Subscription Plan'}
.options=${['Basic - $9/month', 'Pro - $29/month', 'Enterprise - $99/month']}
.selectedOption=${'Pro - $29/month'}
.description=${'Choose your subscription tier'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Priority Level'}
.options=${['High', 'Medium', 'Low']}
.selectedOption=${'Medium'}
.required=${true}
></dees-input-radiogroup>
</div>
</dees-panel>
<dees-panel .title=${'2. Horizontal Layout'} .subtitle=${'Radio groups with horizontal arrangement'}>
<dees-input-radiogroup
.label=${'Do you agree with the terms?'}
.options=${['Yes', 'No', 'Maybe']}
.direction=${'horizontal'}
.selectedOption=${'Yes'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Experience Level'}
.options=${['Beginner', 'Intermediate', 'Expert']}
.direction=${'horizontal'}
.selectedOption=${'Intermediate'}
.description=${'Select your experience level with web development'}
></dees-input-radiogroup>
</dees-panel>
<dees-panel .title=${'3. Advanced Options'} .subtitle=${'Using object format with keys and payloads'}>
<dees-input-radiogroup
id="advanced-radio"
.label=${'Select Region'}
.options=${[
{ option: 'United States (US East)', key: 'us-east', payload: { region: 'us-east-1', latency: 20 } },
{ option: 'Europe (Frankfurt)', key: 'eu-central', payload: { region: 'eu-central-1', latency: 50 } },
{ option: 'Asia Pacific (Singapore)', key: 'ap-southeast', payload: { region: 'ap-southeast-1', latency: 120 } }
]}
.selectedOption=${'eu-central'}
.description=${'Choose the closest region for optimal performance'}
@change=${(e: CustomEvent) => {
const display = document.querySelector('#region-result');
if (display) {
display.textContent = 'Selected: ' + JSON.stringify(e.detail.value, null, 2);
}
}}
></dees-input-radiogroup>
<div id="region-result" class="result-display">Selected: { "region": "eu-central-1", "latency": 50 }</div>
</dees-panel>
<dees-panel .title=${'4. Survey Example'} .subtitle=${'Multiple radio groups for surveys and forms'}>
<div class="demo-grid">
<dees-input-radiogroup
.label=${'How satisfied are you?'}
.options=${['Very Satisfied', 'Satisfied', 'Neutral', 'Dissatisfied', 'Very Dissatisfied']}
.selectedOption=${'Satisfied'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Would you recommend us?'}
.options=${['Definitely', 'Probably', 'Not Sure', 'Probably Not', 'Definitely Not']}
.selectedOption=${'Probably'}
></dees-input-radiogroup>
</div>
</dees-panel>
<dees-panel .title=${'5. States & Validation'} .subtitle=${'Different states and validation examples'}>
<div class="demo-grid">
<dees-input-radiogroup
.label=${'Required Selection'}
.options=${['Option A', 'Option B', 'Option C']}
.required=${true}
.description=${'This field is required'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Disabled State'}
.options=${['Disabled Option 1', 'Disabled Option 2', 'Disabled Option 3']}
.selectedOption=${'Disabled Option 2'}
.disabled=${true}
></dees-input-radiogroup>
</div>
</dees-panel>
<dees-panel .title=${'6. Settings Example'} .subtitle=${'Common patterns in application settings'}>
<dees-input-radiogroup
.label=${'Theme Preference'}
.options=${[
{ option: 'Light Theme', key: 'light', payload: 'light' },
{ option: 'Dark Theme', key: 'dark', payload: 'dark' },
{ option: 'System Default', key: 'system', payload: 'auto' }
]}
.selectedOption=${'dark'}
.description=${'Choose how the application should appear'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Notification Frequency'}
.options=${['All Notifications', 'Important Only', 'None']}
.selectedOption=${'Important Only'}
.description=${'Control how often you receive notifications'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Language'}
.options=${['English', 'German', 'French', 'Spanish', 'Japanese']}
.selectedOption=${'English'}
.direction=${'horizontal'}
></dees-input-radiogroup>
</dees-panel>
<dees-panel .title=${'7. Form Integration'} .subtitle=${'Works seamlessly with dees-form'}>
<dees-form>
<dees-input-text
.label=${'Product Name'}
.required=${true}
.key=${'productName'}
></dees-input-text>
<dees-input-radiogroup
.label=${'Product Category'}
.options=${['Electronics', 'Clothing', 'Books', 'Home & Garden', 'Sports']}
.required=${true}
.key=${'category'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Condition'}
.options=${['New', 'Like New', 'Good', 'Fair', 'Poor']}
.direction=${'horizontal'}
.key=${'condition'}
.selectedOption=${'New'}
></dees-input-radiogroup>
<dees-input-radiogroup
.label=${'Shipping Speed'}
.options=${[
{ option: 'Standard (5-7 days)', key: 'standard', payload: { days: 7, price: 0 } },
{ option: 'Express (2-3 days)', key: 'express', payload: { days: 3, price: 10 } },
{ option: 'Overnight', key: 'overnight', payload: { days: 1, price: 25 } }
]}
.selectedOption=${'standard'}
.key=${'shipping'}
></dees-input-radiogroup>
<dees-form-submit .text=${'Submit Product'}></dees-form-submit>
</dees-form>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -0,0 +1,357 @@
import {
customElement,
type TemplateResult,
property,
html,
css,
cssManager,
} from '@design.estate/dees-element';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-radiogroup.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-radiogroup': DeesInputRadiogroup;
}
}
type RadioOption = string | { option: string; key: string; payload?: any };
@customElement('dees-input-radiogroup')
export class DeesInputRadiogroup extends DeesInputBase<string | object> {
public static demo = demoFunc;
// INSTANCE
@property({ type: Array })
public options: RadioOption[] = [];
@property()
public selectedOption: string = '';
@property({ type: String })
public direction: 'vertical' | 'horizontal' = 'vertical';
@property({ type: String, reflect: true })
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
// Form compatibility
public get value() {
const option = this.getOptionByKey(this.selectedOption);
if (typeof option === 'object' && option.payload !== undefined) {
return option.payload;
}
return this.selectedOption;
}
public set value(val: string | any) {
if (typeof val === 'string') {
this.selectedOption = val;
} else {
// Try to find option by payload
const option = this.options.find(opt =>
typeof opt === 'object' && opt.payload === val
);
if (option && typeof option === 'object') {
this.selectedOption = option.key;
}
}
}
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
position: relative;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
.maincontainer {
display: flex;
flex-direction: column;
gap: 10px;
}
.maincontainer.horizontal {
flex-direction: row;
flex-wrap: wrap;
gap: 20px;
}
.radio-option {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
position: relative;
border-radius: 4px;
}
.maincontainer.horizontal .radio-option {
padding: 6px 20px 6px 0;
}
.radio-option:hover .radio-circle {
border-color: ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
}
.radio-option:hover .radio-label {
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
}
.radio-circle {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.radio-option.selected .radio-circle {
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%)', 'hsl(213.1 93.9% 67.8%)')};
}
.radio-option.selected .radio-circle::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
transform: scale(0);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.radio-option.selected .radio-circle::after {
transform: scale(1);
}
.radio-circle:focus-visible {
outline: none;
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 3.9%)')},
0 0 0 4px ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
}
.radio-label {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: -0.006em;
line-height: 20px;
}
.radio-option.selected .radio-label {
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
}
:host([disabled]) .radio-option {
cursor: not-allowed;
opacity: 0.5;
}
:host([disabled]) .radio-option:hover .radio-circle {
border-color: ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
}
:host([disabled]) .radio-option:hover .radio-label {
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
}
.label-text {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
margin-bottom: 10px;
letter-spacing: -0.006em;
line-height: 20px;
}
.description-text {
font-size: 13px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
margin-top: 10px;
line-height: 1.5;
letter-spacing: -0.003em;
}
/* Validation styles */
:host([validationState="invalid"]) .radio-circle {
border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
}
:host([validationState="invalid"]) .radio-option.selected .radio-circle {
border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
}
:host([validationState="valid"]) .radio-option.selected .radio-circle {
border-color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
background: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
}
:host([validationState="warn"]) .radio-option.selected .radio-circle {
border-color: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')};
background: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')};
}
/* Override base grid layout for radiogroup to prevent large gaps */
:host([label-position="left"]) .input-wrapper {
grid-template-columns: auto auto;
}
:host([label-position="right"]) .input-wrapper {
grid-template-columns: auto auto;
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
${this.label ? html`<div class="label-text">${this.label}</div>` : ''}
<div class="maincontainer ${this.direction}">
${this.options.map((option) => {
const optionKey = this.getOptionKey(option);
const optionLabel = this.getOptionLabel(option);
const isSelected = this.selectedOption === optionKey;
return html`
<div
class="radio-option ${isSelected ? 'selected' : ''}"
@click="${() => this.selectOption(optionKey)}"
@keydown="${(e: KeyboardEvent) => this.handleKeydown(e, optionKey)}"
>
<div
class="radio-circle"
tabindex="${this.disabled ? '-1' : '0'}"
role="radio"
aria-checked="${isSelected}"
aria-label="${optionLabel}"
></div>
<div class="radio-label">${optionLabel}</div>
</div>
`;
})}
</div>
${this.description ? html`<div class="description-text">${this.description}</div>` : ''}
</div>
`;
}
private getOptionKey(option: RadioOption): string {
if (typeof option === 'string') {
return option;
}
return option.key;
}
private getOptionLabel(option: RadioOption): string {
if (typeof option === 'string') {
return option;
}
return option.option;
}
private getOptionByKey(key: string): RadioOption | undefined {
return this.options.find(opt => this.getOptionKey(opt) === key);
}
private selectOption(key: string): void {
if (this.disabled) {
return;
}
const oldValue = this.selectedOption;
this.selectedOption = key;
if (oldValue !== key) {
this.dispatchEvent(new CustomEvent('change', {
detail: { value: this.value },
bubbles: true,
composed: true,
}));
this.dispatchEvent(new CustomEvent('input', {
detail: { value: this.value },
bubbles: true,
composed: true,
}));
this.changeSubject.next(this);
}
}
public getValue(): string | any {
return this.value;
}
public setValue(val: string | any): void {
this.value = val;
}
public async validate(): Promise<boolean> {
if (this.required && !this.selectedOption) {
this.validationState = 'invalid';
return false;
}
this.validationState = 'valid';
return true;
}
public async firstUpdated() {
// Auto-select first option if none selected and not required
if (!this.selectedOption && this.options.length > 0 && !this.required) {
const firstOption = this.options[0];
this.selectedOption = this.getOptionKey(firstOption);
}
}
private handleKeydown(event: KeyboardEvent, optionKey: string) {
if (this.disabled) return;
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
this.selectOption(optionKey);
} else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault();
this.focusNextOption();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault();
this.focusPreviousOption();
}
}
private focusNextOption() {
const radioCircles = Array.from(this.shadowRoot.querySelectorAll('.radio-circle'));
const currentIndex = radioCircles.findIndex(el => el === this.shadowRoot.activeElement);
const nextIndex = (currentIndex + 1) % radioCircles.length;
(radioCircles[nextIndex] as HTMLElement).focus();
}
private focusPreviousOption() {
const radioCircles = Array.from(this.shadowRoot.querySelectorAll('.radio-circle'));
const currentIndex = radioCircles.findIndex(el => el === this.shadowRoot.activeElement);
const prevIndex = currentIndex <= 0 ? radioCircles.length - 1 : currentIndex - 1;
(radioCircles[prevIndex] as HTMLElement).focus();
}
}

View File

@ -0,0 +1,133 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.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;
}
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic Rich Text Editor'} .subtitle=${'A full-featured rich text editor with formatting toolbar'}>
<dees-input-richtext
.label=${'Article Content'}
.value=${'<h1>Welcome to the Rich Text Editor!</h1><p>This is a feature-rich editor built with TipTap. You can:</p><ul><li><strong>Format text</strong> with <em>various</em> <u>styles</u></li><li>Create different heading levels</li><li>Add <a href="https://example.com">links</a> to external resources</li><li>Write <code>inline code</code> or code blocks</li></ul><blockquote><p>Use the toolbar above to explore all the formatting options available!</p></blockquote><p>Start typing to see the magic happen...</p>'}
.description=${'Use the toolbar to format your content with headings, lists, links, and more'}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'2. With Placeholder'} .subtitle=${'Empty editor with placeholder text'}>
<dees-input-richtext
.label=${'Blog Post'}
.placeholder=${'Start writing your blog post here...'}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'3. Different Heights'} .subtitle=${'Editors with different minimum heights for various use cases'}>
<div class="grid-layout">
<dees-input-richtext
.label=${'Short Note'}
.minHeight=${150}
.placeholder=${'Quick note...'}
.showWordCount=${false}
></dees-input-richtext>
<dees-input-richtext
.label=${'Extended Content'}
.minHeight=${300}
.placeholder=${'Write your extended content here...'}
.showWordCount=${true}
></dees-input-richtext>
</div>
</dees-panel>
<dees-panel .title=${'4. Code Examples'} .subtitle=${'Editor pre-filled with code examples'}>
<dees-input-richtext
.label=${'Technical Documentation'}
.value=${'<h2>Installation Guide</h2><p>To install the package, run the following command:</p><pre><code>npm install @design.estate/dees-catalog</code></pre><p>Then import the component in your TypeScript file:</p><pre><code>import { DeesInputRichtext } from "@design.estate/dees-catalog";</code></pre><p>You can now use the <code>&lt;dees-input-richtext&gt;</code> element in your templates.</p>'}
.minHeight=${250}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only rich text content'}>
<dees-input-richtext
.label=${'Published Article (Read Only)'}
.value=${'<h2>The Future of Web Components</h2><p>Web Components have revolutionized how we build modern web applications...</p><blockquote><p>"The future of web development lies in reusable, encapsulated components."</p></blockquote>'}
.disabled=${true}
.showWordCount=${true}
></dees-input-richtext>
</dees-panel>
<dees-panel .title=${'6. Interactive Demo'} .subtitle=${'Type in the editor below and see the HTML output'}>
<dees-input-richtext
id="interactive-editor"
.label=${'Try it yourself'}
.placeholder=${'Type something here...'}
.showWordCount=${true}
@change=${(e: CustomEvent) => {
const output = document.querySelector('#output-preview');
if (output) {
output.textContent = e.detail.value;
}
}}
></dees-input-richtext>
<div class="output-preview" id="output-preview">
<em>HTML output will appear here...</em>
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -0,0 +1,714 @@
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();
}
}
}

View File

@ -0,0 +1,248 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.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;
}
}
.tag-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
background: #f9fafb;
border-radius: 4px;
min-height: 40px;
align-items: center;
}
@media (prefers-color-scheme: dark) {
.tag-preview {
background: #1f2937;
}
}
.tag-preview-item {
display: inline-block;
padding: 4px 12px;
background: #e0e7ff;
color: #4338ca;
border-radius: 12px;
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.tag-preview-item {
background: #312e81;
color: #c7d2fe;
}
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic Tags Input'} .subtitle=${'Simple tag input with common programming languages'}>
<dees-input-tags
.label=${'Programming Languages'}
.placeholder=${'Add a language...'}
.value=${['JavaScript', 'TypeScript', 'Python', 'Go']}
.description=${'Press Enter or comma to add tags'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'2. Tags with Suggestions'} .subtitle=${'Auto-complete suggestions for faster input'}>
<dees-input-tags
.label=${'Tech Stack'}
.placeholder=${'Type to see suggestions...'}
.suggestions=${[
'React', 'Vue', 'Angular', 'Svelte', 'Lit', 'Next.js', 'Nuxt', 'SvelteKit',
'Node.js', 'Deno', 'Bun', 'Express', 'Fastify', 'Nest.js', 'Koa',
'MongoDB', 'PostgreSQL', 'Redis', 'MySQL', 'SQLite', 'Cassandra',
'Docker', 'Kubernetes', 'AWS', 'Azure', 'GCP', 'Vercel', 'Netlify'
]}
.value=${['React', 'Node.js', 'PostgreSQL', 'Docker']}
.description=${'Start typing to see suggestions from popular technologies'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'3. Limited Tags'} .subtitle=${'Restrict the number of tags users can add'}>
<div class="grid-layout">
<dees-input-tags
.label=${'Top 3 Skills'}
.placeholder=${'Add up to 3 skills...'}
.maxTags=${3}
.value=${['Design', 'Development']}
.description=${'Maximum 3 tags allowed'}
></dees-input-tags>
<dees-input-tags
.label=${'Categories (Max 5)'}
.placeholder=${'Select categories...'}
.maxTags=${5}
.suggestions=${['Blog', 'Tutorial', 'News', 'Review', 'Guide', 'Case Study', 'Interview']}
.value=${['Tutorial', 'Guide']}
.description=${'Choose up to 5 categories'}
></dees-input-tags>
</div>
</dees-panel>
<dees-panel .title=${'4. Required & Validation'} .subtitle=${'Tags input with validation requirements'}>
<dees-input-tags
.label=${'Project Tags'}
.placeholder=${'Add at least one tag...'}
.required=${true}
.description=${'This field is required - add at least one tag'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only tags display'}>
<dees-input-tags
.label=${'System Tags'}
.value=${['System', 'Protected', 'Read-Only', 'Archive']}
.disabled=${true}
.description=${'These tags cannot be modified'}
></dees-input-tags>
</dees-panel>
<dees-panel .title=${'6. Form Integration'} .subtitle=${'Tags input working within a form context'}>
<dees-form>
<dees-input-text
.label=${'Project Name'}
.placeholder=${'My Awesome Project'}
.required=${true}
.key=${'name'}
></dees-input-text>
<div class="grid-layout">
<dees-input-tags
.label=${'Technologies Used'}
.placeholder=${'Add technologies...'}
.required=${true}
.key=${'technologies'}
.suggestions=${[
'TypeScript', 'JavaScript', 'Python', 'Go', 'Rust',
'React', 'Vue', 'Angular', 'Svelte',
'Node.js', 'Deno', 'Express', 'FastAPI'
]}
></dees-input-tags>
<dees-input-tags
.label=${'Project Tags'}
.placeholder=${'Add descriptive tags...'}
.key=${'tags'}
.maxTags=${10}
.suggestions=${[
'frontend', 'backend', 'fullstack', 'mobile', 'desktop',
'web', 'api', 'database', 'devops', 'ui/ux',
'opensource', 'saas', 'enterprise', 'startup'
]}
></dees-input-tags>
</div>
<dees-input-text
.label=${'Description'}
.inputType=${'textarea'}
.placeholder=${'Describe your project...'}
.key=${'description'}
></dees-input-text>
<dees-form-submit .text=${'Create Project'}></dees-form-submit>
</dees-form>
</dees-panel>
<dees-panel .title=${'7. Interactive Demo'} .subtitle=${'Add tags and see them collected in real-time'}>
<dees-input-tags
id="interactive-tags"
.label=${'Your Interests'}
.placeholder=${'Type your interests...'}
.suggestions=${[
'Music', 'Movies', 'Books', 'Travel', 'Photography',
'Cooking', 'Gaming', 'Sports', 'Art', 'Technology',
'Fashion', 'Fitness', 'Nature', 'Science', 'History'
]}
@change=${(e: CustomEvent) => {
const preview = document.querySelector('#tags-preview');
const tags = e.detail.value;
if (preview) {
if (tags.length === 0) {
preview.innerHTML = '<em style="color: #999;">No tags added yet...</em>';
} else {
preview.innerHTML = tags.map((tag: string) =>
`<span class="tag-preview-item">${tag}</span>`
).join('');
}
}
}}
></dees-input-tags>
<div class="tag-preview" id="tags-preview">
<em style="color: #999;">No tags added yet...</em>
</div>
<div class="output-preview" id="tags-json">
<em>JSON output will appear here...</em>
</div>
<script>
// Update JSON preview
const tagsInput = document.querySelector('#interactive-tags');
tagsInput?.addEventListener('change', (e) => {
const jsonPreview = document.querySelector('#tags-json');
if (jsonPreview) {
jsonPreview.textContent = JSON.stringify(e.detail.value, null, 2);
}
});
</script>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -0,0 +1,431 @@
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 { demoFunc } from './dees-input-tags.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-tags': DeesInputTags;
}
}
@customElement('dees-input-tags')
export class DeesInputTags extends DeesInputBase<DeesInputTags> {
// STATIC
public static demo = demoFunc;
// INSTANCE
@property({ type: Array })
public value: string[] = [];
@property({ type: String })
public placeholder: string = 'Add tags...';
@property({ type: Number })
public maxTags: number = 0; // 0 means unlimited
@property({ type: Array })
public suggestions: string[] = [];
@state()
private inputValue: string = '';
@state()
private showSuggestions: boolean = false;
@state()
private highlightedSuggestionIndex: number = -1;
@property({ type: String })
public validationText: string = '';
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%;
}
.tags-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 10px;
min-height: 40px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
transition: all 0.15s ease;
cursor: text;
}
.tags-container:hover:not(.disabled) {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.tags-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)')};
}
.tags-container.disabled {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
cursor: not-allowed;
opacity: 0.5;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: ${cssManager.bdTheme('hsl(215 20.2% 65.1% / 0.2)', 'hsl(215 20.2% 35.1% / 0.2)')};
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(215 20.2% 65.1% / 0.3)', 'hsl(215 20.2% 35.1% / 0.3)')};
border-radius: 4px;
font-size: 13px;
font-weight: 500;
line-height: 18px;
user-select: none;
animation: tagAppear 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes tagAppear {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin-left: 2px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('hsl(215.3 25% 46.7%)', 'hsl(217.9 10.6% 54.9%)')};
}
.tag-remove:hover {
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.08)', 'hsl(0 0% 100% / 0.08)')};
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
}
.tag-remove dees-icon {
width: 10px;
height: 10px;
}
.tag-input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
outline: none;
font-size: 14px;
font-family: inherit;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
padding: 2px 4px;
line-height: 20px;
}
.tag-input::placeholder {
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
}
.tag-input:disabled {
cursor: not-allowed;
}
/* Suggestions dropdown */
.suggestions-container {
position: relative;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
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;
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1), 0 2px 4px -2px hsl(0 0% 0% / 0.1);
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.suggestion {
padding: 6px 10px;
cursor: pointer;
transition: all 0.15s ease;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
}
.suggestion:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
}
.suggestion.highlighted {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
/* Validation styles */
.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 styles */
.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 */
.suggestions-dropdown::-webkit-scrollbar {
width: 8px;
}
.suggestions-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.suggestions-dropdown::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 4px;
}
.suggestions-dropdown::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
`,
];
public render(): TemplateResult {
const filteredSuggestions = this.suggestions.filter(
suggestion =>
!this.value.includes(suggestion) &&
suggestion.toLowerCase().includes(this.inputValue.toLowerCase())
);
return html`
<div class="input-wrapper">
${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''}
<div class="suggestions-container">
<div
class="tags-container ${this.disabled ? 'disabled' : ''}"
@click=${this.handleContainerClick}
>
${this.value.map(tag => html`
<div class="tag">
<span>${tag}</span>
${!this.disabled ? html`
<div class="tag-remove" @click=${(e: Event) => this.removeTag(e, tag)}>
<dees-icon .icon=${'lucide:x'}></dees-icon>
</div>
` : ''}
</div>
`)}
${!this.disabled && (!this.maxTags || this.value.length < this.maxTags) ? html`
<input
type="text"
class="tag-input"
.placeholder=${this.placeholder}
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
?disabled=${this.disabled}
/>
` : ''}
</div>
${this.showSuggestions && filteredSuggestions.length > 0 ? html`
<div class="suggestions-dropdown">
${filteredSuggestions.map((suggestion, index) => html`
<div
class="suggestion ${index === this.highlightedSuggestionIndex ? 'highlighted' : ''}"
@mousedown=${(e: Event) => {
e.preventDefault(); // Prevent blur
this.addTag(suggestion);
}}
@mouseenter=${() => this.highlightedSuggestionIndex = index}
>
${suggestion}
</div>
`)}
</div>
` : ''}
</div>
${this.validationText ? html`
<div class="validation-message">${this.validationText}</div>
` : ''}
${this.description ? html`
<div class="description">${this.description}</div>
` : ''}
</div>
`;
}
private handleContainerClick(e: Event) {
if (this.disabled) return;
const input = this.shadowRoot?.querySelector('.tag-input') as HTMLInputElement;
if (input && e.target !== input) {
input.focus();
}
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.inputValue = input.value;
// Check for comma or semicolon to add tag
if (this.inputValue.includes(',') || this.inputValue.includes(';')) {
const tag = this.inputValue.replace(/[,;]/g, '').trim();
if (tag) {
this.addTag(tag);
}
}
}
private handleKeyDown(e: KeyboardEvent) {
const input = e.target as HTMLInputElement;
if (e.key === 'Enter') {
e.preventDefault();
if (this.highlightedSuggestionIndex >= 0 && this.showSuggestions) {
const filteredSuggestions = this.suggestions.filter(
suggestion =>
!this.value.includes(suggestion) &&
suggestion.toLowerCase().includes(this.inputValue.toLowerCase())
);
if (filteredSuggestions[this.highlightedSuggestionIndex]) {
this.addTag(filteredSuggestions[this.highlightedSuggestionIndex]);
}
} else if (this.inputValue.trim()) {
this.addTag(this.inputValue.trim());
}
} else if (e.key === 'Backspace' && !this.inputValue && this.value.length > 0) {
// Remove last tag when backspace is pressed on empty input
this.removeTag(e, this.value[this.value.length - 1]);
} else if (e.key === 'ArrowDown' && this.showSuggestions) {
e.preventDefault();
const filteredCount = this.suggestions.filter(
s => !this.value.includes(s) && s.toLowerCase().includes(this.inputValue.toLowerCase())
).length;
this.highlightedSuggestionIndex = Math.min(
this.highlightedSuggestionIndex + 1,
filteredCount - 1
);
} else if (e.key === 'ArrowUp' && this.showSuggestions) {
e.preventDefault();
this.highlightedSuggestionIndex = Math.max(this.highlightedSuggestionIndex - 1, 0);
} else if (e.key === 'Escape') {
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
}
}
private handleFocus() {
if (this.suggestions.length > 0) {
this.showSuggestions = true;
}
}
private handleBlur() {
// Delay to allow click on suggestions
setTimeout(() => {
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
}, 200);
}
private addTag(tag: string) {
if (!tag || this.value.includes(tag)) return;
if (this.maxTags && this.value.length >= this.maxTags) return;
this.value = [...this.value, tag];
this.inputValue = '';
this.showSuggestions = false;
this.highlightedSuggestionIndex = -1;
// Clear the input
const input = this.shadowRoot?.querySelector('.tag-input') as HTMLInputElement;
if (input) {
input.value = '';
}
this.emitChange();
}
private removeTag(e: Event, tag: string) {
e.stopPropagation();
this.value = this.value.filter(t => t !== tag);
this.emitChange();
}
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 tag is required';
return false;
}
this.validationText = '';
return true;
}
}

View File

@ -0,0 +1,195 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
dees-panel {
margin-bottom: 24px;
}
dees-panel:last-child {
margin-bottom: 0;
}
.horizontal-group {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.grid-layout {
grid-template-columns: 1fr;
}
}
.interactive-section {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
.output-text {
font-family: monospace;
font-size: 13px;
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(210 40% 80%)')};
padding: 8px;
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')};
border-radius: 4px;
min-height: 24px;
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'Basic Text Inputs'} .subtitle=${'Standard text inputs with labels and descriptions'}>
<dees-input-text
.label=${'Username'}
.value=${'johndoe'}
.key=${'username'}
></dees-input-text>
<dees-input-text
.label=${'Email Address'}
.value=${'john@example.com'}
.description=${'We will never share your email with anyone'}
.key=${'email'}
></dees-input-text>
<dees-input-text
.label=${'Password'}
.isPasswordBool=${true}
.value=${'secret123'}
.key=${'password'}
></dees-input-text>
</dees-panel>
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Multiple inputs arranged horizontally for compact forms'}>
<div class="horizontal-group">
<dees-input-text
.label=${'First Name'}
.value=${'John'}
.layoutMode=${'horizontal'}
.key=${'firstName'}
></dees-input-text>
<dees-input-text
.label=${'Last Name'}
.value=${'Doe'}
.layoutMode=${'horizontal'}
.key=${'lastName'}
></dees-input-text>
<dees-input-text
.label=${'Age'}
.value=${'28'}
.layoutMode=${'horizontal'}
.key=${'age'}
></dees-input-text>
</div>
</dees-panel>
<dees-panel .title=${'Label Positions'} .subtitle=${'Different label positioning options for various layouts'}>
<dees-input-text
.label=${'Label on Top (Default)'}
.value=${'Standard layout'}
.labelPosition=${'top'}
></dees-input-text>
<dees-input-text
.label=${'Label on Left'}
.value=${'Inline label'}
.labelPosition=${'left'}
></dees-input-text>
<div class="grid-layout">
<dees-input-text
.label=${'City'}
.value=${'New York'}
.labelPosition=${'left'}
></dees-input-text>
<dees-input-text
.label=${'ZIP Code'}
.value=${'10001'}
.labelPosition=${'left'}
></dees-input-text>
</div>
</dees-panel>
<dees-panel .title=${'Validation & States'} .subtitle=${'Different validation states and input configurations'}>
<dees-input-text
.label=${'Required Field'}
.required=${true}
.key=${'requiredField'}
></dees-input-text>
<dees-input-text
.label=${'Disabled Field'}
.value=${'Cannot edit this'}
.disabled=${true}
></dees-input-text>
<dees-input-text
.label=${'Field with Error'}
.value=${'invalid@'}
.validationText=${'Please enter a valid email address'}
.validationState=${'invalid'}
></dees-input-text>
</dees-panel>
<dees-panel .title=${'Advanced Features'} .subtitle=${'Password visibility toggle and other advanced features'}>
<dees-input-text
.label=${'Password with Toggle'}
.isPasswordBool=${true}
.value=${'mySecurePassword123'}
.description=${'Click the eye icon to show/hide password'}
></dees-input-text>
<dees-input-text
.label=${'API Key'}
.isPasswordBool=${true}
.value=${'sk-1234567890abcdef'}
.description=${'Keep this key secure and never share it'}
></dees-input-text>
</dees-panel>
<dees-panel .title=${'Interactive Example'} .subtitle=${'Try typing in the inputs to see real-time value changes'}>
<dees-input-text
.label=${'Dynamic Input'}
.placeholder=${'Type something here...'}
@changeSubject=${(event) => {
const output = document.querySelector('#text-input-output');
if (output && event.detail) {
output.textContent = `Current value: "${event.detail.getValue()}"`;
}
}}
></dees-input-text>
<div class="interactive-section">
<div id="text-input-output" class="output-text">Current value: ""</div>
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@ -1,16 +1,16 @@
import * as colors from './00colors.js';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-text.demo.js';
import { cssGeistFontFamily, cssMonoFontFamily } from './00fonts.js';
import {
customElement,
DeesElement,
type TemplateResult,
property,
html,
cssManager,
css,
type CSSResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
declare global {
interface HTMLElementTagNameMap {
@ -19,47 +19,16 @@ declare global {
}
@customElement('dees-input-text')
export class DeesInputText extends DeesElement {
public static demo = () => html`
<dees-input-text .label=${'this is a label'} .value=${'test'}></dees-input-text>
<dees-input-text .isPasswordBool=${true}></dees-input-text>
`;
export class DeesInputText extends DeesInputBase {
public static demo = demoFunc;
// INSTANCE
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesInputText>();
@property({
type: String,
})
public label: string;
@property({
type: String,
})
public description: string;
@property({
type: String,
reflect: true,
})
public key: string;
@property({
type: String,
reflect: true,
})
public value: string = '';
@property({
type: Boolean,
})
public required: boolean = false;
@property({
type: Boolean,
})
public disabled: boolean = false;
@property({
type: Boolean,
reflect: true,
@ -87,6 +56,7 @@ export class DeesInputText extends DeesElement {
validationFunction: (value: string) => boolean;
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
* {
@ -95,79 +65,130 @@ export class DeesInputText extends DeesElement {
:host {
position: relative;
display: grid;
margin: 8px 0px;
margin-bottom: 24px;
z-index: auto;
font-family: ${cssGeistFontFamily};
}
.maincontainer {
color: ${cssManager.bdTheme('#333', '#ccc')};
position: relative;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
}
input {
margin-top: 0px;
background: ${cssManager.bdTheme('#fafafa', '#222')};
border-top: ${cssManager.bdTheme('1px solid #CCC', '1px solid #ffffff10')};
border-bottom: ${cssManager.bdTheme('1px solid #CCC', '1px solid #222')};
border-right: ${cssManager.bdTheme('1px solid #CCC', 'none')};
border-left: ${cssManager.bdTheme('1px solid #CCC', 'none')};
padding-left: 10px;
padding-right: 10px;
border-radius: 2px;
display: flex;
height: 40px;
width: 100%;
line-height: 36px;
transition: all 0.2s;
padding: 0 12px;
font-size: 14px;
line-height: 40px;
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;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
font-size: 16px;
position: relative;
z-index: 2;
cursor: default;
cursor: text;
font-family: inherit;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
}
input:disabled {
background: ${cssManager.bdTheme('#ffffff00', '#11111100')};
border: 1px dashed ${cssManager.bdTheme('#666666', '#666666')};
color: #9b9b9e;
cursor: default;
input::placeholder {
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
}
input:hover:not(:disabled):not(:focus) {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
input:focus {
outline: none;
border-bottom: 1px solid ${cssManager.bdTheme( colors.bright.blueActive, colors.dark.blueActive)};
cursor: text;
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)')};
}
input:hover {
filter: ${cssManager.bdTheme('brightness(0.95)', 'brightness(1.1)')};
input:disabled {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
cursor: not-allowed;
opacity: 0.5;
}
/* Password toggle button */
.showPassword {
position: absolute;
bottom: 7px;
right: 10px;
border: 1px dashed #444;
border-radius: 7px;
padding: 4px 0px;
width: 40px;
z-index: 3;
right: 1px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
cursor: pointer;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
transition: all 0.15s ease;
border-radius: 0 5px 5px 0;
}
.showPassword:hover {
background: ${cssManager.bdTheme('#00000010', '#ffffff10')};
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
/* Validation styles */
.validationContainer {
text-align: center;
padding: 6px 2px 2px 2px;
margin-top: -4px;
margin-top: 4px;
padding: 4px 8px;
font-size: 12px;
color: #fff;
background: #e4002b;
position: relative;
z-index: 1;
border-radius: 3px;
transition: all 0.2s;
font-weight: 500;
border-radius: 4px;
transition: all 0.2s ease;
overflow: hidden;
}
.validationContainer.error {
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.1)', 'hsl(0 72.2% 50.6% / 0.1)')};
color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
}
.validationContainer.warn {
background: ${cssManager.bdTheme('hsl(25 95% 53% / 0.1)', 'hsl(25 95% 63% / 0.1)')};
color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
}
.validationContainer.valid {
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
}
/* Error state for input */
:host([validation-state="invalid"]) input {
border-color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
}
:host([validation-state="invalid"]) input:focus {
border-color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.05)', 'hsl(0 72.2% 50.6% / 0.05)')};
}
/* Warning state for input */
:host([validation-state="warn"]) input {
border-color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
}
:host([validation-state="warn"]) input:focus {
border-color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(25 95% 53% / 0.05)', 'hsl(25 95% 63% / 0.05)')};
}
/* Valid state for input */
:host([validation-state="valid"]) input {
border-color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
}
:host([validation-state="valid"]) input:focus {
border-color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.05)', 'hsl(142.1 70.6% 45.3% / 0.05)')};
}
`,
];
@ -176,50 +197,58 @@ export class DeesInputText extends DeesElement {
return html`
<style>
input {
font-family: ${this.isPasswordBool ? 'monospace' : 'Geist Sans'};
letter-spacing: ${this.isPasswordBool ? '1px' : 'normal'};
color: ${this.goBright ? '#333' : '#ccc'};
font-family: ${this.isPasswordBool ? cssMonoFontFamily : 'inherit'};
letter-spacing: ${this.isPasswordBool ? '0.5px' : 'normal'};
padding-right: ${this.isPasswordBool ? '48px' : '12px'};
}
${this.validationText ? css`
.validationContainer {
height: 22px;
opacity: 1;
}
` : css`
.validationContainer {
height: 4px;
padding: 2px !important;
opacity: 0;
}
`}
</style>
<div class="maincontainer">
<dees-label .label=${this.label} .description=${this.description}></dees-label>
<input
type="${this.isPasswordBool && !this.showPasswordBool ? 'password' : 'text'}"
.value=${this.value}
@input="${this.updateValue}"
.disabled=${this.disabled}
/>
<div class="validationContainer">
${this.validationText}
</div>
${this.isPasswordBool
? html`
<div class="showPassword" @click=${this.togglePasswordView}>
<dees-icon .iconFA=${this.showPasswordBool ? 'eye' : 'eyeSlash'}></dees-icon>
</div>
${this.validationText
? css`
.validationContainer {
height: auto;
opacity: 1;
transform: translateY(0);
}
`
: html``}
: css`
.validationContainer {
height: 0;
padding: 0 !important;
opacity: 0;
transform: translateY(-4px);
}
`}
</style>
<div class="input-wrapper">
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
<div class="maincontainer">
<input
type="${this.isPasswordBool && !this.showPasswordBool ? 'password' : 'text'}"
.value=${this.value}
@input="${this.updateValue}"
.disabled=${this.disabled}
placeholder="${this.label ? '' : 'Enter text...'}"
/>
${this.isPasswordBool
? html`
<div class="showPassword" @click=${this.togglePasswordView}>
<dees-icon .iconName=${this.showPasswordBool ? 'lucideEye' : 'lucideEyeOff'}></dees-icon>
</div>
`
: html``}
${this.validationText
? html`
<div class="validationContainer ${this.validationState || 'error'}">
${this.validationText}
</div>
`
: html`<div class="validationContainer"></div>`}
</div>
</div>
`;
}
firstUpdated() {
const input = this.shadowRoot.querySelector('input');
input.addEventListener('input', (eventArg: InputEvent) => {
});
// Input event handling is already done in updateValue method
}
public async updateValue(eventArg: Event) {
@ -228,18 +257,16 @@ export class DeesInputText extends DeesElement {
this.changeSubject.next(this);
}
public async freeze() {
this.disabled = true;
public getValue(): string {
return this.value;
}
public async unfreeze() {
this.disabled = false;
public setValue(value: string): void {
this.value = value;
}
public async togglePasswordView() {
const domtools = await this.domtoolsPromise;
this.showPasswordBool = !this.showPasswordBool;
console.log(`this.showPasswordBool is: ${this.showPasswordBool}`);
}
public async focus() {

Some files were not shown because too many files have changed in this diff Show More