This commit is contained in:
2025-07-14 17:26:57 +00:00
parent 95e92a5533
commit 193b1f5234
6 changed files with 409 additions and 10 deletions

View File

@@ -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';

View File

@@ -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
}
}

View File

@@ -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 */

View File

@@ -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 {
<sio-button type="ghost" size="sm">
<sio-icon icon="phone" size="16"></sio-icon>
</sio-button>
<sio-button type="ghost" size="sm">
<sio-icon icon="more-vertical" size="16"></sio-icon>
</sio-button>
<sio-dropdown-menu
.items=${this.dropdownMenuItems}
@item-selected=${this.handleDropdownAction}
>
<sio-button type="ghost" size="sm">
<sio-icon icon="more-vertical" size="16"></sio-icon>
</sio-button>
</sio-dropdown-menu>
</div>
</div>
@@ -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);
}
}

View File

@@ -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`
<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();
}
}