update
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
298
ts_web/elements/sio-dropdown-menu.ts
Normal file
298
ts_web/elements/sio-dropdown-menu.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user