Files
dees-catalog-mobile/ts_web/elements/00group-ui/dees-mobile-contextmenu/dees-mobile-contextmenu.ts

284 lines
7.3 KiB
TypeScript
Raw Normal View History

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>
`;
}
}