284 lines
7.3 KiB
TypeScript
284 lines
7.3 KiB
TypeScript
|
|
import {
|
||
|
|
DeesElement,
|
||
|
|
css,
|
||
|
|
cssManager,
|
||
|
|
customElement,
|
||
|
|
html,
|
||
|
|
property,
|
||
|
|
state,
|
||
|
|
type TemplateResult,
|
||
|
|
} from '@design.estate/dees-element';
|
||
|
|
|
||
|
|
import { mobileComponentStyles } from '../../00componentstyles.js';
|
||
|
|
import '../dees-mobile-icon/dees-mobile-icon.js';
|
||
|
|
import { demoFunc } from './dees-mobile-contextmenu.demo.js';
|
||
|
|
|
||
|
|
export interface IContextMenuItem {
|
||
|
|
label?: string;
|
||
|
|
icon?: string;
|
||
|
|
action?: () => void;
|
||
|
|
danger?: boolean;
|
||
|
|
divider?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
declare global {
|
||
|
|
interface HTMLElementTagNameMap {
|
||
|
|
'dees-mobile-contextmenu': DeesMobileContextmenu;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@customElement('dees-mobile-contextmenu')
|
||
|
|
export class DeesMobileContextmenu extends DeesElement {
|
||
|
|
public static demo = demoFunc;
|
||
|
|
|
||
|
|
@property({ type: Array })
|
||
|
|
accessor items: IContextMenuItem[] = [];
|
||
|
|
|
||
|
|
@property({ type: Number })
|
||
|
|
accessor x: number = 0;
|
||
|
|
|
||
|
|
@property({ type: Number })
|
||
|
|
accessor y: number = 0;
|
||
|
|
|
||
|
|
@property({ type: Boolean })
|
||
|
|
accessor isTouch: boolean = false;
|
||
|
|
|
||
|
|
@state()
|
||
|
|
accessor isClosing: boolean = false;
|
||
|
|
|
||
|
|
@state()
|
||
|
|
accessor transformOrigin: string = 'top left';
|
||
|
|
|
||
|
|
public static styles = [
|
||
|
|
cssManager.defaultStyles,
|
||
|
|
mobileComponentStyles,
|
||
|
|
css`
|
||
|
|
:host {
|
||
|
|
position: fixed;
|
||
|
|
z-index: var(--dees-z-contextmenu, 10000);
|
||
|
|
}
|
||
|
|
|
||
|
|
:host(.closing) .menu {
|
||
|
|
animation: scaleOut 100ms ease-in;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu {
|
||
|
|
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||
|
|
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||
|
|
border-radius: 0.5rem;
|
||
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||
|
|
padding: 0.5rem 0;
|
||
|
|
min-width: 180px;
|
||
|
|
animation: scaleIn 100ms ease-out;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 0.75rem;
|
||
|
|
padding: 0.5rem 1rem;
|
||
|
|
font-size: 0.875rem;
|
||
|
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 100ms ease;
|
||
|
|
background: none;
|
||
|
|
border: none;
|
||
|
|
width: 100%;
|
||
|
|
text-align: left;
|
||
|
|
font-family: inherit;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item:hover {
|
||
|
|
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item.danger {
|
||
|
|
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.divider {
|
||
|
|
height: 1px;
|
||
|
|
background: ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||
|
|
margin: 0.25rem 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes scaleIn {
|
||
|
|
from {
|
||
|
|
opacity: 0;
|
||
|
|
transform: scale(0.9);
|
||
|
|
}
|
||
|
|
to {
|
||
|
|
opacity: 1;
|
||
|
|
transform: scale(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes scaleOut {
|
||
|
|
from {
|
||
|
|
opacity: 1;
|
||
|
|
transform: scale(1);
|
||
|
|
}
|
||
|
|
to {
|
||
|
|
opacity: 0;
|
||
|
|
transform: scale(0.9);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`,
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Factory method to create and show a context menu
|
||
|
|
*/
|
||
|
|
public static createAndShow(
|
||
|
|
items: IContextMenuItem[],
|
||
|
|
x: number,
|
||
|
|
y: number,
|
||
|
|
isTouch = false
|
||
|
|
): DeesMobileContextmenu {
|
||
|
|
// Remove any existing context menu
|
||
|
|
const existing = document.querySelector('dees-mobile-contextmenu');
|
||
|
|
if (existing) {
|
||
|
|
existing.remove();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create new menu
|
||
|
|
const menu = document.createElement('dees-mobile-contextmenu') as DeesMobileContextmenu;
|
||
|
|
menu.items = items;
|
||
|
|
menu.x = x;
|
||
|
|
menu.y = y;
|
||
|
|
menu.isTouch = isTouch;
|
||
|
|
|
||
|
|
// Add to document
|
||
|
|
document.body.appendChild(menu);
|
||
|
|
|
||
|
|
// Position after render to handle viewport bounds
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
menu.adjustPosition();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Close on outside click
|
||
|
|
const handleClick = (e: MouseEvent) => {
|
||
|
|
if (!e.composedPath().includes(menu)) {
|
||
|
|
menu.close();
|
||
|
|
document.removeEventListener('click', handleClick, true);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Add listener on next tick to avoid immediate close
|
||
|
|
setTimeout(() => {
|
||
|
|
document.addEventListener('click', handleClick, true);
|
||
|
|
}, 0);
|
||
|
|
|
||
|
|
return menu;
|
||
|
|
}
|
||
|
|
|
||
|
|
private adjustPosition(): void {
|
||
|
|
const rect = this.getBoundingClientRect();
|
||
|
|
const menuWidth = rect.width;
|
||
|
|
const menuHeight = rect.height;
|
||
|
|
const padding = 10;
|
||
|
|
|
||
|
|
let adjustedX = this.x;
|
||
|
|
let adjustedY = this.y;
|
||
|
|
|
||
|
|
// Calculate available space in each direction
|
||
|
|
const spaceTop = this.y - padding;
|
||
|
|
const spaceBottom = window.innerHeight - this.y - padding;
|
||
|
|
const spaceLeft = this.x - padding;
|
||
|
|
const spaceRight = window.innerWidth - this.x - padding;
|
||
|
|
|
||
|
|
// For touch interactions, prefer opening upward if there's space
|
||
|
|
if (this.isTouch && spaceTop >= menuHeight) {
|
||
|
|
// Open upward from touch point
|
||
|
|
adjustedY = this.y - menuHeight;
|
||
|
|
this.transformOrigin = 'bottom left';
|
||
|
|
|
||
|
|
// Adjust X if needed
|
||
|
|
if (spaceRight < menuWidth && spaceLeft >= menuWidth) {
|
||
|
|
adjustedX = this.x - menuWidth;
|
||
|
|
this.transformOrigin = 'bottom right';
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Default behavior (open downward/rightward)
|
||
|
|
// Flip horizontally if not enough space on right
|
||
|
|
if (spaceRight < menuWidth && spaceLeft >= menuWidth) {
|
||
|
|
adjustedX = this.x - menuWidth;
|
||
|
|
this.transformOrigin = this.transformOrigin.replace('left', 'right');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Flip vertically if not enough space below
|
||
|
|
if (spaceBottom < menuHeight && spaceTop >= menuHeight) {
|
||
|
|
adjustedY = this.y - menuHeight;
|
||
|
|
this.transformOrigin = this.transformOrigin.replace('top', 'bottom');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Final boundary checks to keep menu fully visible
|
||
|
|
adjustedX = Math.max(padding, Math.min(adjustedX, window.innerWidth - menuWidth - padding));
|
||
|
|
adjustedY = Math.max(padding, Math.min(adjustedY, window.innerHeight - menuHeight - padding));
|
||
|
|
|
||
|
|
this.style.left = `${adjustedX}px`;
|
||
|
|
this.style.top = `${adjustedY}px`;
|
||
|
|
|
||
|
|
// Update the menu's transform origin
|
||
|
|
const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement;
|
||
|
|
if (menu) {
|
||
|
|
menu.style.transformOrigin = this.transformOrigin;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public close(): void {
|
||
|
|
if (this.isClosing) return;
|
||
|
|
|
||
|
|
this.isClosing = true;
|
||
|
|
this.classList.add('closing');
|
||
|
|
|
||
|
|
// Wait for the next frame to ensure animation starts
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
// Listen for animation end
|
||
|
|
const menu = this.shadowRoot?.querySelector('.menu');
|
||
|
|
if (menu) {
|
||
|
|
menu.addEventListener(
|
||
|
|
'animationend',
|
||
|
|
() => {
|
||
|
|
this.remove();
|
||
|
|
},
|
||
|
|
{ once: true }
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
// Fallback if menu not found
|
||
|
|
setTimeout(() => this.remove(), 100);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleItemClick(item: IContextMenuItem): void {
|
||
|
|
if (!item.divider && item.action) {
|
||
|
|
item.action();
|
||
|
|
this.close();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public render(): TemplateResult {
|
||
|
|
return html`
|
||
|
|
<div class="menu">
|
||
|
|
${this.items.map((item) =>
|
||
|
|
item.divider
|
||
|
|
? html`<div class="divider"></div>`
|
||
|
|
: html`
|
||
|
|
<button
|
||
|
|
class="menu-item ${item.danger ? 'danger' : ''}"
|
||
|
|
@click=${() => this.handleItemClick(item)}
|
||
|
|
>
|
||
|
|
${item.icon
|
||
|
|
? html`<dees-mobile-icon icon="${item.icon}" size="16"></dees-mobile-icon>`
|
||
|
|
: ''}
|
||
|
|
${item.label || ''}
|
||
|
|
</button>
|
||
|
|
`
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|