feat(ui): add mobile context menu and iconbutton components with demos and exports
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { IContextMenuItem } from './dees-mobile-contextmenu.js';
|
||||
|
||||
export const demoFunc = (): TemplateResult => {
|
||||
const showContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const items: IContextMenuItem[] = [
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'pencil',
|
||||
action: () => console.log('Edit clicked'),
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
icon: 'copy',
|
||||
action: () => console.log('Duplicate clicked'),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: 'Share',
|
||||
icon: 'share',
|
||||
action: () => console.log('Share clicked'),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'trash-2',
|
||||
danger: true,
|
||||
action: () => console.log('Delete clicked'),
|
||||
},
|
||||
];
|
||||
|
||||
import('./dees-mobile-contextmenu.js').then(({ DeesMobileContextmenu }) => {
|
||||
DeesMobileContextmenu.createAndShow(items, e.clientX, e.clientY);
|
||||
});
|
||||
};
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.demo-container {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.demo-area {
|
||||
padding: 3rem;
|
||||
border: 2px dashed #e4e4e7;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
cursor: context-menu;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.demo-area:hover {
|
||||
border-color: #a1a1aa;
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.demo-description {
|
||||
font-size: 0.875rem;
|
||||
color: #71717a;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="demo-description">
|
||||
Right-click (or long-press on touch) to show context menu
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="demo-area"
|
||||
@contextmenu=${showContextMenu}
|
||||
>
|
||||
Right-click here
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-mobile-contextmenu.js';
|
||||
@@ -0,0 +1,89 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import './dees-mobile-iconbutton.js';
|
||||
import '../dees-mobile-icon/dees-mobile-icon.js';
|
||||
|
||||
export const demoFunc = (): TemplateResult => {
|
||||
return html`
|
||||
<style>
|
||||
.demo-container {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.demo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="demo-section">
|
||||
<div class="demo-label">Sizes</div>
|
||||
<div class="demo-row">
|
||||
<dees-mobile-iconbutton size="sm" label="Small">
|
||||
<dees-mobile-icon icon="settings" size="16"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
|
||||
<dees-mobile-iconbutton size="md" label="Medium">
|
||||
<dees-mobile-icon icon="settings" size="20"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
|
||||
<dees-mobile-iconbutton size="lg" label="Large">
|
||||
<dees-mobile-icon icon="settings" size="24"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<div class="demo-label">Common Actions</div>
|
||||
<div class="demo-row">
|
||||
<dees-mobile-iconbutton label="Edit">
|
||||
<dees-mobile-icon icon="pencil" size="20"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
|
||||
<dees-mobile-iconbutton label="Delete">
|
||||
<dees-mobile-icon icon="trash-2" size="20"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
|
||||
<dees-mobile-iconbutton label="Share">
|
||||
<dees-mobile-icon icon="share" size="20"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
|
||||
<dees-mobile-iconbutton label="More">
|
||||
<dees-mobile-icon icon="more-vertical" size="20"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<div class="demo-label">States</div>
|
||||
<div class="demo-row">
|
||||
<dees-mobile-iconbutton label="Normal">
|
||||
<dees-mobile-icon icon="check" size="20"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
|
||||
<dees-mobile-iconbutton label="Disabled" disabled>
|
||||
<dees-mobile-icon icon="check" size="20"></dees-mobile-icon>
|
||||
</dees-mobile-iconbutton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { mobileComponentStyles } from '../../00componentstyles.js';
|
||||
import { demoFunc } from './dees-mobile-iconbutton.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-mobile-iconbutton': DeesMobileIconbutton;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-mobile-iconbutton')
|
||||
export class DeesMobileIconbutton extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
accessor label: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor disabled: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
accessor size: 'sm' | 'md' | 'lg' = 'md';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
mobileComponentStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
transform: scale(1);
|
||||
position: relative;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background-color: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
button.sm {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
button.md {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
button.lg {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
::slotted(svg),
|
||||
::slotted(div),
|
||||
::slotted(dees-mobile-icon) {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button.sm ::slotted(svg),
|
||||
button.sm ::slotted(div),
|
||||
button.sm ::slotted(dees-mobile-icon) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
button.lg ::slotted(svg),
|
||||
button.lg ::slotted(div),
|
||||
button.lg ::slotted(dees-mobile-icon) {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<button
|
||||
class=${this.size}
|
||||
?disabled=${this.disabled}
|
||||
aria-label=${this.label}
|
||||
title=${this.label}
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-mobile-iconbutton.js';
|
||||
@@ -1,6 +1,8 @@
|
||||
// Core UI Components
|
||||
export * from './dees-mobile-button/index.js';
|
||||
export * from './dees-mobile-contextmenu/index.js';
|
||||
export * from './dees-mobile-icon/index.js';
|
||||
export * from './dees-mobile-iconbutton/index.js';
|
||||
export * from './dees-mobile-header/index.js';
|
||||
export * from './dees-mobile-modal/index.js';
|
||||
export * from './dees-mobile-actionsheet/index.js';
|
||||
|
||||
Reference in New Issue
Block a user