diff --git a/test/test.contextmenu-demo.browser.ts b/test/test.contextmenu-demo.browser.ts new file mode 100644 index 0000000..26e0610 --- /dev/null +++ b/test/test.contextmenu-demo.browser.ts @@ -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(); \ No newline at end of file diff --git a/test/test.contextmenu-nested-close.browser.ts b/test/test.contextmenu-nested-close.browser.ts new file mode 100644 index 0000000..e76e69a --- /dev/null +++ b/test/test.contextmenu-nested-close.browser.ts @@ -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(); \ No newline at end of file diff --git a/test/test.contextmenu-shadowdom.browser.ts b/test/test.contextmenu-shadowdom.browser.ts new file mode 100644 index 0000000..28f1489 --- /dev/null +++ b/test/test.contextmenu-shadowdom.browser.ts @@ -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` +
+

Shadow DOM Content

+

Right-click anywhere inside this shadow DOM

+
+ `; + } +} + +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(); \ No newline at end of file diff --git a/test/test.contextmenu.browser.ts b/test/test.contextmenu.browser.ts new file mode 100644 index 0000000..03de222 --- /dev/null +++ b/test/test.contextmenu.browser.ts @@ -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(); \ No newline at end of file diff --git a/test/test.wysiwyg-blocktype-change.browser.ts b/test/test.wysiwyg-blocktype-change.browser.ts new file mode 100644 index 0000000..97a43ad --- /dev/null +++ b/test/test.wysiwyg-blocktype-change.browser.ts @@ -0,0 +1,109 @@ +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 = '

This is a test paragraph

'; + 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(); + + // Add debug logging + console.log('Before click:', { + blockType: wysiwygEditor.blocks[0].type, + blockId: wysiwygEditor.blocks[0].id + }); + + // Click on "Heading 1" + (heading1Item as HTMLElement).click(); + + // Wait for menu to close and block to update + await new Promise(resolve => setTimeout(resolve, 500)); + + console.log('After click:', { + blockType: wysiwygEditor.blocks[0].type, + blockId: wysiwygEditor.blocks[0].id + }); + + // 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(); \ No newline at end of file diff --git a/test/test.wysiwyg-contextmenu.browser.ts b/test/test.wysiwyg-contextmenu.browser.ts new file mode 100644 index 0000000..6f781cd --- /dev/null +++ b/test/test.wysiwyg-contextmenu.browser.ts @@ -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 = '

Test paragraph

Test heading

'; + 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(); \ No newline at end of file diff --git a/ts_web/elements/dees-contextmenu.demo.ts b/ts_web/elements/dees-contextmenu.demo.ts index 6c89696..852df25 100644 --- a/ts_web/elements/dees-contextmenu.demo.ts +++ b/ts_web/elements/dees-contextmenu.demo.ts @@ -13,139 +13,203 @@ export const demoFunc = () => html` display: flex; flex-direction: column; gap: 20px; - padding: 40px; - background: #f5f5f5; + padding: 20px; min-height: 400px; } .demo-area { - background: white; padding: 40px; border-radius: 8px; - border: 1px solid #e0e0e0; text-align: center; cursor: context-menu; + transition: background 0.2s; + } + .demo-area:hover { + background: rgba(0, 0, 0, 0.02); }
-
{ - DeesContextmenu.openContextMenuWithOptions(eventArg, [ - { - name: 'Cut', - iconName: 'scissors', - shortcut: 'Cmd+X', - action: async () => { - console.log('Cut action'); + +
{ + 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: 'Copy', - iconName: 'copy', - shortcut: 'Cmd+C', - action: async () => { - console.log('Copy action'); + { + name: 'Edit', + iconName: 'edit3', + action: async () => {}, // Parent items with submenus still need an action + submenu: [ + { name: 'Cut', iconName: 'scissors', shortcut: 'Cmd+X', action: async () => console.log('Cut') }, + { name: 'Copy', iconName: 'copy', shortcut: 'Cmd+C', action: async () => console.log('Copy') }, + { name: 'Paste', iconName: 'clipboard', shortcut: 'Cmd+V', action: async () => console.log('Paste') }, + { divider: true }, + { name: 'Find', iconName: 'search', shortcut: 'Cmd+F', action: async () => console.log('Find') }, + { name: 'Replace', iconName: 'repeat', shortcut: 'Cmd+H', action: async () => console.log('Replace') }, + ] }, - }, - { - name: 'Paste', - iconName: 'clipboard', - shortcut: 'Cmd+V', - action: async () => { - console.log('Paste action'); + { + 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: 'Delete', - iconName: 'trash2', - action: async () => { - console.log('Delete action'); + { divider: true }, + { + name: 'Settings', + iconName: 'settings', + action: async () => console.log('Settings') }, - }, - { divider: true }, - { - name: 'Select All', - shortcut: 'Cmd+A', - action: async () => { - console.log('Select All action'); + { + 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') }, + ] + } + ]); + }}> +

Right-click anywhere in this area

+

A context menu with nested submenus will appear

+
+
+ + { + 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') }, + ] }, - }, - ]); - }}> -

Right-click anywhere in this area

-

A context menu will appear with various options

-
+ { + 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 + + + +
{ + 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') + } + ]); + }}> +

Advanced Nested Menu Example

+

This shows deeply nested submenus and various formatting options

+
+
- { - DeesContextmenu.openContextMenuWithOptions(eventArg, [ - { - name: 'Button Action 1', - iconName: 'play', - action: async () => { - console.log('Button action 1'); - }, - }, - { - name: 'Button Action 2', - iconName: 'pause', - action: async () => { - console.log('Button action 2'); - }, - }, - { - name: 'Disabled Action', - iconName: 'ban', - disabled: true, - action: async () => { - console.log('This should not run'); - }, - }, - { divider: true }, - { - name: 'Settings', - iconName: 'settings', - action: async () => { - console.log('Settings'); - }, - }, - ]); - }}>Right-click on this button for a different menu - -
-

Static Context Menu (always visible):

+ console.log('New file'), + name: 'Project', + iconName: 'folder', + action: async () => {}, // Parent items with submenus still need an action + submenu: [ + { name: 'New Project', iconName: 'folderPlus', shortcut: 'Cmd+Shift+N', action: async () => console.log('New project') }, + { name: 'Open Project', iconName: 'folderOpen', shortcut: 'Cmd+Shift+O', action: async () => console.log('Open project') }, + { divider: true }, + { name: 'Recent Projects', iconName: 'clock', action: async () => {}, submenu: [ + { name: 'Project Alpha', action: async () => console.log('Open Alpha') }, + { name: 'Project Beta', action: async () => console.log('Open Beta') }, + { name: 'Project Gamma', action: async () => console.log('Open Gamma') }, + ]}, + ] }, { - name: 'Open File', - iconName: 'folderOpen', - shortcut: 'Cmd+O', - action: async () => console.log('Open file'), - }, - { - name: 'Save', - iconName: 'save', - shortcut: 'Cmd+S', - action: async () => console.log('Save'), + name: 'Tools', + iconName: 'tool', + action: async () => {}, // Parent items with submenus still need an action + submenu: [ + { name: 'Terminal', iconName: 'terminal', shortcut: 'Cmd+T', action: async () => console.log('Terminal') }, + { name: 'Console', iconName: 'monitor', shortcut: 'Cmd+K', action: async () => console.log('Console') }, + { divider: true }, + { name: 'Extensions', iconName: 'package', action: async () => console.log('Extensions') }, + ] }, { divider: true }, { - name: 'Export', - iconName: 'download', - action: async () => console.log('Export'), - }, - { - name: 'Import', - iconName: 'upload', - action: async () => console.log('Import'), + name: 'Preferences', + iconName: 'sliders', + action: async () => console.log('Preferences'), }, ]} > -
+
`; \ No newline at end of file diff --git a/ts_web/elements/dees-contextmenu.ts b/ts_web/elements/dees-contextmenu.ts index a62bd96..efe428c 100644 --- a/ts_web/elements/dees-contextmenu.ts +++ b/ts_web/elements/dees-contextmenu.ts @@ -31,7 +31,7 @@ export class DeesContextmenu extends DeesElement { // STATIC // This will store all the accumulated menu items public static contextMenuDeactivated = false; - public static accumulatedMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] = []; + public static accumulatedMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[] = []; // Add a global event listener for the right-click context menu public static initializeGlobalListener() { @@ -41,16 +41,16 @@ export class DeesContextmenu extends DeesElement { } event.preventDefault(); - // Get the target element of the right-click - let target: EventTarget | null = event.target; - // Clear previously accumulated items DeesContextmenu.accumulatedMenuItems = []; - // Traverse up the DOM tree to accumulate menu items - while (target) { - if ((target as any).getContextMenuItems) { - const items = (target as any).getContextMenuItems(); + // Use composedPath to properly traverse shadow DOM boundaries + const path = event.composedPath(); + + // Traverse the composed path to accumulate menu items + for (const element of path) { + if ((element as any).getContextMenuItems) { + const items = (element as any).getContextMenuItems(); if (items && items.length > 0) { if (DeesContextmenu.accumulatedMenuItems.length > 0) { DeesContextmenu.accumulatedMenuItems.push({ divider: true }); @@ -58,7 +58,6 @@ export class DeesContextmenu extends DeesElement { DeesContextmenu.accumulatedMenuItems.push(...items); } } - target = (target as Node).parentNode; } // Open the context menu with the accumulated items @@ -67,7 +66,7 @@ export class DeesContextmenu extends DeesElement { } // allows opening of a contextmenu with options - public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[]) { + public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[]) { if (this.contextMenuDeactivated) { return; } @@ -80,8 +79,13 @@ export class DeesContextmenu extends DeesElement { contextMenu.style.transform = 'scale(0.95) translateY(-10px)'; contextMenu.menuItems = menuItemsArg; contextMenu.windowLayer = await DeesWindowLayer.createAndShow(); - contextMenu.windowLayer.addEventListener('click', async () => { - 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); @@ -123,8 +127,12 @@ export class DeesContextmenu extends DeesElement { @property({ type: Array, }) - public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; divider?: never } | { divider: true })[] = []; + public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[]; divider?: never } | { divider: true })[] = []; windowLayer: DeesWindowLayer; + + private submenu: DeesContextmenu | null = null; + private submenuTimeout: any = null; + private parentMenu: DeesContextmenu | null = null; constructor() { super(); @@ -167,13 +175,22 @@ export class DeesContextmenu extends DeesElement { cursor: default; transition: background 0.1s; line-height: 1; + position: relative; } .menuitem:hover { background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')}; } + + .menuitem.has-submenu::after { + content: '›'; + position: absolute; + right: 8px; + font-size: 16px; + opacity: 0.5; + } - .menuitem:active { + .menuitem:active:not(.has-submenu) { background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')}; } @@ -215,14 +232,20 @@ export class DeesContextmenu extends DeesElement { return html``; } - const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }; + const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: any }; + const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0; return html` -