diff --git a/test/test.browser.ts b/test/test.browser.ts index 01b27b7..7573551 100644 --- a/test/test.browser.ts +++ b/test/test.browser.ts @@ -76,4 +76,34 @@ tap.test('render image lightbox component', async () => { document.body.removeChild(lightbox); }); +tap.test('render dropdown menu component', async () => { + // Create and add dropdown menu + const dropdown = new socialioCatalog.SioDropdownMenu(); + dropdown.items = [ + { id: 'option1', label: 'Option 1', icon: 'settings' }, + { id: 'option2', label: 'Option 2', icon: 'user' }, + { id: 'divider', label: '', divider: true }, + { id: 'delete', label: 'Delete', icon: 'trash', destructive: true } + ]; + document.body.appendChild(dropdown); + + await dropdown.updateComplete; + expect(dropdown).toBeInstanceOf(socialioCatalog.SioDropdownMenu); + + // Check main elements + const trigger = dropdown.shadowRoot.querySelector('.trigger'); + expect(trigger).toBeTruthy(); + + const dropdownElement = dropdown.shadowRoot.querySelector('.dropdown'); + expect(dropdownElement).toBeTruthy(); + + // Check menu items + const menuItems = dropdown.shadowRoot.querySelectorAll('.menu-item'); + expect(menuItems.length).toEqual(3); // 3 items (excluding divider) + + console.log('Dropdown menu component rendered successfully'); + + document.body.removeChild(dropdown); +}); + tap.start(); \ No newline at end of file diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index e953800..4bd6071 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -1,6 +1,7 @@ // Core components export * from './sio-icon.js'; export * from './sio-button.js'; +export * from './sio-dropdown-menu.js'; // Conversation components export * from './sio-conversation-selector.js'; diff --git a/ts_web/elements/sio-button.ts b/ts_web/elements/sio-button.ts index 93ef19e..478e0cc 100644 --- a/ts_web/elements/sio-button.ts +++ b/ts_web/elements/sio-button.ts @@ -260,11 +260,7 @@ export class SioButton extends DeesElement { return; } - // Dispatch a custom event with any data - this.dispatchEvent(new CustomEvent('click', { - detail: { originalEvent: event }, - bubbles: true, - composed: true - })); + // Let the native click bubble normally + // Don't dispatch a custom event to avoid double-triggering } } \ No newline at end of file diff --git a/ts_web/elements/sio-combox.ts b/ts_web/elements/sio-combox.ts index fed98c4..8f856ca 100644 --- a/ts_web/elements/sio-combox.ts +++ b/ts_web/elements/sio-combox.ts @@ -125,7 +125,7 @@ export class SioCombox extends DeesElement { border-radius: ${unsafeCSS(radius['2xl'])}; border: 1px solid ${bdTheme('border')}; box-shadow: ${unsafeCSS(shadows.xl)}; - overflow: hidden; + overflow: visible; font-family: ${unsafeCSS(fontFamilies.sans)}; position: relative; transform-origin: bottom right; @@ -164,6 +164,8 @@ export class SioCombox extends DeesElement { .container { display: flex; height: 100%; + overflow: visible; + border-radius: ${unsafeCSS(radius['2xl'])}; } /* Responsive layout */ diff --git a/ts_web/elements/sio-conversation-view.ts b/ts_web/elements/sio-conversation-view.ts index 4bf9075..0b7114a 100644 --- a/ts_web/elements/sio-conversation-view.ts +++ b/ts_web/elements/sio-conversation-view.ts @@ -14,6 +14,10 @@ import { import { colors, bdTheme } from './00colors.js'; import { spacing, radius, shadows, transitions } from './00tokens.js'; import { fontFamilies, typography } from './00fonts.js'; +import { SioDropdownMenu, type IDropdownMenuItem } from './sio-dropdown-menu.js'; + +// Make sure components are loaded +SioDropdownMenu; // Types export interface IAttachment { @@ -70,6 +74,17 @@ export class SioConversationView extends DeesElement { @state() private pendingAttachments: IAttachment[] = []; + private dropdownMenuItems: IDropdownMenuItem[] = [ + { id: 'mute', label: 'Mute notifications', icon: 'bell-off' }, + { id: 'pin', label: 'Pin conversation', icon: 'pin' }, + { id: 'search', label: 'Search in chat', icon: 'search' }, + { id: 'divider1', label: '', divider: true }, + { id: 'export', label: 'Export chat', icon: 'download' }, + { id: 'archive', label: 'Archive conversation', icon: 'archive' }, + { id: 'divider2', label: '', divider: true }, + { id: 'clear', label: 'Clear history', icon: 'trash-2', destructive: true } + ]; + public static styles = [ cssManager.defaultStyles, css` @@ -93,6 +108,7 @@ export class SioConversationView extends DeesElement { position: sticky; top: 0; z-index: 10; + overflow: visible; } .back-button { @@ -117,6 +133,8 @@ export class SioConversationView extends DeesElement { .header-actions { display: flex; gap: ${unsafeCSS(spacing["2"])}; + position: relative; + overflow: visible; } .messages-container { @@ -495,9 +513,14 @@ export class SioConversationView extends DeesElement { - - - + + + + + @@ -802,4 +825,53 @@ export class SioConversationView extends DeesElement { a.click(); document.body.removeChild(a); } + + private handleDropdownAction(event: CustomEvent) { + const { item } = event.detail as { item: IDropdownMenuItem }; + + // Dispatch event for parent to handle these actions + this.dispatchEvent(new CustomEvent('conversation-action', { + detail: { + action: item.id, + conversationId: this.conversation?.id + }, + bubbles: true, + composed: true + })); + + // Log action for demo purposes + console.log('Conversation action:', item.id, item.label); + + // Handle some actions locally for demo + switch (item.id) { + case 'search': + // Could open a search overlay + console.log('Opening search...'); + break; + case 'export': + // Export conversation as JSON/text + this.exportConversation(); + break; + } + } + + private exportConversation() { + if (!this.conversation) return; + + const exportData = { + conversation: this.conversation.title, + exportDate: new Date().toISOString(), + messages: this.conversation.messages + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.conversation.title.replace(/\s+/g, '-')}-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } } \ No newline at end of file diff --git a/ts_web/elements/sio-dropdown-menu.ts b/ts_web/elements/sio-dropdown-menu.ts new file mode 100644 index 0000000..6e2d691 --- /dev/null +++ b/ts_web/elements/sio-dropdown-menu.ts @@ -0,0 +1,298 @@ +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` +
+ + + + + +
+ `; + + @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` +
+ +
+ + `; + } + + 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(); + } +} \ No newline at end of file