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`
+
+
+
+
+ ${this.items.map(item =>
+ item.divider ? html`
+
+ ` : 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