490 lines
15 KiB
TypeScript
490 lines
15 KiB
TypeScript
import {
|
|
DeesElement,
|
|
property,
|
|
html,
|
|
customElement,
|
|
type TemplateResult,
|
|
cssManager,
|
|
css,
|
|
unsafeCSS,
|
|
state,
|
|
} from '@design.estate/dees-element';
|
|
import * as domtools from '@design.estate/dees-domtools';
|
|
|
|
// Import design tokens
|
|
import { colors, bdTheme } from './00colors.js';
|
|
import { spacing, radius, shadows, transitions } from './00tokens.js';
|
|
import { fontFamilies, typography } from './00fonts.js';
|
|
|
|
// Import components
|
|
import { SioConversationSelector, type IConversation } from './sio-conversation-selector.js';
|
|
import { SioConversationView, type IMessage, type IConversationData, type IAttachment } from './sio-conversation-view.js';
|
|
import { SioImageLightbox, type ILightboxImage } from './sio-image-lightbox.js';
|
|
|
|
// Make sure components are loaded
|
|
SioConversationSelector;
|
|
SioConversationView;
|
|
SioImageLightbox;
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'sio-combox': SioCombox;
|
|
}
|
|
}
|
|
|
|
@customElement('sio-combox')
|
|
export class SioCombox extends DeesElement {
|
|
public static demo = () => html` <sio-combox></sio-combox> `;
|
|
|
|
// Singleton instance
|
|
private static instance: SioCombox | null = null;
|
|
|
|
/**
|
|
* Creates and appends a singleton combox to document.body
|
|
*/
|
|
public static createOnBody(): SioCombox {
|
|
if (!SioCombox.instance) {
|
|
SioCombox.instance = new SioCombox();
|
|
document.body.appendChild(SioCombox.instance);
|
|
}
|
|
return SioCombox.instance;
|
|
}
|
|
|
|
/**
|
|
* Gets the singleton instance if it exists
|
|
*/
|
|
public static getInstance(): SioCombox | null {
|
|
return SioCombox.instance;
|
|
}
|
|
|
|
@property({ type: Object })
|
|
public accessor referenceObject: HTMLElement;
|
|
|
|
@state()
|
|
private accessor selectedConversationId: string | null = null;
|
|
|
|
@state()
|
|
private accessor isKeyboardVisible: boolean = false;
|
|
|
|
@state()
|
|
private accessor isOpen: boolean = false;
|
|
|
|
private keyboardBlurTimeout?: number;
|
|
|
|
@state()
|
|
private accessor conversations: IConversation[] = [
|
|
{
|
|
id: '1',
|
|
title: 'Technical Support',
|
|
lastMessage: 'Thanks for your help with the login issue!',
|
|
time: '2 min ago',
|
|
unread: true,
|
|
status: 'new',
|
|
},
|
|
{
|
|
id: '2',
|
|
title: 'Billing Question',
|
|
lastMessage: 'I need help understanding my invoice',
|
|
time: '1 hour ago',
|
|
status: 'needs-action',
|
|
},
|
|
{
|
|
id: '3',
|
|
title: 'Feature Request',
|
|
lastMessage: 'That would be great! Looking forward to it',
|
|
time: 'Yesterday',
|
|
status: 'waiting',
|
|
},
|
|
{
|
|
id: '4',
|
|
title: 'General Inquiry',
|
|
lastMessage: 'Thank you for the information',
|
|
time: '2 days ago',
|
|
status: 'resolved',
|
|
}
|
|
];
|
|
|
|
@state()
|
|
private accessor messages: { [conversationId: string]: IMessage[] } = {
|
|
'1': [
|
|
{ id: '1', text: 'Hi, I\'m having trouble logging in', sender: 'user', time: '10:00 AM' },
|
|
{ id: '2', text: 'I can help you with that. Can you tell me what error you\'re seeing?', sender: 'support', time: '10:02 AM' },
|
|
{ id: '3', text: 'It says "Invalid credentials" but I\'m sure my password is correct', sender: 'user', time: '10:03 AM' },
|
|
{ id: '4', text: 'Let me check your account. Please try resetting your password using the forgot password link.', sender: 'support', time: '10:05 AM' },
|
|
{
|
|
id: '5',
|
|
text: 'Here\'s a screenshot of the error',
|
|
sender: 'user',
|
|
time: '10:08 AM',
|
|
attachments: [{
|
|
id: 'att1',
|
|
name: 'error-screenshot.png',
|
|
size: 245780,
|
|
type: 'image/png',
|
|
url: 'https://picsum.photos/400/300?random=1'
|
|
}]
|
|
},
|
|
{ id: '6', text: 'Thanks for your help with the login issue!', sender: 'user', time: '10:10 AM' },
|
|
{
|
|
id: '7',
|
|
text: 'Here is the documentation you requested',
|
|
sender: 'support',
|
|
time: '10:15 AM',
|
|
attachments: [{
|
|
id: 'att2',
|
|
name: 'user-guide.pdf',
|
|
size: 2457600,
|
|
type: 'application/pdf',
|
|
url: 'data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G'
|
|
}]
|
|
},
|
|
],
|
|
'2': [
|
|
{ id: '1', text: 'I need help understanding my invoice', sender: 'user', time: '9:00 AM' },
|
|
{ id: '2', text: 'I\'d be happy to help explain your invoice. Which part is unclear?', sender: 'support', time: '9:05 AM' },
|
|
],
|
|
'3': [
|
|
{ id: '1', text: 'I\'d love to see dark mode support in your app!', sender: 'user', time: 'Yesterday' },
|
|
{ id: '2', text: 'Thanks for the suggestion! We\'re actually working on dark mode and it should be available next month.', sender: 'support', time: 'Yesterday' },
|
|
{ id: '3', text: 'That would be great! Looking forward to it', sender: 'user', time: 'Yesterday' },
|
|
],
|
|
'4': [
|
|
{ id: '1', text: 'Can you tell me more about your enterprise plans?', sender: 'user', time: '2 days ago' },
|
|
{ id: '2', text: 'Of course! Our enterprise plans include advanced features like SSO, dedicated support, and custom integrations.', sender: 'support', time: '2 days ago' },
|
|
{ id: '3', text: 'Thank you for the information', sender: 'user', time: '2 days ago' },
|
|
]
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
domtools.DomTools.setupDomTools();
|
|
}
|
|
|
|
async connectedCallback() {
|
|
await super.connectedCallback();
|
|
this.addEventListener('input-focus', this.handleInputFocus as EventListener);
|
|
this.addEventListener('input-blur', this.handleInputBlur as EventListener);
|
|
}
|
|
|
|
async disconnectedCallback() {
|
|
await super.disconnectedCallback();
|
|
this.removeEventListener('input-focus', this.handleInputFocus as EventListener);
|
|
this.removeEventListener('input-blur', this.handleInputBlur as EventListener);
|
|
if (this.keyboardBlurTimeout) {
|
|
clearTimeout(this.keyboardBlurTimeout);
|
|
}
|
|
}
|
|
|
|
private handleInputFocus = () => {
|
|
if (this.keyboardBlurTimeout) {
|
|
clearTimeout(this.keyboardBlurTimeout);
|
|
this.keyboardBlurTimeout = undefined;
|
|
}
|
|
this.isKeyboardVisible = true;
|
|
}
|
|
|
|
private handleInputBlur = () => {
|
|
if (this.keyboardBlurTimeout) {
|
|
clearTimeout(this.keyboardBlurTimeout);
|
|
}
|
|
this.keyboardBlurTimeout = window.setTimeout(() => {
|
|
this.isKeyboardVisible = false;
|
|
this.keyboardBlurTimeout = undefined;
|
|
}, 150);
|
|
}
|
|
|
|
updated(changedProperties: Map<string, any>) {
|
|
super.updated(changedProperties);
|
|
if (changedProperties.has('isKeyboardVisible')) {
|
|
if (this.isKeyboardVisible) {
|
|
this.setAttribute('keyboard-visible', '');
|
|
} else {
|
|
this.removeAttribute('keyboard-visible');
|
|
}
|
|
}
|
|
if (changedProperties.has('isOpen')) {
|
|
if (this.isOpen) {
|
|
this.classList.add('open');
|
|
this.dispatchEvent(new CustomEvent('opened', { bubbles: true, composed: true }));
|
|
} else {
|
|
this.classList.remove('open');
|
|
this.dispatchEvent(new CustomEvent('closed', { bubbles: true, composed: true }));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens the combox
|
|
*/
|
|
public open() {
|
|
this.isOpen = true;
|
|
}
|
|
|
|
/**
|
|
* Closes the combox
|
|
*/
|
|
public close() {
|
|
this.isOpen = false;
|
|
this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
|
|
}
|
|
|
|
/**
|
|
* Toggles the combox open/closed state
|
|
*/
|
|
public toggle() {
|
|
if (this.isOpen) {
|
|
this.close();
|
|
} else {
|
|
this.open();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns whether the combox is currently open
|
|
*/
|
|
public getIsOpen(): boolean {
|
|
return this.isOpen;
|
|
}
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
position: fixed;
|
|
bottom: 100px;
|
|
right: 20px;
|
|
height: 600px;
|
|
width: 800px;
|
|
background: ${bdTheme('background')};
|
|
border-radius: ${unsafeCSS(radius['2xl'])};
|
|
border: 1px solid ${bdTheme('border')};
|
|
box-shadow: ${unsafeCSS(shadows.xl)};
|
|
overflow: hidden;
|
|
font-family: ${unsafeCSS(fontFamilies.sans)};
|
|
transform-origin: bottom right;
|
|
z-index: 10001;
|
|
|
|
/* Hidden by default */
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transform: scale(0.95) translateY(10px);
|
|
transition: opacity 200ms ease, transform 200ms ease;
|
|
}
|
|
|
|
:host(.open) {
|
|
opacity: 1;
|
|
pointer-events: all;
|
|
transform: scale(1) translateY(0);
|
|
}
|
|
|
|
:host::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: ${unsafeCSS(radius['2xl'])};
|
|
padding: 1px;
|
|
background: linear-gradient(145deg, ${bdTheme('border')}, transparent 50%);
|
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
|
-webkit-mask-composite: exclude;
|
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
|
mask-composite: exclude;
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.container {
|
|
display: flex;
|
|
height: 100%;
|
|
overflow: visible;
|
|
border-radius: ${unsafeCSS(radius['2xl'])};
|
|
}
|
|
|
|
/* Desktop layout (default) */
|
|
sio-conversation-selector {
|
|
width: 320px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
sio-conversation-view {
|
|
flex: 1;
|
|
}
|
|
`,
|
|
// Mobile responsive layout - full screen with sliding mechanics
|
|
cssManager.cssForPhablet(css`
|
|
:host {
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
border-radius: 0;
|
|
transform-origin: center center;
|
|
}
|
|
|
|
:host(.open) {
|
|
transform: scale(1) translateY(0);
|
|
}
|
|
|
|
:host::before {
|
|
border-radius: 0;
|
|
}
|
|
|
|
.container {
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
sio-conversation-selector {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
transition: left 300ms ease, opacity 200ms ease;
|
|
}
|
|
|
|
sio-conversation-view {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
transition: left 300ms ease, opacity 200ms ease;
|
|
}
|
|
|
|
/* Mobile navigation states */
|
|
.container.show-list sio-conversation-selector {
|
|
left: 0;
|
|
opacity: 1;
|
|
}
|
|
|
|
.container.show-list sio-conversation-view {
|
|
left: 100%;
|
|
opacity: 0;
|
|
}
|
|
|
|
.container.show-conversation sio-conversation-selector {
|
|
left: -100%;
|
|
opacity: 0;
|
|
}
|
|
|
|
.container.show-conversation sio-conversation-view {
|
|
left: 0;
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Keyboard visible adjustments */
|
|
:host([keyboard-visible]) {
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
}
|
|
`),
|
|
];
|
|
|
|
public render(): TemplateResult {
|
|
const selectedConversation = this.selectedConversationId
|
|
? this.conversations.find(c => c.id === this.selectedConversationId)
|
|
: null;
|
|
|
|
const conversationData: IConversationData | null = selectedConversation
|
|
? {
|
|
id: selectedConversation.id,
|
|
title: selectedConversation.title,
|
|
messages: this.messages[selectedConversation.id] || []
|
|
}
|
|
: null;
|
|
|
|
const containerClass = this.selectedConversationId ? 'show-conversation' : 'show-list';
|
|
|
|
return html`
|
|
<div class="container ${containerClass}">
|
|
<sio-conversation-selector
|
|
.conversations=${this.conversations}
|
|
.selectedConversationId=${this.selectedConversationId}
|
|
@conversation-selected=${this.handleConversationSelected}
|
|
></sio-conversation-selector>
|
|
|
|
<sio-conversation-view
|
|
.conversation=${conversationData}
|
|
@back=${this.handleBack}
|
|
@send-message=${this.handleSendMessage}
|
|
@open-image=${this.handleOpenImage}
|
|
@open-file=${this.handleOpenImage}
|
|
></sio-conversation-view>
|
|
</div>
|
|
|
|
<sio-image-lightbox></sio-image-lightbox>
|
|
`;
|
|
}
|
|
|
|
private handleConversationSelected(event: CustomEvent) {
|
|
const conversation = event.detail.conversation as IConversation;
|
|
this.selectedConversationId = conversation.id;
|
|
|
|
// Mark conversation as read
|
|
const convIndex = this.conversations.findIndex(c => c.id === conversation.id);
|
|
if (convIndex !== -1) {
|
|
this.conversations[convIndex] = { ...this.conversations[convIndex], unread: false };
|
|
this.conversations = [...this.conversations];
|
|
}
|
|
}
|
|
|
|
private handleBack() {
|
|
// For mobile view, go back to conversation list
|
|
this.selectedConversationId = null;
|
|
}
|
|
|
|
private handleSendMessage(event: CustomEvent) {
|
|
const message = event.detail.message as IMessage;
|
|
const conversationId = this.selectedConversationId;
|
|
|
|
if (conversationId) {
|
|
// Add message to the conversation
|
|
if (!this.messages[conversationId]) {
|
|
this.messages[conversationId] = [];
|
|
}
|
|
this.messages[conversationId] = [...this.messages[conversationId], message];
|
|
this.messages = { ...this.messages };
|
|
|
|
// Update conversation's last message
|
|
const convIndex = this.conversations.findIndex(c => c.id === conversationId);
|
|
if (convIndex !== -1) {
|
|
this.conversations[convIndex] = {
|
|
...this.conversations[convIndex],
|
|
lastMessage: message.text,
|
|
time: 'Just now'
|
|
};
|
|
// Move conversation to top
|
|
const [conv] = this.conversations.splice(convIndex, 1);
|
|
this.conversations = [conv, ...this.conversations];
|
|
}
|
|
|
|
// Simulate a response after a delay (remove in production)
|
|
setTimeout(() => {
|
|
const responseMessage: IMessage = {
|
|
id: Date.now().toString(),
|
|
text: 'Thanks for your message! We\'ll get back to you shortly.',
|
|
sender: 'support',
|
|
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
};
|
|
|
|
this.messages[conversationId] = [...this.messages[conversationId], responseMessage];
|
|
this.messages = { ...this.messages };
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
private handleOpenImage(event: CustomEvent) {
|
|
const attachment = event.detail.attachment as IAttachment;
|
|
const lightbox = this.shadowRoot?.querySelector('sio-image-lightbox') as SioImageLightbox;
|
|
|
|
if (lightbox && attachment) {
|
|
const lightboxFile: ILightboxImage = {
|
|
url: attachment.url,
|
|
name: attachment.name,
|
|
size: attachment.size,
|
|
type: attachment.type
|
|
};
|
|
lightbox.open(lightboxFile);
|
|
}
|
|
}
|
|
} |