feat: Enhance context menu functionality with keyboard navigation and improved item handling
This commit is contained in:
@ -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;
|
||||
}
|
||||
</style>
|
||||
<dees-button @contextmenu=${(eventArg) => {
|
||||
<div class="demo-container">
|
||||
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'copy',
|
||||
iconName: 'copySolid',
|
||||
name: 'Cut',
|
||||
iconName: 'scissors',
|
||||
shortcut: 'Cmd+X',
|
||||
action: async () => {
|
||||
return null;
|
||||
console.log('Cut action');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit',
|
||||
iconName: 'penToSquare',
|
||||
name: 'Copy',
|
||||
iconName: 'copy',
|
||||
shortcut: 'Cmd+C',
|
||||
action: async () => {
|
||||
return null;
|
||||
console.log('Copy action');
|
||||
},
|
||||
},{
|
||||
name: 'paste',
|
||||
iconName: 'pasteSolid',
|
||||
},
|
||||
{
|
||||
name: 'Paste',
|
||||
iconName: 'clipboard',
|
||||
shortcut: 'Cmd+V',
|
||||
action: async () => {
|
||||
return null;
|
||||
console.log('Paste action');
|
||||
},
|
||||
},
|
||||
{ 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 for contextmenu</dees-button>
|
||||
<dees-contextmenu class="withMargin"></dees-contextmenu>
|
||||
<dees-contextmenu
|
||||
}}>
|
||||
<h3>Right-click anywhere in this area</h3>
|
||||
<p>A context menu will appear with various options</p>
|
||||
</div>
|
||||
|
||||
<dees-button @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'Button Action 1',
|
||||
iconName: 'play',
|
||||
action: async () => {
|
||||
console.log('Button action 1');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Button Action 2',
|
||||
iconName: 'pause',
|
||||
action: async () => {
|
||||
console.log('Button action 2');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Disabled Action',
|
||||
iconName: 'ban',
|
||||
disabled: true,
|
||||
action: async () => {
|
||||
console.log('This should not run');
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'settings',
|
||||
action: async () => {
|
||||
console.log('Settings');
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}>Right-click on this button for a different menu</dees-button>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h4>Static Context Menu (always visible):</h4>
|
||||
<dees-contextmenu
|
||||
class="withMargin"
|
||||
.menuItems=${[
|
||||
{
|
||||
name: 'copy',
|
||||
iconName: 'copySolid',
|
||||
action: async () => {},
|
||||
name: 'New File',
|
||||
iconName: 'filePlus',
|
||||
shortcut: 'Cmd+N',
|
||||
action: async () => console.log('New file'),
|
||||
},
|
||||
{
|
||||
name: 'edit',
|
||||
iconName: 'penToSquare',
|
||||
action: async () => {},
|
||||
},{
|
||||
name: 'paste',
|
||||
iconName: 'pasteSolid',
|
||||
action: async () => {},
|
||||
name: 'Open File',
|
||||
iconName: 'folderOpen',
|
||||
shortcut: 'Cmd+O',
|
||||
action: async () => console.log('Open file'),
|
||||
},
|
||||
] as plugins.tsclass.website.IMenuItem[]}
|
||||
></dees-contextmenu>
|
||||
{
|
||||
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'),
|
||||
},
|
||||
]}
|
||||
></dees-contextmenu>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -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)')};
|
||||
}
|
||||
|
||||
.mainbox .menuitem:active {
|
||||
background: #ffffff05;
|
||||
.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 +210,20 @@ 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 };
|
||||
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' : ''}" @click=${() => !menuItem.disabled && this.handleClick(menuItem)}>
|
||||
${menuItem.iconName ? html`
|
||||
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
|
||||
` : ''}
|
||||
<span class="menuitem-text">${menuItem.name}</span>
|
||||
${menuItem.shortcut ? html`
|
||||
<span class="menuitem-shortcut">${menuItem.shortcut}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
@ -158,8 +232,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,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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
Reference in New Issue
Block a user