Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/locale-ui-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@chatcops/widget": patch
---

Move hardcoded English UI text and aria-labels into `WidgetLocaleStrings`.

New keys added to `WidgetLocaleStrings`:
- `openChat` – aria-label for the FAB when the panel is closed
- `defaultName` – default branding name shown in the panel header ("AI Assistant")
- `defaultSubtitle` – default branding subtitle ("Online")
- `greetingWithName` – welcome greeting when the visitor's name is known; supports a `{name}` placeholder
- `selectPlaceholder` – default option text for select fields in the pre-chat form; supports a `{label}` placeholder
- `dismissBubble` – aria-label for the welcome bubble dismiss button

The existing `sendButton` and `closeButton` keys are now also used as aria-labels for the send and close buttons respectively.

The bubble dismiss button gains `type="button"` and an `aria-label`.
3 changes: 3 additions & 0 deletions packages/widget/src/dom/bubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface BubbleOptions {
delay?: number;
showOnce?: boolean;
position: 'bottom-right' | 'bottom-left';
dismissLabel: string;
onClick: () => void;
}

Expand All @@ -22,7 +23,9 @@ export class WelcomeBubble {
this.el.textContent = options.text;

const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'cc-bubble-close';
closeBtn.setAttribute('aria-label', options.dismissLabel);
closeBtn.innerHTML = '×';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
Expand Down
11 changes: 9 additions & 2 deletions packages/widget/src/dom/fab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@ const CLOSE_ICON = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">

export interface FABOptions {
position: 'bottom-right' | 'bottom-left';
openLabel: string;
closeLabel: string;
onClick: () => void;
}

export class FAB {
private el: HTMLButtonElement;
private badge: HTMLDivElement;
private isOpen = false;
private openLabel: string;
private closeLabel: string;

constructor(root: ShadowRoot, options: FABOptions) {
this.openLabel = options.openLabel;
this.closeLabel = options.closeLabel;

this.el = document.createElement('button');
this.el.className = 'cc-fab cc-pulse';
this.el.setAttribute('aria-label', 'Open chat');
this.el.setAttribute('aria-label', this.openLabel);
if (options.position === 'bottom-left') {
this.el.classList.add('cc-left');
}
Expand All @@ -37,7 +44,7 @@ export class FAB {
this.el.innerHTML = open ? CLOSE_ICON : CHAT_ICON;
if (!open) this.el.appendChild(this.badge);
this.el.classList.toggle('cc-open', open);
this.el.setAttribute('aria-label', open ? 'Close chat' : 'Open chat');
this.el.setAttribute('aria-label', open ? this.closeLabel : this.openLabel);
}

showBadge(): void {
Expand Down
3 changes: 2 additions & 1 deletion packages/widget/src/dom/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const MAX_MESSAGE_LENGTH = 10_000;

export interface InputOptions {
placeholder: string;
sendLabel: string;
onSend: (text: string) => void;
}

Expand All @@ -29,7 +30,7 @@ export class Input {
this.sendBtn = document.createElement('button');
this.sendBtn.className = 'cc-send-btn';
this.sendBtn.disabled = true;
this.sendBtn.setAttribute('aria-label', 'Send message');
this.sendBtn.setAttribute('aria-label', options.sendLabel);
this.sendBtn.innerHTML = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>';
this.sendBtn.addEventListener('click', () => this.send());

Expand Down
5 changes: 4 additions & 1 deletion packages/widget/src/dom/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface PanelOptions {
};
placeholder: string;
footerText: string;
closeLabel: string;
sendLabel: string;
messageActions?: MessageActions;
onSend: (text: string) => void;
onClose: () => void;
Expand Down Expand Up @@ -63,7 +65,7 @@ export class Panel {
if (!options.inline) {
const closeBtn = document.createElement('button');
closeBtn.className = 'cc-header-close';
closeBtn.setAttribute('aria-label', 'Close chat');
closeBtn.setAttribute('aria-label', options.closeLabel);
closeBtn.innerHTML = CLOSE_ICON;
closeBtn.addEventListener('click', options.onClose);
header.appendChild(closeBtn);
Expand All @@ -83,6 +85,7 @@ export class Panel {
// Input
this.input = new Input(this.el, {
placeholder: options.placeholder,
sendLabel: options.sendLabel,
onSend: options.onSend,
});

Expand Down
3 changes: 2 additions & 1 deletion packages/widget/src/dom/prechat-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ export class PreChatForm {
select.className = 'cc-prechat-select';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = field.placeholder ?? `Select ${field.label.toLowerCase()}...`;
defaultOpt.textContent = field.placeholder
?? this.options.locale.selectPlaceholder.replace('{label}', field.label.toLowerCase());
defaultOpt.disabled = true;
defaultOpt.selected = true;
select.appendChild(defaultOpt);
Expand Down
14 changes: 13 additions & 1 deletion packages/widget/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ export interface WidgetLocaleStrings {
inputPlaceholder: string;
sendButton: string;
closeButton: string;
openChat: string;
defaultName: string;
defaultSubtitle: string;
greetingWithName: string;
selectPlaceholder: string;
dismissBubble: string;
errorGeneric: string;
errorNetwork: string;
errorRateLimit: string;
Expand All @@ -20,10 +26,16 @@ export interface WidgetLocaleStrings {
}

const en: WidgetLocaleStrings = {
welcomeMessage: 'Hi! How can I help you today?',
welcomeMessage: 'How can I help you today?',
inputPlaceholder: 'Type a message...',
sendButton: 'Send',
closeButton: 'Close',
openChat: 'Open chat',
defaultName: 'AI Assistant',
defaultSubtitle: 'Online',
greetingWithName: 'Hi {name}!',
selectPlaceholder: 'Select {label}...',
dismissBubble: 'Dismiss',
errorGeneric: 'Something went wrong. Please try again.',
errorNetwork: 'Connection error. Please check your network.',
errorRateLimit: 'Too many messages. Please wait a moment.',
Expand Down
12 changes: 9 additions & 3 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export class Widget {
if (!this.isInline) {
this.fab = new FAB(this.shadow, {
position,
openLabel: this.locale.openChat,
closeLabel: this.locale.closeButton,
onClick: () => this.toggle(),
});
}
Expand All @@ -154,12 +156,14 @@ export class Widget {
inline: this.isInline,
preChatEnabled: this.config.preChatForm?.enabled ?? false,
branding: {
name: this.config.branding?.name ?? 'AI Assistant',
name: this.config.branding?.name ?? this.locale.defaultName,
avatar: this.config.branding?.avatar,
subtitle: this.config.branding?.subtitle ?? 'Online',
subtitle: this.config.branding?.subtitle ?? this.locale.defaultSubtitle,
},
placeholder: this.config.placeholder ?? this.locale.inputPlaceholder,
footerText: this.locale.poweredBy,
closeLabel: this.locale.closeButton,
sendLabel: this.locale.sendButton,
messageActions: {
onRetry: (messageId) => {
void this.handleRetry(messageId);
Expand Down Expand Up @@ -206,6 +210,7 @@ export class Widget {
delay: this.config.welcomeBubble.delay,
showOnce: this.config.welcomeBubble.showOnce ?? true,
position,
dismissLabel: this.locale.dismissBubble,
onClick: () => this.open(),
});
}
Expand Down Expand Up @@ -326,8 +331,9 @@ export class Widget {
private showChatView(): void {
if (this.messages.length === 0) {
const userName = this.userData?.name;
const greeting = this.locale.greetingWithName.replace('{name}', () => userName!);
const welcomeText = userName
? `Hi ${userName}! ${this.config.welcomeMessage ?? this.locale.welcomeMessage}`
? `${greeting} ${this.config.welcomeMessage ?? this.locale.welcomeMessage}`
: (this.config.welcomeMessage ?? this.locale.welcomeMessage);
const welcomeMsg: MessageData = {
id: 'welcome',
Expand Down
37 changes: 29 additions & 8 deletions packages/widget/tests/dom/fab.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FAB } from '../../src/dom/fab.js';

const defaultOptions = {
openLabel: 'Open chat',
closeLabel: 'Close',
} as const;

describe('FAB', () => {
let root: ShadowRoot;

Expand All @@ -12,41 +17,57 @@ describe('FAB', () => {
});

it('renders a button', () => {
new FAB(root, { position: 'bottom-right', onClick: vi.fn() });
new FAB(root, { position: 'bottom-right', ...defaultOptions, onClick: vi.fn() });
const btn = root.querySelector('.cc-fab');
expect(btn).toBeTruthy();
expect(btn?.tagName).toBe('BUTTON');
});

it('calls onClick when clicked', () => {
const onClick = vi.fn();
new FAB(root, { position: 'bottom-right', onClick });
new FAB(root, { position: 'bottom-right', ...defaultOptions, onClick });
const btn = root.querySelector('.cc-fab') as HTMLButtonElement;
btn.click();
expect(onClick).toHaveBeenCalledOnce();
});

it('adds left class for bottom-left position', () => {
new FAB(root, { position: 'bottom-left', onClick: vi.fn() });
new FAB(root, { position: 'bottom-left', ...defaultOptions, onClick: vi.fn() });
const btn = root.querySelector('.cc-fab');
expect(btn?.classList.contains('cc-left')).toBe(true);
});

it('toggles open/close state', () => {
const fab = new FAB(root, { position: 'bottom-right', onClick: vi.fn() });
const fab = new FAB(root, { position: 'bottom-right', ...defaultOptions, onClick: vi.fn() });
const btn = root.querySelector('.cc-fab') as HTMLElement;

fab.setOpen(true);
expect(btn.classList.contains('cc-open')).toBe(true);
expect(btn.getAttribute('aria-label')).toBe('Close chat');
expect(btn.getAttribute('aria-label')).toBe(defaultOptions.closeLabel);

fab.setOpen(false);
expect(btn.classList.contains('cc-open')).toBe(false);
expect(btn.getAttribute('aria-label')).toBe('Open chat');
expect(btn.getAttribute('aria-label')).toBe(defaultOptions.openLabel);
});

it('uses provided openLabel and closeLabel for aria-label', () => {
const fab = new FAB(root, {
position: 'bottom-right',
openLabel: 'Abrir chat',
closeLabel: 'Cerrar',
onClick: vi.fn(),
});
const btn = root.querySelector('.cc-fab') as HTMLElement;

expect(btn.getAttribute('aria-label')).toBe('Abrir chat');
fab.setOpen(true);
expect(btn.getAttribute('aria-label')).toBe('Cerrar');
fab.setOpen(false);
expect(btn.getAttribute('aria-label')).toBe('Abrir chat');
});

it('shows and hides badge', () => {
const fab = new FAB(root, { position: 'bottom-right', onClick: vi.fn() });
const fab = new FAB(root, { position: 'bottom-right', ...defaultOptions, onClick: vi.fn() });
const badge = root.querySelector('.cc-fab-badge') as HTMLElement;

fab.showBadge();
Expand All @@ -57,7 +78,7 @@ describe('FAB', () => {
});

it('removes element on destroy', () => {
const fab = new FAB(root, { position: 'bottom-right', onClick: vi.fn() });
const fab = new FAB(root, { position: 'bottom-right', ...defaultOptions, onClick: vi.fn() });
fab.destroy();
expect(root.querySelector('.cc-fab')).toBeNull();
});
Expand Down
2 changes: 2 additions & 0 deletions packages/widget/tests/dom/panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ describe('Panel', () => {
branding: { name: 'Test Bot', subtitle: 'Online' },
placeholder: 'Type here...',
footerText: 'Powered by ChatCops',
closeLabel: 'Close',
sendLabel: 'Send',
onSend: vi.fn(),
onClose: vi.fn(),
...overrides,
Expand Down
84 changes: 84 additions & 0 deletions packages/widget/tests/widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type WidgetInternals = {
errorType?: 'rate_limit' | 'network' | 'provider_error' | 'timeout';
}>;
isStreaming: boolean;
userData?: Record<string, string>;
showChatView(): void;
handleSend(text: string): Promise<void>;
handleRetry(messageId: string): Promise<void>;
handleRegenerate(messageId: string): Promise<void>;
Expand Down Expand Up @@ -78,6 +80,88 @@ describe('Widget', () => {
}
);

it('strings: { closeButton } overrides the close aria-label on both the panel header and the FAB', () => {
const widget = new Widget({
apiUrl: 'https://api.test/chat',
strings: { closeButton: 'Cerrar' },
});
widget.init();

const root = getInternals(widget).shadow;
const headerClose = root.querySelector('.cc-header-close') as HTMLButtonElement;
const launcher = root.querySelector('.cc-fab') as HTMLButtonElement;

expect(headerClose.getAttribute('aria-label')).toBe('Cerrar');

launcher.click();
expect(launcher.getAttribute('aria-label')).toBe('Cerrar');

widget.destroy();
});

it('greeting: no name → shows site welcomeMessage as-is', () => {
const widget = new Widget({
apiUrl: 'https://api.test/chat',
welcomeMessage: 'Welcome to Acme support.',
});
widget.init();

getInternals(widget).showChatView();

const welcome = getInternals(widget).messages.find((m) => m.id === 'welcome');
expect(welcome?.content).toBe('Welcome to Acme support.');

widget.destroy();
});

it('greeting: name + default locale → uses locale welcomeMessage', () => {
const widget = new Widget({ apiUrl: 'https://api.test/chat' });
widget.init();

const internals = getInternals(widget);
internals.messages.length = 0;
internals.userData = { name: 'Alex' };
internals.showChatView();

const welcome = internals.messages.find((m) => m.id === 'welcome');
expect(welcome?.content).toBe('Hi Alex! How can I help you today?');

widget.destroy();
});

it('greeting: name + custom site welcomeMessage → site message is preserved', () => {
const widget = new Widget({
apiUrl: 'https://api.test/chat',
welcomeMessage: 'Welcome to Acme support.',
});
widget.init();

const internals = getInternals(widget);
internals.messages.length = 0;
internals.userData = { name: 'Alex' };
internals.showChatView();

const welcome = internals.messages.find((m) => m.id === 'welcome');
expect(welcome?.content).toBe('Hi Alex! Welcome to Acme support.');

widget.destroy();
});

it('greeting: dollar-sign patterns in name are not expanded by String.replace', () => {
const widget = new Widget({ apiUrl: 'https://api.test/chat' });
widget.init();

const internals = getInternals(widget);
internals.messages.length = 0;
internals.userData = { name: 'A$&B' };
internals.showChatView();

const welcome = internals.messages.find((m) => m.id === 'welcome');
expect(welcome?.content).toBe('Hi A$&B! How can I help you today?');

widget.destroy();
});

it('fires the leadCaptured callback and event when the stream includes lead data', async () => {
const onLeadCaptured = vi.fn();
const widget = new Widget({
Expand Down
Loading