298 lines
7.8 KiB
TypeScript
298 lines
7.8 KiB
TypeScript
|
|
import {
|
||
|
|
DeesElement,
|
||
|
|
property,
|
||
|
|
html,
|
||
|
|
customElement,
|
||
|
|
type TemplateResult,
|
||
|
|
cssManager,
|
||
|
|
css,
|
||
|
|
unsafeCSS,
|
||
|
|
state,
|
||
|
|
} from '@design.estate/dees-element';
|
||
|
|
|
||
|
|
// Import design tokens
|
||
|
|
import { colors, bdTheme } from './00colors.js';
|
||
|
|
import { spacing, radius, shadows, transitions } from './00tokens.js';
|
||
|
|
import { fontFamilies } from './00fonts.js';
|
||
|
|
|
||
|
|
export interface IDropdownMenuItem {
|
||
|
|
id: string;
|
||
|
|
label: string;
|
||
|
|
icon?: string;
|
||
|
|
divider?: boolean;
|
||
|
|
destructive?: boolean;
|
||
|
|
disabled?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
declare global {
|
||
|
|
interface HTMLElementTagNameMap {
|
||
|
|
'sio-dropdown-menu': SioDropdownMenu;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@customElement('sio-dropdown-menu')
|
||
|
|
export class SioDropdownMenu extends DeesElement {
|
||
|
|
public static demo = () => html`
|
||
|
|
<div style="position: relative; height: 200px; display: flex; justify-content: center; padding-top: 50px;">
|
||
|
|
<sio-dropdown-menu .items=${[
|
||
|
|
{ id: 'mute', label: 'Mute notifications', icon: 'bell-off' },
|
||
|
|
{ id: 'pin', label: 'Pin conversation', icon: 'pin' },
|
||
|
|
{ id: 'divider1', label: '', divider: true },
|
||
|
|
{ id: 'export', label: 'Export chat', icon: 'download' },
|
||
|
|
{ id: 'clear', label: 'Clear history', icon: 'trash-2', destructive: true }
|
||
|
|
]}>
|
||
|
|
<sio-button type="ghost" size="sm">
|
||
|
|
<sio-icon icon="more-vertical" size="16"></sio-icon>
|
||
|
|
</sio-button>
|
||
|
|
</sio-dropdown-menu>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
@property({ type: Array })
|
||
|
|
public items: IDropdownMenuItem[] = [];
|
||
|
|
|
||
|
|
@property({ type: String })
|
||
|
|
public align: 'left' | 'right' = 'right';
|
||
|
|
|
||
|
|
@state()
|
||
|
|
private isOpen: boolean = false;
|
||
|
|
|
||
|
|
private documentClickHandler: (e: MouseEvent) => void;
|
||
|
|
private scrollHandler: () => void;
|
||
|
|
private resizeHandler: () => void;
|
||
|
|
|
||
|
|
public static styles = [
|
||
|
|
cssManager.defaultStyles,
|
||
|
|
css`
|
||
|
|
:host {
|
||
|
|
position: relative;
|
||
|
|
display: inline-block;
|
||
|
|
font-family: ${unsafeCSS(fontFamilies.sans)};
|
||
|
|
}
|
||
|
|
|
||
|
|
.trigger {
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.dropdown {
|
||
|
|
position: absolute;
|
||
|
|
top: calc(100% + 10px);
|
||
|
|
right: 0;
|
||
|
|
min-width: 200px;
|
||
|
|
background: ${bdTheme('background')};
|
||
|
|
border: 1px solid ${bdTheme('border')};
|
||
|
|
border-radius: ${unsafeCSS(radius.lg)};
|
||
|
|
box-shadow: ${unsafeCSS(shadows.lg)};
|
||
|
|
overflow: hidden;
|
||
|
|
z-index: 100000;
|
||
|
|
opacity: 0;
|
||
|
|
transform: translateY(-10px) scale(0.95);
|
||
|
|
pointer-events: none;
|
||
|
|
transition: all 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||
|
|
transform-origin: top right;
|
||
|
|
}
|
||
|
|
|
||
|
|
.dropdown.align-left {
|
||
|
|
right: auto;
|
||
|
|
left: 0;
|
||
|
|
transform-origin: top left;
|
||
|
|
}
|
||
|
|
|
||
|
|
.dropdown.open {
|
||
|
|
opacity: 1;
|
||
|
|
transform: translateY(0) scale(1);
|
||
|
|
pointer-events: all;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: ${unsafeCSS(spacing["3"])};
|
||
|
|
padding: ${unsafeCSS(spacing["2.5"])} ${unsafeCSS(spacing["3"])};
|
||
|
|
font-size: 0.875rem;
|
||
|
|
line-height: 1.5;
|
||
|
|
color: ${bdTheme('foreground')};
|
||
|
|
cursor: pointer;
|
||
|
|
transition: ${unsafeCSS(transitions.all)};
|
||
|
|
user-select: none;
|
||
|
|
border: none;
|
||
|
|
background: none;
|
||
|
|
width: 100%;
|
||
|
|
text-align: left;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item:hover:not(.disabled) {
|
||
|
|
background: ${bdTheme('accent')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item:active:not(.disabled) {
|
||
|
|
transform: scale(0.98);
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item.disabled {
|
||
|
|
opacity: 0.5;
|
||
|
|
cursor: not-allowed;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item.destructive {
|
||
|
|
color: ${bdTheme('destructive')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item.destructive:hover:not(.disabled) {
|
||
|
|
background: ${bdTheme('destructive')}10;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-icon {
|
||
|
|
flex-shrink: 0;
|
||
|
|
color: ${bdTheme('mutedForeground')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-item.destructive .menu-icon {
|
||
|
|
color: ${bdTheme('destructive')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-label {
|
||
|
|
flex: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.divider {
|
||
|
|
height: 1px;
|
||
|
|
background: ${bdTheme('border')};
|
||
|
|
margin: ${unsafeCSS(spacing["1"])} 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Mobile adjustments */
|
||
|
|
@media (max-width: 600px) {
|
||
|
|
.dropdown {
|
||
|
|
position: fixed;
|
||
|
|
top: auto !important;
|
||
|
|
left: ${unsafeCSS(spacing["4"])} !important;
|
||
|
|
right: ${unsafeCSS(spacing["4"])};
|
||
|
|
bottom: ${unsafeCSS(spacing["4"])};
|
||
|
|
width: auto;
|
||
|
|
transform-origin: bottom center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.dropdown.open {
|
||
|
|
transform: translateY(0) scale(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.dropdown:not(.open) {
|
||
|
|
transform: translateY(10px) scale(0.95);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`,
|
||
|
|
];
|
||
|
|
|
||
|
|
public render(): TemplateResult {
|
||
|
|
return html`
|
||
|
|
<div class="trigger" @click=${this.toggleDropdown}>
|
||
|
|
<slot></slot>
|
||
|
|
</div>
|
||
|
|
<div class="dropdown ${this.isOpen ? 'open' : ''} align-${this.align}">
|
||
|
|
${this.items.map(item =>
|
||
|
|
item.divider ? html`
|
||
|
|
<div class="divider"></div>
|
||
|
|
` : html`
|
||
|
|
<button
|
||
|
|
class="menu-item ${item.destructive ? 'destructive' : ''} ${item.disabled ? 'disabled' : ''}"
|
||
|
|
@click=${() => this.handleItemClick(item)}
|
||
|
|
?disabled=${item.disabled}
|
||
|
|
>
|
||
|
|
${item.icon ? html`
|
||
|
|
<sio-icon class="menu-icon" icon="${item.icon}" size="16"></sio-icon>
|
||
|
|
` : ''}
|
||
|
|
<span class="menu-label">${item.label}</span>
|
||
|
|
</button>
|
||
|
|
`
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
private toggleDropdown = (e: Event) => {
|
||
|
|
console.log('[Dropdown] Toggle called, current state:', this.isOpen);
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
|
||
|
|
this.isOpen = !this.isOpen;
|
||
|
|
console.log('[Dropdown] New state:', this.isOpen);
|
||
|
|
|
||
|
|
if (this.isOpen) {
|
||
|
|
this.addDocumentListener();
|
||
|
|
} else {
|
||
|
|
this.removeDocumentListener();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private updateDropdownPosition() {
|
||
|
|
// For absolute positioning, we don't need to calculate position dynamically
|
||
|
|
// The CSS handles it with top: calc(100% + 10px) and right: 0
|
||
|
|
console.log('[Dropdown] Position is handled by CSS (absolute positioning)');
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleItemClick(item: IDropdownMenuItem) {
|
||
|
|
if (item.disabled || item.divider) return;
|
||
|
|
|
||
|
|
this.isOpen = false;
|
||
|
|
this.removeDocumentListener();
|
||
|
|
|
||
|
|
// Dispatch custom event with the selected item
|
||
|
|
this.dispatchEvent(new CustomEvent('item-selected', {
|
||
|
|
detail: { item },
|
||
|
|
bubbles: true,
|
||
|
|
composed: true
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
private addDocumentListener() {
|
||
|
|
// Close dropdown when clicking outside
|
||
|
|
this.documentClickHandler = (e: MouseEvent) => {
|
||
|
|
const path = e.composedPath();
|
||
|
|
if (!path.includes(this)) {
|
||
|
|
this.isOpen = false;
|
||
|
|
this.removeDocumentListener();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Update position on scroll/resize
|
||
|
|
this.scrollHandler = () => this.updateDropdownPosition();
|
||
|
|
this.resizeHandler = () => {
|
||
|
|
this.updateDropdownPosition();
|
||
|
|
if (window.innerWidth <= 600) {
|
||
|
|
// Close on mobile resize to prevent positioning issues
|
||
|
|
this.isOpen = false;
|
||
|
|
this.removeDocumentListener();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Delay to avoid immediate closing
|
||
|
|
setTimeout(() => {
|
||
|
|
document.addEventListener('click', this.documentClickHandler);
|
||
|
|
window.addEventListener('scroll', this.scrollHandler, true);
|
||
|
|
window.addEventListener('resize', this.resizeHandler);
|
||
|
|
}, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
private removeDocumentListener() {
|
||
|
|
if (this.documentClickHandler) {
|
||
|
|
document.removeEventListener('click', this.documentClickHandler);
|
||
|
|
}
|
||
|
|
if (this.scrollHandler) {
|
||
|
|
window.removeEventListener('scroll', this.scrollHandler, true);
|
||
|
|
}
|
||
|
|
if (this.resizeHandler) {
|
||
|
|
window.removeEventListener('resize', this.resizeHandler);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public async disconnectedCallback() {
|
||
|
|
await super.disconnectedCallback();
|
||
|
|
this.removeDocumentListener();
|
||
|
|
}
|
||
|
|
|
||
|
|
public close() {
|
||
|
|
this.isOpen = false;
|
||
|
|
this.removeDocumentListener();
|
||
|
|
}
|
||
|
|
}
|