diff --git a/ts_web/elements/dees-contextmenu.demo.ts b/ts_web/elements/dees-contextmenu.demo.ts index ebeb898..6c89696 100644 --- a/ts_web/elements/dees-contextmenu.demo.ts +++ b/ts_web/elements/dees-contextmenu.demo.ts @@ -9,49 +9,143 @@ export const demoFunc = () => html` display: block; margin: 20px; } + .demo-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 40px; + background: #f5f5f5; + min-height: 400px; + } + .demo-area { + background: white; + padding: 40px; + border-radius: 8px; + border: 1px solid #e0e0e0; + text-align: center; + cursor: context-menu; + } - { - DeesContextmenu.openContextMenuWithOptions(eventArg, [ - { - name: 'copy', - iconName: 'copySolid', - action: async () => { - return null; +
+
{ + DeesContextmenu.openContextMenuWithOptions(eventArg, [ + { + name: 'Cut', + iconName: 'scissors', + shortcut: 'Cmd+X', + action: async () => { + console.log('Cut action'); + }, }, - }, - { - name: 'edit', - iconName: 'penToSquare', - action: async () => { - return null; + { + name: 'Copy', + iconName: 'copy', + shortcut: 'Cmd+C', + action: async () => { + console.log('Copy action'); + }, }, - },{ - name: 'paste', - iconName: 'pasteSolid', - action: async () => { - return null; + { + name: 'Paste', + iconName: 'clipboard', + shortcut: 'Cmd+V', + action: async () => { + console.log('Paste action'); + }, }, - }, - ]); -}}>Right-Click for contextmenu - - {}, - }, - { - name: 'edit', - iconName: 'penToSquare', - action: async () => {}, - },{ - name: 'paste', - iconName: 'pasteSolid', - action: async () => {}, - }, - ] as plugins.tsclass.website.IMenuItem[]} -> + { divider: true }, + { + name: 'Delete', + iconName: 'trash2', + action: async () => { + console.log('Delete action'); + }, + }, + { divider: true }, + { + name: 'Select All', + shortcut: 'Cmd+A', + action: async () => { + console.log('Select All action'); + }, + }, + ]); + }}> +

Right-click anywhere in this area

+

A context menu will appear with various 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: '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'), + }, + { divider: true }, + { + name: 'Export', + iconName: 'download', + action: async () => console.log('Export'), + }, + { + name: 'Import', + iconName: 'upload', + action: async () => console.log('Import'), + }, + ]} + > +
+
`; \ No newline at end of file diff --git a/ts_web/elements/dees-contextmenu.ts b/ts_web/elements/dees-contextmenu.ts index 6b80076..2706339 100644 --- a/ts_web/elements/dees-contextmenu.ts +++ b/ts_web/elements/dees-contextmenu.ts @@ -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,7 @@ import { import * as domtools from '@design.estate/dees-domtools'; import { DeesWindowLayer } from './dees-windowlayer.js'; +import './dees-icon.js'; declare global { interface HTMLElementTagNameMap { @@ -30,7 +30,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 } | { divider: true })[] = []; // Add a global event listener for the right-click context menu public static initializeGlobalListener() { @@ -49,7 +49,13 @@ export class DeesContextmenu extends DeesElement { // Traverse up the DOM tree to accumulate menu items while (target) { if ((target as any).getContextMenuItems) { - DeesContextmenu.accumulatedMenuItems.push(...(target as any).getContextMenuItems()); + const items = (target 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; } @@ -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 } | { divider: true })[]) { if (this.contextMenuDeactivated) { return; } @@ -68,32 +74,60 @@ 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 = '10000'; 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(); }) 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; divider?: never } | { divider: true })[] = []; windowLayer: DeesWindowLayer; constructor() { super(); + this.tabIndex = 0; } /** @@ -104,40 +138,70 @@ 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; } - .mainbox .menuitem dees-icon { - display: inline-block; - margin-right: 8px; - width: 14px; - transform: translateY(-1px); + .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:active { + 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; } - .mainbox .menuitem:active { - background: #ffffff05; + .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 +210,20 @@ export class DeesContextmenu extends DeesElement { return html`
${this.menuItems.map((menuItemArg) => { + if ('divider' in menuItemArg && menuItemArg.divider) { + return html``; + } + + const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }; return html` - ` : html``}
@@ -167,10 +241,45 @@ 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); + } + + 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) { + public async handleClick(menuItem: plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }) { menuItem.action(); await this.destroy(); } @@ -180,7 +289,7 @@ export class DeesContextmenu extends DeesElement { 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); }