From 03806eb1da5a996b12ada2ffc79494cb26ef133d Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 16 Oct 2026 21:00:35 +0530 Subject: [PATCH 1/2] Added weidget locale strings Signed-off-by: Anurag Yadav --- .changeset/locale-ui-strings.md | 17 ++++++++++++ packages/widget/src/dom/bubble.ts | 3 ++ packages/widget/src/dom/fab.ts | 11 ++++++-- packages/widget/src/dom/input.ts | 3 +- packages/widget/src/dom/panel.ts | 5 +++- packages/widget/src/dom/prechat-form.ts | 3 +- packages/widget/src/i18n.ts | 12 ++++++++ packages/widget/src/widget.ts | 11 ++++++-- packages/widget/tests/dom/fab.test.ts | 37 +++++++++++++++++++------ packages/widget/tests/dom/panel.test.ts | 2 ++ packages/widget/tests/widget.test.ts | 19 +++++++++++++ 11 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 .changeset/locale-ui-strings.md diff --git a/.changeset/locale-ui-strings.md b/.changeset/locale-ui-strings.md new file mode 100644 index 0000000..d0ff9ee --- /dev/null +++ b/.changeset/locale-ui-strings.md @@ -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`. diff --git a/packages/widget/src/dom/bubble.ts b/packages/widget/src/dom/bubble.ts index cea3dce..283aef9 100644 --- a/packages/widget/src/dom/bubble.ts +++ b/packages/widget/src/dom/bubble.ts @@ -3,6 +3,7 @@ export interface BubbleOptions { delay?: number; showOnce?: boolean; position: 'bottom-right' | 'bottom-left'; + dismissLabel: string; onClick: () => void; } @@ -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(); diff --git a/packages/widget/src/dom/fab.ts b/packages/widget/src/dom/fab.ts index 11d713e..6425e2d 100644 --- a/packages/widget/src/dom/fab.ts +++ b/packages/widget/src/dom/fab.ts @@ -3,6 +3,8 @@ const CLOSE_ICON = ` export interface FABOptions { position: 'bottom-right' | 'bottom-left'; + openLabel: string; + closeLabel: string; onClick: () => void; } @@ -10,11 +12,16 @@ 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'); } @@ -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 { diff --git a/packages/widget/src/dom/input.ts b/packages/widget/src/dom/input.ts index 779dadd..1461c86 100644 --- a/packages/widget/src/dom/input.ts +++ b/packages/widget/src/dom/input.ts @@ -3,6 +3,7 @@ const MAX_MESSAGE_LENGTH = 10_000; export interface InputOptions { placeholder: string; + sendLabel: string; onSend: (text: string) => void; } @@ -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 = ''; this.sendBtn.addEventListener('click', () => this.send()); diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index 4122234..48781cd 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -15,6 +15,8 @@ export interface PanelOptions { }; placeholder: string; footerText: string; + closeLabel: string; + sendLabel: string; messageActions?: MessageActions; onSend: (text: string) => void; onClose: () => void; @@ -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); @@ -83,6 +85,7 @@ export class Panel { // Input this.input = new Input(this.el, { placeholder: options.placeholder, + sendLabel: options.sendLabel, onSend: options.onSend, }); diff --git a/packages/widget/src/dom/prechat-form.ts b/packages/widget/src/dom/prechat-form.ts index 769bf5b..49226df 100644 --- a/packages/widget/src/dom/prechat-form.ts +++ b/packages/widget/src/dom/prechat-form.ts @@ -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); diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index 6a3816a..0a07c44 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -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; @@ -24,6 +30,12 @@ const en: WidgetLocaleStrings = { inputPlaceholder: 'Type a message...', sendButton: 'Send', closeButton: 'Close', + openChat: 'Open chat', + defaultName: 'AI Assistant', + defaultSubtitle: 'Online', + greetingWithName: 'Hi {name}! How can I help you today?', + 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.', diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index a930c68..893f1ad 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -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(), }); } @@ -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); @@ -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(), }); } @@ -327,7 +332,7 @@ export class Widget { if (this.messages.length === 0) { const userName = this.userData?.name; const welcomeText = userName - ? `Hi ${userName}! ${this.config.welcomeMessage ?? this.locale.welcomeMessage}` + ? this.locale.greetingWithName.replace('{name}', userName) : (this.config.welcomeMessage ?? this.locale.welcomeMessage); const welcomeMsg: MessageData = { id: 'welcome', diff --git a/packages/widget/tests/dom/fab.test.ts b/packages/widget/tests/dom/fab.test.ts index 7447bf0..3dc7129 100644 --- a/packages/widget/tests/dom/fab.test.ts +++ b/packages/widget/tests/dom/fab.test.ts @@ -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; @@ -12,7 +17,7 @@ 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'); @@ -20,33 +25,49 @@ describe('FAB', () => { 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(); @@ -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(); }); diff --git a/packages/widget/tests/dom/panel.test.ts b/packages/widget/tests/dom/panel.test.ts index 4c50c21..15afb23 100644 --- a/packages/widget/tests/dom/panel.test.ts +++ b/packages/widget/tests/dom/panel.test.ts @@ -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, diff --git a/packages/widget/tests/widget.test.ts b/packages/widget/tests/widget.test.ts index 59baa54..9a47392 100644 --- a/packages/widget/tests/widget.test.ts +++ b/packages/widget/tests/widget.test.ts @@ -78,6 +78,25 @@ 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('fires the leadCaptured callback and event when the stream includes lead data', async () => { const onLeadCaptured = vi.fn(); const widget = new Widget({ From 74c0cb681a7266ed089d5dfde1224935e4936389 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sat, 17 Oct 2026 11:12:50 +0530 Subject: [PATCH 2/2] Adress review Signed-off-by: Anurag Yadav --- packages/widget/src/i18n.ts | 4 +- packages/widget/src/widget.ts | 3 +- packages/widget/tests/widget.test.ts | 65 ++++++++++++++++++++++++ website/src/content/docs/widget/i18n.mdx | 13 +++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index 0a07c44..c3f0717 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -26,14 +26,14 @@ 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}! How can I help you today?', + greetingWithName: 'Hi {name}!', selectPlaceholder: 'Select {label}...', dismissBubble: 'Dismiss', errorGeneric: 'Something went wrong. Please try again.', diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 893f1ad..ed4d125 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -331,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 - ? this.locale.greetingWithName.replace('{name}', userName) + ? `${greeting} ${this.config.welcomeMessage ?? this.locale.welcomeMessage}` : (this.config.welcomeMessage ?? this.locale.welcomeMessage); const welcomeMsg: MessageData = { id: 'welcome', diff --git a/packages/widget/tests/widget.test.ts b/packages/widget/tests/widget.test.ts index 9a47392..0c3e2fd 100644 --- a/packages/widget/tests/widget.test.ts +++ b/packages/widget/tests/widget.test.ts @@ -14,6 +14,8 @@ type WidgetInternals = { errorType?: 'rate_limit' | 'network' | 'provider_error' | 'timeout'; }>; isStreaming: boolean; + userData?: Record; + showChatView(): void; handleSend(text: string): Promise; handleRetry(messageId: string): Promise; handleRegenerate(messageId: string): Promise; @@ -97,6 +99,69 @@ describe('Widget', () => { 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({ diff --git a/website/src/content/docs/widget/i18n.mdx b/website/src/content/docs/widget/i18n.mdx index 8c4511f..e73894d 100644 --- a/website/src/content/docs/widget/i18n.mdx +++ b/website/src/content/docs/widget/i18n.mdx @@ -49,6 +49,12 @@ interface LocaleStrings { inputPlaceholder: string; sendButton: string; closeButton: string; + openChat: string; + defaultName: string; + defaultSubtitle: string; + greetingWithName: string; + selectPlaceholder: string; + dismissBubble: string; errorGeneric: string; errorNetwork: string; errorRateLimit: string; @@ -56,6 +62,13 @@ interface LocaleStrings { poweredBy: string; newConversation: string; welcomeBubbleDefault: string; + preChatTitle: string; + preChatSubtitle: string; + preChatSubmit: string; + preChatRequired: string; + preChatInvalidEmail: string; + retryButton: string; + regenerateButton: string; } ```