@ingridsandev/chatbot-widget
Version:
A framework-agnostic chatbot widget using Web Components
383 lines (322 loc) • 10.4 kB
JavaScript
import ChatInterface from './chat-interface.js';
import AgentSelector from './agent-selector.js';
import EventEmitter from '../utils/event-emitter.js';
import Storage from '../utils/storage.js';
import '../styles/chatbot.css';
import '../styles/themes.css';
class ChatbotWidget extends HTMLElement {
constructor() {
super();
this.eventEmitter = new EventEmitter();
this.storage = new Storage();
this.isOpen = false;
// Default configuration
this.config = {
agents: [{ id: 'default', name: 'AI Assistant', avatar: '🤖' }],
theme: 'light',
position: 'bottom-right',
title: 'AI Assistant',
placeholder: 'Type your message...',
open: false
};
// Bind methods
this.handleMessageSend = this.handleMessageSend.bind(this);
this.handleAgentChange = this.handleAgentChange.bind(this);
// Store bound event handlers for cleanup
this.boundClickOutside = this.handleClickOutside.bind(this);
this.boundKeydown = this.handleKeydown.bind(this);
}
static get observedAttributes() {
return ['agents', 'theme', 'position', 'open', 'title', 'placeholder'];
}
connectedCallback() {
this.loadConfig();
this.render();
this.setupEventListeners();
// Restore open state
if (this.config.open || this.storage.get('isOpen', false)) {
setTimeout(() => this.open(), 100);
}
}
disconnectedCallback() {
this.cleanup();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
try {
switch (name) {
case 'agents':
try {
this.config.agents = JSON.parse(newValue);
this.updateAgents();
} catch {
console.warn('Invalid agents JSON:', newValue);
}
break;
case 'theme':
this.config.theme = newValue;
this.updateTheme();
break;
case 'position':
this.config.position = newValue;
this.updatePosition();
break;
case 'open':
this.config.open = newValue === 'true' || newValue === '';
if (this.config.open) {
this.open();
} else {
this.close();
}
break;
case 'title':
this.config.title = newValue;
this.updateTitle();
break;
case 'placeholder':
this.config.placeholder = newValue;
this.updatePlaceholder();
break;
}
} catch (error) {
console.warn(`Error handling attribute change for ${name}:`, error);
}
}
loadConfig() {
// Load from attributes
const agentsAttr = this.getAttribute('agents');
if (agentsAttr) {
try {
this.config.agents = JSON.parse(agentsAttr);
} catch {
console.warn('Invalid agents attribute:', agentsAttr);
}
}
this.config.theme = this.getAttribute('theme') || this.config.theme;
this.config.position = this.getAttribute('position') || this.config.position;
this.config.title = this.getAttribute('title') || this.config.title;
this.config.placeholder = this.getAttribute('placeholder') || this.config.placeholder;
this.config.open = this.hasAttribute('open');
}
render() {
this.className = `chatbot-widget position-${this.config.position} theme-${this.config.theme}`;
// Create trigger button
this.triggerButton = document.createElement('button');
this.triggerButton.className = 'chatbot-trigger';
this.triggerButton.innerHTML = '💬';
this.triggerButton.setAttribute('aria-label', 'Open chat');
// Create chat container
this.chatContainer = document.createElement('div');
this.chatContainer.className = 'chatbot-container';
// Header
const header = document.createElement('div');
header.className = 'chatbot-header';
this.titleElement = document.createElement('h3');
this.titleElement.className = 'chatbot-title';
this.titleElement.textContent = this.config.title;
const closeButton = document.createElement('button');
closeButton.className = 'chatbot-close';
closeButton.innerHTML = '✕';
closeButton.setAttribute('aria-label', 'Close chat');
header.appendChild(this.titleElement);
header.appendChild(closeButton);
// Agent selector
this.agentSelector = new AgentSelector(this.config.agents);
const agentSelectorEl = this.agentSelector.render();
// Chat interface
this.chatInterface = new ChatInterface({
placeholder: this.config.placeholder
});
const chatInterfaceEl = this.chatInterface.render();
// Assembly
this.chatContainer.appendChild(header);
this.chatContainer.appendChild(agentSelectorEl);
this.chatContainer.appendChild(chatInterfaceEl);
this.appendChild(this.triggerButton);
this.appendChild(this.chatContainer);
// Event listeners
this.triggerButton.addEventListener('click', () => this.toggle());
closeButton.addEventListener('click', () => this.close());
}
setupEventListeners() {
// Chat interface events
this.chatInterface.on('messageSend', this.handleMessageSend);
// Agent selector events
this.agentSelector.on('agentChange', this.handleAgentChange);
// Click outside to close - store reference for cleanup
document.addEventListener('click', this.boundClickOutside);
// Escape key to close - store reference for cleanup
document.addEventListener('keydown', this.boundKeydown);
}
handleClickOutside(e) {
if (this.isOpen && !this.contains(e.target)) {
this.close();
}
}
handleKeydown(e) {
if (e.key === 'Escape' && this.isOpen) {
this.close();
}
}
handleMessageSend(data) {
const currentAgent = this.agentSelector.getCurrentAgent();
// Emit custom event
const event = new CustomEvent('messageSend', {
detail: {
message: data.message,
agent: currentAgent
},
bubbles: true
});
this.dispatchEvent(event);
}
handleAgentChange(data) {
// Emit custom event
const event = new CustomEvent('agentSwitch', {
detail: data,
bubbles: true
});
this.dispatchEvent(event);
}
// Public API methods
open() {
if (this.isOpen) return;
this.isOpen = true;
this.chatContainer.classList.add('open');
this.triggerButton.classList.add('open');
this.triggerButton.innerHTML = '✕';
this.storage.set('isOpen', true);
// Focus input
setTimeout(() => {
const input = this.chatInterface.input;
if (input) input.focus();
}, 300);
// Emit event
const event = new CustomEvent('chatToggle', {
detail: { isOpen: true },
bubbles: true
});
this.dispatchEvent(event);
}
close() {
if (!this.isOpen) return;
this.isOpen = false;
this.chatContainer.classList.remove('open');
this.triggerButton.classList.remove('open');
this.triggerButton.innerHTML = '💬';
this.storage.set('isOpen', false);
// Emit event
const event = new CustomEvent('chatToggle', {
detail: { isOpen: false },
bubbles: true
});
this.dispatchEvent(event);
}
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
addMessage(text, isUser = false, agent = null) {
if (!this.chatInterface) return;
const messageAgent = agent || this.agentSelector.getCurrentAgent();
this.chatInterface.addMessage(text, isUser, messageAgent);
// Emit event
const event = new CustomEvent('messageReceived', {
detail: {
message: text,
isUser,
agent: messageAgent
},
bubbles: true
});
this.dispatchEvent(event);
}
clearMessages() {
if (this.chatInterface) {
this.chatInterface.clearMessages();
}
}
setAgent(agentId) {
const agent = this.config.agents.find(a => a.id === agentId);
if (agent && this.agentSelector) {
this.agentSelector.setAgent(agent);
}
}
showTyping() {
if (this.chatInterface) {
this.chatInterface.showTyping();
}
}
hideTyping() {
if (this.chatInterface) {
this.chatInterface.hideTyping();
}
}
// Update methods
updateAgents() {
if (this.agentSelector) {
this.agentSelector.updateAgents(this.config.agents);
// Re-render agent selector
const newAgentSelector = new AgentSelector(this.config.agents);
const oldElement = this.querySelector('.chatbot-agent-selector');
const newElement = newAgentSelector.render();
if (oldElement) {
oldElement.replaceWith(newElement);
}
this.agentSelector = newAgentSelector;
this.agentSelector.on('agentChange', this.handleAgentChange);
}
}
updateTheme() {
this.className = this.className.replace(/theme-\w+/, `theme-${this.config.theme}`);
}
updatePosition() {
this.className = this.className.replace(/position-\w+-\w+/, `position-${this.config.position}`);
}
updateTitle() {
if (this.titleElement) {
this.titleElement.textContent = this.config.title;
}
}
updatePlaceholder() {
if (this.chatInterface) {
this.chatInterface.setPlaceholder(this.config.placeholder);
}
}
cleanup() {
try {
// Clean up components
if (this.chatInterface) {
this.chatInterface.off('messageSend', this.handleMessageSend);
this.chatInterface.destroy?.();
}
if (this.agentSelector) {
this.agentSelector.off('agentChange', this.handleAgentChange);
this.agentSelector.destroy?.();
}
// Remove global event listeners
document.removeEventListener('click', this.boundClickOutside);
document.removeEventListener('keydown', this.boundKeydown);
} catch (error) {
console.warn('Error during cleanup:', error);
}
}
// Getters
get agents() {
return this.config.agents;
}
get currentAgent() {
return this.agentSelector ? this.agentSelector.getCurrentAgent() : null;
}
get messages() {
return this.chatInterface ? this.chatInterface.messages : [];
}
}
// Register the custom element
if (!customElements.get('chatbot-widget')) {
customElements.define('chatbot-widget', ChatbotWidget);
}
export default ChatbotWidget;