From 95acee8d369a974d3f3e455fbf60ff0e4dca208f Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sat, 31 Oct 2026 03:01:01 +0530 Subject: [PATCH 1/3] Added widget menu Signed-off-by: Anurag Yadav --- .changeset/widget-header-menu.md | 5 + .size-limit.json | 8 +- api/widget.api.md | 7 +- e2e/tests/states.spec.ts | 24 ++ packages/widget/README.md | 4 +- packages/widget/src/dom/icons.ts | 1 + packages/widget/src/dom/menu.ts | 143 +++++++++++ packages/widget/src/dom/panel.ts | 119 ++++++++- packages/widget/src/i18n.ts | 18 +- packages/widget/src/script-config.ts | 1 + packages/widget/src/styles/launcher.css | 3 + packages/widget/src/styles/menu.css | 41 +++ packages/widget/src/styles/widget.css | 14 +- packages/widget/src/theme.ts | 1 + packages/widget/src/widget.ts | 26 +- packages/widget/tests/dom/menu.test.ts | 237 ++++++++++++++++++ packages/widget/tests/dom/panel.test.ts | 16 +- .../src/content/docs/widget/configuration.mdx | 16 ++ 18 files changed, 660 insertions(+), 24 deletions(-) create mode 100644 .changeset/widget-header-menu.md create mode 100644 packages/widget/src/dom/menu.ts create mode 100644 packages/widget/src/styles/menu.css create mode 100644 packages/widget/tests/dom/menu.test.ts diff --git a/.changeset/widget-header-menu.md b/.changeset/widget-header-menu.md new file mode 100644 index 00000000..bc5dc5ee --- /dev/null +++ b/.changeset/widget-header-menu.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": minor +--- + +Add a header "More options" menu (`⋯`) with "New conversation", "Download transcript" and an optional "Privacy" link configured via `privacyUrl` (or `data-privacy-url`). diff --git a/.size-limit.json b/.size-limit.json index 2cd5f00a..37541b55 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -17,10 +17,16 @@ "limit": "2 KB", "gzip": true }, + { + "name": "widget, header menu (chunks/menu-*.js, loaded on first open)", + "path": "packages/widget/dist/chunks/menu-*.js", + "limit": "2 KB", + "gzip": true + }, { "name": "widget, single file (chatcops.full.min.js)", "path": "packages/widget/dist/chatcops.full.min.js", - "limit": "20 KB", + "limit": "22 KB", "gzip": true }, { diff --git a/api/widget.api.md b/api/widget.api.md index daa7087e..f5553d58 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -99,6 +99,7 @@ export declare class Widget { }): Promise; newConversation(): void; clear(): void; + downloadTranscript(): void; setUser(user: { email?: string; name?: string; @@ -155,6 +156,7 @@ export interface WidgetConfig { onPreChatSubmit?: (data: Record) => void; waitForConsent?: boolean; onConsentRequest?: () => void; + privacyUrl?: string; } export type WidgetEventHandler = (...args: WidgetEvents[E]) => void; @@ -217,9 +219,12 @@ export interface WidgetLocaleStrings { disclosureLearnMore: string; consentNotice: string; consentButton: string; + moreButton: string; + downloadTranscript: string; + privacy: string; } -export type WidgetUpdate = Pick & { +export type WidgetUpdate = Pick & { theme?: Omit; }; ``` diff --git a/e2e/tests/states.spec.ts b/e2e/tests/states.spec.ts index 4eaba434..cfc1cc09 100644 --- a/e2e/tests/states.spec.ts +++ b/e2e/tests/states.spec.ts @@ -201,6 +201,30 @@ for (const look of looks) { await expectAccessible(page); }); + test('header menu with keyboard navigation and New conversation', async ({ page }) => { + await start(page, look, { privacyUrl: 'https://example.com/privacy' }); + await openChat(page); + await ask(page, 'First message'); + await expect(page.locator('.cc-message-user')).toHaveCount(1); + + const moreBtn = page.locator('.cc-header-more'); + await moreBtn.focus(); + await expect(moreBtn).toBeFocused(); + await page.keyboard.press('ArrowDown'); + + const menu = page.locator('.cc-header-menu'); + await expect(menu).toBeVisible(); + await expect(moreBtn).toHaveAttribute('aria-expanded', 'true'); + + await expectAccessible(page); + + await page.keyboard.press('Enter'); + await expect(menu).not.toBeVisible(); + await expect(moreBtn).toHaveAttribute('aria-expanded', 'false'); + + await expect(page.locator('.cc-message-user')).toHaveCount(0); + }); + test.describe('S14 on a phone', () => { test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); diff --git a/packages/widget/README.md b/packages/widget/README.md index 8b81352b..aabbf213 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -101,6 +101,7 @@ ChatCops.init({ pageContext: true, // send page URL/title to API autoOpen: 5000, // auto-open after ms (popup only) locale: 'en', + privacyUrl: 'https://example.com/privacy', // link in header menu (https: or http:) // Callbacks onOpen: () => {}, @@ -146,7 +147,8 @@ All configuration can be set via `data-*` attributes: data-welcome-bubble-delay="3000" data-placeholder="Type here..." data-locale="en" - data-auto-open="true"> + data-auto-open="true" + data-privacy-url="https://example.com/privacy"> ``` diff --git a/packages/widget/src/dom/icons.ts b/packages/widget/src/dom/icons.ts index a6de259a..dbd4d577 100644 --- a/packages/widget/src/dom/icons.ts +++ b/packages/widget/src/dom/icons.ts @@ -1,4 +1,5 @@ /** Launcher icons, shared by the loader and the full widget. */ export const CHAT_ICON = ``; export const CLOSE_ICON = ``; +export const MORE_ICON = ``; diff --git a/packages/widget/src/dom/menu.ts b/packages/widget/src/dom/menu.ts new file mode 100644 index 00000000..1fdbaefc --- /dev/null +++ b/packages/widget/src/dom/menu.ts @@ -0,0 +1,143 @@ +import menuStyles from '../styles/menu.css?inline'; + +export interface HeaderMenuOptions { + moreLabel: string; + newConversationLabel: string; + downloadTranscriptLabel: string; + privacyLabel: string; + privacyUrl?: string; + onNewConversation?: () => void; + onDownloadTranscript?: () => void; +} + +export function safePrivacyUrl(url?: string): string | undefined { + if (!url || typeof url !== 'string') return undefined; + try { + const parsed = new URL(url); + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + return parsed.href; + } + } catch { + return undefined; + } + return undefined; +} + +function addMenuStyles(node: Node): void { + const root = node.getRootNode(); + if (!(root instanceof ShadowRoot) || root.querySelector('style[data-cc-menu]')) return; + const style = document.createElement('style'); + const existing = root.querySelector('style[nonce]') as HTMLStyleElement | null; + if (existing?.nonce) style.nonce = existing.nonce; + style.dataset.ccMenu = ''; + style.textContent = menuStyles; + root.appendChild(style); +} + +export class HeaderMenu { + el: HTMLDivElement; + private btn: HTMLButtonElement; + private options: HeaderMenuOptions; + + constructor(button: HTMLButtonElement, options: HeaderMenuOptions) { + this.btn = button; + this.options = options; + addMenuStyles(this.btn); + + const menu = document.createElement('div'); + menu.className = 'cc-header-menu'; + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', options.moreLabel); + menu.setAttribute('hidden', ''); + this.el = menu; + + this.render(); + + menu.addEventListener('click', (e) => { + const item = (e.target as HTMLElement).closest('[role="menuitem"]'); + if (!item) return; + this.close(); + const action = item.dataset.action; + if (action === 'new') this.options.onNewConversation?.(); + else if (action === 'download') this.options.onDownloadTranscript?.(); + }); + + menu.addEventListener('keydown', (e) => { + const items = this.items; + const idx = items.indexOf((menu.getRootNode() as ShadowRoot | Document).activeElement as HTMLElement); + if (e.key === 'ArrowDown') { + e.preventDefault(); + items[(idx + 1) % items.length]?.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + items[(idx - 1 + items.length) % items.length]?.focus(); + } else if (e.key === 'Home') { + e.preventDefault(); + items[0]?.focus(); + } else if (e.key === 'End') { + e.preventDefault(); + items[items.length - 1]?.focus(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this.close(); + this.btn.focus(); + } else if (e.key === 'Tab') { + this.close(); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + items[idx]?.click(); + } + }); + } + + setButton(button: HTMLButtonElement): void { + this.btn = button; + } + + get isOpen(): boolean { + return !this.el.hasAttribute('hidden'); + } + + get items(): HTMLElement[] { + return Array.from(this.el.querySelectorAll('[role="menuitem"]')); + } + + open(focusLast = false): void { + this.el.removeAttribute('hidden'); + this.btn.setAttribute('aria-expanded', 'true'); + const items = this.items; + if (items.length > 0) { + (focusLast ? items[items.length - 1] : items[0]).focus(); + } + } + + close(): void { + if (!this.isOpen) return; + this.el.setAttribute('hidden', ''); + this.btn.setAttribute('aria-expanded', 'false'); + } + + update(options: Partial): void { + Object.assign(this.options, options); + this.render(); + } + + private render(): void { + const { moreLabel, newConversationLabel, downloadTranscriptLabel, privacyLabel, privacyUrl } = this.options; + this.el.setAttribute('aria-label', moreLabel); + const validPrivacyUrl = safePrivacyUrl(privacyUrl); + this.el.innerHTML = + `` + + `` + + (validPrivacyUrl + ? `${this.escapeHtml(privacyLabel)}` + : ''); + } + + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index 2a980920..893058b5 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -1,11 +1,23 @@ import { Messages, type MessageActions, type MessageData } from './messages.js'; import { Input } from './input.js'; +import { MORE_ICON } from './icons.js'; +import type { HeaderMenu } from './menu.js'; export const PANEL_ID = 'cc-panel'; const BOT_ICON = ``; const CLOSE_ICON = ``; +let menuChunk: Promise | undefined; + +export function loadMenu(): Promise { + menuChunk ??= import('./menu.js').catch((error: unknown) => { + menuChunk = undefined; + throw error; + }); + return menuChunk; +} + export interface PanelOptions { position: 'bottom-right' | 'bottom-left'; inline?: boolean; @@ -19,13 +31,32 @@ export interface PanelOptions { footerText: string; closeLabel: string; sendLabel: string; + moreLabel?: string; + newConversationLabel?: string; + downloadTranscriptLabel?: string; + privacyLabel?: string; + privacyUrl?: string; messageActions?: MessageActions; onSend: (text: string) => void; onClose: () => void; + onNewConversation?: () => void; + onDownloadTranscript?: () => void; } /** The parts of the panel that `update()` can change. */ -export type PanelLabels = Pick; +export type PanelLabels = Pick< + PanelOptions, + | 'branding' + | 'placeholder' + | 'footerText' + | 'closeLabel' + | 'sendLabel' + | 'moreLabel' + | 'newConversationLabel' + | 'downloadTranscriptLabel' + | 'privacyLabel' + | 'privacyUrl' +>; export class Panel { private el: HTMLDivElement; @@ -33,6 +64,8 @@ export class Panel { private options: PanelOptions; private header: HTMLDivElement; private footer: HTMLDivElement; + private moreBtn: HTMLButtonElement | null = null; + private headerMenu: HeaderMenu | null = null; messages: Messages; input: Input; messagesContainer: HTMLElement; @@ -53,6 +86,11 @@ export class Panel { this.el.setAttribute('aria-modal', 'true'); this.el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { + if (this.headerMenu?.isOpen) { + this.headerMenu.close(); + this.moreBtn?.focus(); + return; + } options.onClose(); return; } @@ -89,6 +127,12 @@ export class Panel { this.renderHeader(); this.el.appendChild(this.header); + this.el.addEventListener('pointerdown', (e) => { + if (this.headerMenu?.isOpen && !this.header.contains(e.target as Node)) { + this.headerMenu.close(); + } + }); + if (options.preChatEnabled) { this.messagesContainer = document.createElement('div'); this.messagesContainer.className = 'cc-messages-wrapper'; @@ -120,13 +164,35 @@ export class Panel { // Messages reads its labels from this same object as it renders. if (this.options.messageActions) Object.assign(this.options.messageActions, actionLabels); this.renderHeader(); + this.headerMenu?.update({ + moreLabel: labels.moreLabel, + newConversationLabel: labels.newConversationLabel, + downloadTranscriptLabel: labels.downloadTranscriptLabel, + privacyLabel: labels.privacyLabel, + privacyUrl: labels.privacyUrl, + }); this.input.setLabels(labels.placeholder, labels.sendLabel); this.footer.innerHTML = labels.footerText; } private renderHeader(): void { - const { branding, closeLabel, inline, onClose } = this.options; - const hadFocus = this.header.contains((this.el.getRootNode() as ShadowRoot | Document).activeElement); + const { + branding, + closeLabel, + inline, + onClose, + moreLabel = 'More options', + newConversationLabel = 'New conversation', + downloadTranscriptLabel = 'Download transcript', + privacyLabel = 'Privacy', + privacyUrl, + onNewConversation, + onDownloadTranscript, + } = this.options; + const active = (this.el.getRootNode() as ShadowRoot | Document).activeElement; + const moreHadFocus = this.moreBtn && (this.moreBtn === active || this.headerMenu?.el.contains(active)); + const closeHadFocus = !inline && this.header.contains(active) && !moreHadFocus; + this.header.replaceChildren(); if (!inline) this.el.setAttribute('aria-label', branding.name); @@ -148,6 +214,45 @@ export class Panel { info.innerHTML = `
${this.escapeHtml(branding.name)}
${this.escapeHtml(branding.subtitle)}
`; this.header.appendChild(info); + const moreBtn = document.createElement('button'); + moreBtn.className = 'cc-header-more'; + moreBtn.setAttribute('aria-haspopup', 'menu'); + moreBtn.setAttribute('aria-expanded', 'false'); + moreBtn.setAttribute('aria-label', moreLabel); + moreBtn.innerHTML = MORE_ICON; + this.moreBtn = moreBtn; + + const openMenu = (focusLast?: boolean) => { + void loadMenu().then(({ HeaderMenu }) => { + this.headerMenu ??= new HeaderMenu(moreBtn, { + moreLabel, + newConversationLabel, + downloadTranscriptLabel, + privacyLabel, + privacyUrl, + onNewConversation, + onDownloadTranscript, + }); + this.headerMenu.setButton(moreBtn); + if (!this.header.contains(this.headerMenu.el)) this.header.appendChild(this.headerMenu.el); + this.headerMenu.open(focusLast); + }); + }; + + moreBtn.onclick = () => (this.headerMenu?.isOpen ? this.headerMenu.close() : openMenu()); + moreBtn.onkeydown = (e) => { + if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openMenu(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + openMenu(true); + } + }; + + this.header.appendChild(moreBtn); + if (this.headerMenu) this.header.appendChild(this.headerMenu.el); + if (!inline) { const closeBtn = document.createElement('button'); closeBtn.className = 'cc-header-close'; @@ -155,13 +260,15 @@ export class Panel { closeBtn.innerHTML = CLOSE_ICON; closeBtn.addEventListener('click', onClose); this.header.appendChild(closeBtn); - // Re-rendered under the visitor's focus: keep it on the close button. - if (hadFocus) closeBtn.focus(); + if (closeHadFocus) closeBtn.focus(); } + + if (moreHadFocus) moreBtn.focus(); } show(): void { this.el.classList.add('cc-visible'); + void loadMenu().catch(() => undefined); if (!this.inline) { this.setHidden(false); this.input.focus(); @@ -169,6 +276,7 @@ export class Panel { } hide(): void { + this.headerMenu?.close(); this.el.classList.remove('cc-visible'); if (!this.inline) this.setHidden(true); } @@ -192,6 +300,7 @@ export class Panel { } destroy(): void { + this.headerMenu?.close(); this.el.remove(); } diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index d0beab68..91a0c165 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -36,6 +36,9 @@ export interface WidgetLocaleStrings { disclosureLearnMore: string; consentNotice: string; consentButton: string; + moreButton: string; + downloadTranscript: string; + privacy: string; } const en: WidgetLocaleStrings = { @@ -75,13 +78,24 @@ const en: WidgetLocaleStrings = { disclosureLearnMore: 'Learn more', consentNotice: 'Chat is available once you accept functional cookies.', consentButton: 'Cookie settings', + moreButton: 'More options', + downloadTranscript: 'Download transcript', + privacy: 'Privacy', }; const locales: Record = { en }; -export function getWidgetLocale(code: string, overrides?: Partial): WidgetLocaleStrings { +export function getWidgetLocale( + code: string, + overrides?: Partial & { moreOptions?: string } +): WidgetLocaleStrings { const base = locales[code] ?? en; - return overrides ? { ...base, ...overrides } : base; + if (!overrides) return base; + const merged = { ...base, ...overrides }; + if (overrides.moreOptions && !overrides.moreButton) { + merged.moreButton = overrides.moreOptions; + } + return merged; } /** Adds or replaces a language. Strings it leaves out stay in English. */ diff --git a/packages/widget/src/script-config.ts b/packages/widget/src/script-config.ts index 55b7b384..2b837708 100644 --- a/packages/widget/src/script-config.ts +++ b/packages/widget/src/script-config.ts @@ -56,5 +56,6 @@ export function configFromScript(script: HTMLScriptElement): WidgetConfig | unde persistence: (['local', 'session', 'none'] as const).find((mode) => mode === script.dataset.persistence), historyTtlDays: /^\d+$/.test(script.dataset.historyTtlDays ?? '') ? Number(script.dataset.historyTtlDays) : undefined, waitForConsent: script.dataset.waitForConsent === undefined ? undefined : script.dataset.waitForConsent !== 'false', + privacyUrl: script.dataset.privacyUrl, }; } diff --git a/packages/widget/src/styles/launcher.css b/packages/widget/src/styles/launcher.css index 45a24e3b..0d4e2c91 100644 --- a/packages/widget/src/styles/launcher.css +++ b/packages/widget/src/styles/launcher.css @@ -12,6 +12,7 @@ --cc-text: #1a1d23; --cc-bg: #ffffff; --cc-bg-surface: #f4f5f7; + --cc-surface-raised: #ffffff; --cc-bg-input: #ffffff; --cc-border: #d9dce1; --cc-text-secondary: #5b616e; @@ -46,6 +47,7 @@ --cc-text: #ffffff; --cc-bg: #0a0a0a; --cc-bg-surface: #1a1a1a; + --cc-surface-raised: #1e1e1e; --cc-bg-input: #2a2a2a; --cc-border: #333333; --cc-text-secondary: #a0a0a0; @@ -66,6 +68,7 @@ --cc-text: #ffffff; --cc-bg: #0a0a0a; --cc-bg-surface: #1a1a1a; + --cc-surface-raised: #1e1e1e; --cc-bg-input: #2a2a2a; --cc-border: #333333; --cc-text-secondary: #a0a0a0; diff --git a/packages/widget/src/styles/menu.css b/packages/widget/src/styles/menu.css new file mode 100644 index 00000000..36445629 --- /dev/null +++ b/packages/widget/src/styles/menu.css @@ -0,0 +1,41 @@ +.cc-header-menu { + position: absolute; + top: calc(100% + 4px); + inset-inline-end: 12px; + background: var(--cc-surface-raised); + border: 1px solid var(--cc-border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + padding: 4px; + min-width: 170px; + z-index: 20; + display: flex; + flex-direction: column; +} + +.cc-header-menu[hidden] { + display: none; +} + +.cc-menu-item { + display: block; + width: 100%; + padding: 8px 12px; + font-family: inherit; + font-size: 13px; + color: var(--cc-text); + background: transparent; + border: none; + border-radius: 6px; + text-align: start; + text-decoration: none; + cursor: pointer; + box-sizing: border-box; + white-space: nowrap; +} + +.cc-menu-item:hover, +.cc-menu-item:focus-visible { + background: var(--cc-bg-input); + outline: none; +} diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index 58915869..f9025528 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -52,6 +52,7 @@ /* Panel header */ .cc-header { + position: relative; display: flex; align-items: center; padding: 16px; @@ -100,7 +101,8 @@ color: var(--cc-text-secondary); } -.cc-header-close { +.cc-header-close, +.cc-header-more { width: 32px; height: 32px; border-radius: 8px; @@ -111,16 +113,19 @@ align-items: center; justify-content: center; transition: background 0.15s; + color: var(--cc-text-secondary); } -.cc-header-close:hover { +.cc-header-close:hover, +.cc-header-more:hover { background: var(--cc-bg-input); } -.cc-header-close svg { +.cc-header-close svg, +.cc-header-more svg { width: 16px; height: 16px; - fill: var(--cc-text-secondary); + fill: currentColor; } /* Messages area */ @@ -591,6 +596,7 @@ /* Keyboard focus indicators (the launcher's are in launcher.css) */ .cc-header-close:focus-visible, +.cc-header-more:focus-visible, .cc-consent-btn:focus-visible, .cc-send-btn:focus-visible, .cc-suggestion:focus-visible, diff --git a/packages/widget/src/theme.ts b/packages/widget/src/theme.ts index 81417a08..ac620b70 100644 --- a/packages/widget/src/theme.ts +++ b/packages/widget/src/theme.ts @@ -31,6 +31,7 @@ export function applyTheme(root: ShadowRoot, theme: ThemeConfig): void { if (theme.bgColor) { host.style.setProperty('--cc-bg', theme.bgColor); host.style.setProperty('--cc-bg-surface', lighten(theme.bgColor, 8)); + host.style.setProperty('--cc-surface-raised', lighten(theme.bgColor, 12)); host.style.setProperty('--cc-bg-input', lighten(theme.bgColor, 14)); } if (theme.fontFamily) { diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 1f01f001..80d2110a 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -116,12 +116,14 @@ export interface WidgetConfig { waitForConsent?: boolean; /** Called by the notice's button, to open the site's own consent banner. Without it the button is left out. */ onConsentRequest?: () => void; + /** A link in the header menu to your site's privacy policy (https: or http: only). */ + privacyUrl?: string; } /** What `update()` can change on a running chat. */ export type WidgetUpdate = Pick< WidgetConfig, - 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings' + 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings' | 'privacyUrl' > & { theme?: Omit }; /** What each widget event passes to its handlers. */ @@ -283,6 +285,8 @@ export class Widget { }, onSend: (text) => this.handleSend(text), onClose: () => this.close(), + onNewConversation: () => this.newConversation(), + onDownloadTranscript: () => this.downloadTranscript(), }); if (this.config.disclosure === false) { @@ -464,6 +468,19 @@ export class Widget { this.reset(true); } + /** Saves the visible conversation as a text file. */ + downloadTranscript(): void { + const text = this.messages + .filter((m) => m.content?.trim()) + .map((m) => `${m.role === 'user' ? 'Visitor' : 'Assistant'}: ${m.content}`) + .join('\n\n'); + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' })); + a.download = 'transcript.txt'; + a.click(); + URL.revokeObjectURL(a.href); + } + private reset(forget: boolean, show = true): void { // Waiting for consent: there is nothing to reset, and the notice stays up. if (!this.consented) return; @@ -558,7 +575,7 @@ export class Widget { } private panelLabels(): PanelLabels { - const { branding, placeholder } = this.config; + const { branding, placeholder, privacyUrl } = this.config; const l = this.locale; return { branding: { @@ -570,6 +587,11 @@ export class Widget { footerText: l.poweredBy, closeLabel: l.closeButton, sendLabel: l.sendButton, + moreLabel: l.moreButton, + newConversationLabel: l.newConversation, + downloadTranscriptLabel: l.downloadTranscript, + privacyLabel: l.privacy, + privacyUrl, }; } diff --git a/packages/widget/tests/dom/menu.test.ts b/packages/widget/tests/dom/menu.test.ts new file mode 100644 index 00000000..9bd5b522 --- /dev/null +++ b/packages/widget/tests/dom/menu.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { HeaderMenu, safePrivacyUrl } from '../../src/dom/menu.js'; +import { Panel } from '../../src/dom/panel.js'; +import { Widget } from '../../src/widget.js'; + +describe('HeaderMenu', () => { + let root: ShadowRoot; + let moreBtn: HTMLButtonElement; + + beforeEach(() => { + document.body.innerHTML = ''; + const host = document.createElement('div'); + document.body.appendChild(host); + root = host.attachShadow({ mode: 'open' }); + moreBtn = document.createElement('button'); + moreBtn.className = 'cc-header-more'; + moreBtn.setAttribute('aria-haspopup', 'menu'); + moreBtn.setAttribute('aria-expanded', 'false'); + root.appendChild(moreBtn); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('opens and closes with aria-expanded and hidden attributes', () => { + const menu = new HeaderMenu(moreBtn, { + moreLabel: 'More options', + newConversationLabel: 'New conversation', + downloadTranscriptLabel: 'Download transcript', + privacyLabel: 'Privacy', + }); + root.appendChild(menu.el); + + expect(menu.el.getAttribute('role')).toBe('menu'); + expect(menu.isOpen).toBe(false); + expect(moreBtn.getAttribute('aria-expanded')).toBe('false'); + + menu.open(); + expect(menu.isOpen).toBe(true); + expect(moreBtn.getAttribute('aria-expanded')).toBe('true'); + expect(root.activeElement).toBe(menu.items[0]); + + menu.close(); + expect(menu.isOpen).toBe(false); + expect(moreBtn.getAttribute('aria-expanded')).toBe('false'); + }); + + it('supports keyboard navigation with arrow keys, Home, End, Enter, Space and Escape', () => { + const onNewConversation = vi.fn(); + const onDownloadTranscript = vi.fn(); + const menu = new HeaderMenu(moreBtn, { + moreLabel: 'More options', + newConversationLabel: 'New conversation', + downloadTranscriptLabel: 'Download transcript', + privacyLabel: 'Privacy', + privacyUrl: 'https://example.com/privacy', + onNewConversation, + onDownloadTranscript, + }); + root.appendChild(menu.el); + menu.open(); + + const items = menu.items; + expect(items).toHaveLength(3); + + menu.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(root.activeElement).toBe(items[1]); + + menu.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); + expect(root.activeElement).toBe(items[2]); + + menu.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(root.activeElement).toBe(items[0]); + + menu.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + expect(root.activeElement).toBe(items[2]); + + menu.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); + expect(root.activeElement).toBe(items[0]); + + menu.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(onNewConversation).toHaveBeenCalledOnce(); + expect(menu.isOpen).toBe(false); + + menu.open(); + menu.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + menu.el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + expect(onDownloadTranscript).toHaveBeenCalledOnce(); + + menu.open(); + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + menu.el.dispatchEvent(event); + expect(menu.isOpen).toBe(false); + expect(root.activeElement).toBe(moreBtn); + }); + + it('only shows Privacy link with valid http/https URLs and never for javascript: URLs', () => { + expect(safePrivacyUrl('https://example.com/privacy')).toBe('https://example.com/privacy'); + expect(safePrivacyUrl('http://example.com/privacy')).toBe('http://example.com/privacy'); + expect(safePrivacyUrl('javascript:alert(1)')).toBeUndefined(); + expect(safePrivacyUrl('data:text/html,x')).toBeUndefined(); + expect(safePrivacyUrl('')).toBeUndefined(); + + const menu = new HeaderMenu(moreBtn, { + moreLabel: 'More options', + newConversationLabel: 'New conversation', + downloadTranscriptLabel: 'Download transcript', + privacyLabel: 'Privacy', + privacyUrl: 'javascript:alert(1)', + }); + root.appendChild(menu.el); + expect(menu.el.querySelector('a')).toBeNull(); + + menu.update({ privacyUrl: 'https://example.com/privacy' }); + const link = menu.el.querySelector('a') as HTMLAnchorElement; + expect(link).not.toBeNull(); + expect(link.getAttribute('role')).toBe('menuitem'); + expect(link.getAttribute('href')).toBe('https://example.com/privacy'); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); +}); + +describe('Panel header menu integration', () => { + it('Escape closes menu only and restores focus to moreBtn without closing panel', async () => { + document.body.innerHTML = ''; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = host.attachShadow({ mode: 'open' }); + + const onClose = vi.fn(); + const panel = new Panel(root, { + position: 'bottom-right', + branding: { name: 'Bot', subtitle: 'Online' }, + placeholder: 'Type...', + footerText: 'Powered by ChatCops', + closeLabel: 'Close', + sendLabel: 'Send', + onSend: vi.fn(), + onClose, + }); + panel.show(); + + const moreBtn = root.querySelector('.cc-header-more') as HTMLButtonElement; + moreBtn.click(); + await vi.dynamicImportSettled(); + + const menu = root.querySelector('.cc-header-menu'); + expect(menu?.hasAttribute('hidden')).toBe(false); + + const panelEl = root.querySelector('.cc-panel') as HTMLElement; + panelEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(menu?.hasAttribute('hidden')).toBe(true); + expect(onClose).not.toHaveBeenCalled(); + expect(root.activeElement).toBe(moreBtn); + + panelEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); + +describe('Widget menu actions', () => { + let widget: Widget | undefined; + + beforeEach(() => { + document.body.innerHTML = ''; + localStorage.clear(); + sessionStorage.clear(); + }); + + afterEach(() => { + widget?.destroy(); + widget = undefined; + vi.restoreAllMocks(); + }); + + it('New conversation resets the log', async () => { + widget = new Widget({ apiUrl: 'https://api.test/chat' }); + widget.init(); + widget.open(); + await vi.dynamicImportSettled(); + + const shadow = document.querySelector('#chatcops-root')?.shadowRoot; + const intern = widget as any; + intern.messages.push({ id: '1', role: 'user', content: 'Hello' }); + intern.panel.addMessage({ id: '1', role: 'user', content: 'Hello' }); + expect(shadow?.querySelectorAll('.cc-message-user')).toHaveLength(1); + + const moreBtn = shadow?.querySelector('.cc-header-more') as HTMLButtonElement; + moreBtn.click(); + await vi.dynamicImportSettled(); + + const newItem = shadow?.querySelector('[data-action="new"]') as HTMLElement; + newItem.click(); + + expect(shadow?.querySelectorAll('.cc-message-user')).toHaveLength(0); + expect(intern.messages.filter((m: any) => m.role === 'user')).toHaveLength(0); + }); + + it('Download transcript creates a .txt file with the right text', async () => { + widget = new Widget({ apiUrl: 'https://api.test/chat' }); + widget.init(); + widget.open(); + await vi.dynamicImportSettled(); + + let capturedBlob: Blob | undefined; + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob: Blob) => { + capturedBlob = blob; + return 'blob:mock'; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + + let downloadedName = ''; + const originalClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) { + downloadedName = this.download; + }; + + try { + const intern = widget as any; + intern.messages.push( + { id: '1', role: 'user', content: 'Hello world' }, + { id: '2', role: 'assistant', content: 'Hi there!' } + ); + widget.downloadTranscript(); + + expect(downloadedName).toBe('transcript.txt'); + expect(capturedBlob).toBeDefined(); + const text = await capturedBlob?.text(); + expect(text).toBe('Assistant: How can I help you today?\n\nVisitor: Hello world\n\nAssistant: Hi there!'); + } finally { + HTMLAnchorElement.prototype.click = originalClick; + } + }); +}); diff --git a/packages/widget/tests/dom/panel.test.ts b/packages/widget/tests/dom/panel.test.ts index 9cab6a75..8dbdcfbc 100644 --- a/packages/widget/tests/dom/panel.test.ts +++ b/packages/widget/tests/dom/panel.test.ts @@ -93,7 +93,7 @@ describe('Panel', () => { it('wraps Tab from the last focusable element to the first', () => { const panel = createPanel(); panel.show(); - const closeButton = root.querySelector('.cc-header-close') as HTMLButtonElement; + const moreButton = root.querySelector('.cc-header-more') as HTMLButtonElement; const textarea = root.querySelector('.cc-panel textarea') as HTMLTextAreaElement; textarea.focus(); @@ -101,13 +101,13 @@ describe('Panel', () => { textarea.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); - expect(root.activeElement).toBe(closeButton); + expect(root.activeElement).toBe(moreButton); }); it('wraps Shift+Tab from the first focusable element to the latest one', () => { const panel = createPanel({ preChatEnabled: true }); panel.show(); - const closeButton = root.querySelector('.cc-header-close') as HTMLButtonElement; + const moreButton = root.querySelector('.cc-header-more') as HTMLButtonElement; const submitButton = document.createElement('button'); submitButton.textContent = 'Submit pre-chat form'; submitButton.tabIndex = 0; @@ -118,7 +118,7 @@ describe('Panel', () => { preChatForm.className = 'cc-prechat-fields'; preChatForm.append(preChatInput, submitButton); panel.messagesContainer.appendChild(preChatForm); - closeButton.focus(); + moreButton.focus(); const event = new KeyboardEvent('keydown', { key: 'Tab', @@ -126,7 +126,7 @@ describe('Panel', () => { bubbles: true, cancelable: true, }); - closeButton.dispatchEvent(event); + moreButton.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); expect(root.activeElement).toBe(root.querySelector('.cc-panel textarea')); @@ -135,7 +135,7 @@ describe('Panel', () => { it('recomputes focusable elements after they are added or removed', () => { const panel = createPanel(); panel.show(); - const closeButton = root.querySelector('.cc-header-close') as HTMLButtonElement; + const moreButton = root.querySelector('.cc-header-more') as HTMLButtonElement; const textarea = root.querySelector('.cc-panel textarea') as HTMLTextAreaElement; const retryButton = document.createElement('button'); retryButton.textContent = 'Retry'; @@ -151,7 +151,7 @@ describe('Panel', () => { const tabFromRetry = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); retryButton.dispatchEvent(tabFromRetry); expect(tabFromRetry.defaultPrevented).toBe(true); - expect(root.activeElement).toBe(closeButton); + expect(root.activeElement).toBe(moreButton); retryButton.remove(); textarea.focus(); @@ -162,7 +162,7 @@ describe('Panel', () => { }); textarea.dispatchEvent(tabAfterRetryIsRemoved); expect(tabAfterRetryIsRemoved.defaultPrevented).toBe(true); - expect(root.activeElement).toBe(closeButton); + expect(root.activeElement).toBe(moreButton); }); it('does not trap focus in a closed popup', () => { diff --git a/website/src/content/docs/widget/configuration.mdx b/website/src/content/docs/widget/configuration.mdx index 0337e097..2c5518b6 100644 --- a/website/src/content/docs/widget/configuration.mdx +++ b/website/src/content/docs/widget/configuration.mdx @@ -46,6 +46,7 @@ ChatCops supports two display modes: data-placeholder="Type a message..." data-locale="en" data-auto-open="1000" + data-privacy-url="https://example.com/privacy" > ``` @@ -218,6 +219,19 @@ ChatCops.init({ `disclosure: false` (or `data-disclosure="false"`) removes it. The widget then logs a note in the console, because telling visitors some other way is up to you. +### Privacy policy link + +Add `privacyUrl` (or `data-privacy-url` on the script tag) to show a "Privacy" link in the header's "More options" menu (`⋯`), opening your site's privacy policy in a new tab: + +```typescript +ChatCops.init({ + apiUrl: '/api/chat', + privacyUrl: 'https://example.com/privacy', +}); +``` + +Only `https:` and `http:` URLs are accepted. Invalid or unsafe URLs (such as `javascript:`) are omitted. + ### Streaming inactivity timeout Set `timeout` in `ChatCops.init()` to change the inactivity timeout in milliseconds. @@ -298,6 +312,7 @@ interface WidgetConfig { locale?: string; // Sent to the server with each message. Only English strings are built in dir?: 'ltr' | 'rtl' | 'auto'; // Text direction; defaults from locale, then document direction strings?: Partial; // Custom i18n overrides + privacyUrl?: string; // Link in header menu to your site's privacy policy (https: or http: only) preChatForm?: { enabled: boolean; title?: string; @@ -356,6 +371,7 @@ See the [pre-chat form guide](/widget/pre-chat-form) for field behavior, validat | `data-persistence` | `persistence` | `'local'`, `'session'` or `'none'` | | `data-history-ttl-days` | `historyTtlDays` | Days a saved conversation is kept | | `data-wait-for-consent` | `waitForConsent` | Wait for `ChatCops.setConsent(true)` before using storage or the API. `"false"` turns it off | +| `data-privacy-url` | `privacyUrl` | Privacy policy link in header menu (https: or http: only) | | `data-turnstile-site-key` | `turnstileSiteKey` | Cloudflare Turnstile site key, when the server checks Turnstile | | `data-user-token` | `userToken` | Signed token for a signed-in visitor, when the server has `identity` set | From 2142c431018506cbb8f802ec3a14e052b63f831f Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sat, 31 Oct 2026 03:16:55 +0530 Subject: [PATCH 2/3] test(widget): configure privacyUrl in dev.html Signed-off-by: Anurag Yadav --- packages/widget/dev.html | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/widget/dev.html b/packages/widget/dev.html index 8fa931ca..484aeabb 100644 --- a/packages/widget/dev.html +++ b/packages/widget/dev.html @@ -77,6 +77,7 @@

Configuration

mode, ...(mode === 'inline' && { container: '#chatcops-inline' }), apiUrl: 'http://localhost:3001/chat', + privacyUrl: 'https://chat.codercops.com/privacy', theme: { accent: '#6366f1', }, From 8ae58cb18456bf10b8af8973f1e624cfc193f3bf Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sat, 31 Oct 2026 08:48:51 +0530 Subject: [PATCH 3/3] fix(widget): address review feedback on header menu Signed-off-by: Anurag Yadav --- api/widget.api.md | 1 - e2e/tests/states.spec.ts | 1 + packages/widget/dev.html | 1 - packages/widget/src/dom/menu.ts | 61 +++++++++++++++++++++--- packages/widget/src/dom/panel.ts | 64 ++++++-------------------- packages/widget/src/i18n.ts | 12 +---- packages/widget/src/styles/menu.css | 8 +++- packages/widget/src/widget.ts | 4 +- packages/widget/tests/dom/menu.test.ts | 15 ++++-- 9 files changed, 91 insertions(+), 76 deletions(-) diff --git a/api/widget.api.md b/api/widget.api.md index 1923b4f4..d9568103 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -99,7 +99,6 @@ export declare class Widget { }): Promise; newConversation(): void; clear(): void; - downloadTranscript(): void; setUser(user: { email?: string; name?: string; diff --git a/e2e/tests/states.spec.ts b/e2e/tests/states.spec.ts index a0a3a14a..6281029b 100644 --- a/e2e/tests/states.spec.ts +++ b/e2e/tests/states.spec.ts @@ -258,6 +258,7 @@ for (const look of looks) { await start(page, look, { privacyUrl: 'https://example.com/privacy' }); await openChat(page); await ask(page, 'First message'); + await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.'); await expect(page.locator('.cc-message-user')).toHaveCount(1); const moreBtn = page.locator('.cc-header-more'); diff --git a/packages/widget/dev.html b/packages/widget/dev.html index 484aeabb..8fa931ca 100644 --- a/packages/widget/dev.html +++ b/packages/widget/dev.html @@ -77,7 +77,6 @@

Configuration

mode, ...(mode === 'inline' && { container: '#chatcops-inline' }), apiUrl: 'http://localhost:3001/chat', - privacyUrl: 'https://chat.codercops.com/privacy', theme: { accent: '#6366f1', }, diff --git a/packages/widget/src/dom/menu.ts b/packages/widget/src/dom/menu.ts index 1fdbaefc..6eb8a10e 100644 --- a/packages/widget/src/dom/menu.ts +++ b/packages/widget/src/dom/menu.ts @@ -1,10 +1,10 @@ import menuStyles from '../styles/menu.css?inline'; export interface HeaderMenuOptions { - moreLabel: string; - newConversationLabel: string; - downloadTranscriptLabel: string; - privacyLabel: string; + moreLabel?: string; + newConversationLabel?: string; + downloadTranscriptLabel?: string; + privacyLabel?: string; privacyUrl?: string; onNewConversation?: () => void; onDownloadTranscript?: () => void; @@ -47,7 +47,7 @@ export class HeaderMenu { const menu = document.createElement('div'); menu.className = 'cc-header-menu'; menu.setAttribute('role', 'menu'); - menu.setAttribute('aria-label', options.moreLabel); + menu.setAttribute('aria-label', options.moreLabel ?? 'More options'); menu.setAttribute('hidden', ''); this.el = menu; @@ -93,6 +93,8 @@ export class HeaderMenu { setButton(button: HTMLButtonElement): void { this.btn = button; + this.btn.setAttribute('aria-haspopup', 'menu'); + this.btn.setAttribute('aria-expanded', String(this.isOpen)); } get isOpen(): boolean { @@ -124,7 +126,13 @@ export class HeaderMenu { } private render(): void { - const { moreLabel, newConversationLabel, downloadTranscriptLabel, privacyLabel, privacyUrl } = this.options; + const { + moreLabel = 'More options', + newConversationLabel = 'New conversation', + downloadTranscriptLabel = 'Download transcript', + privacyLabel = 'Privacy', + privacyUrl, + } = this.options; this.el.setAttribute('aria-label', moreLabel); const validPrivacyUrl = safePrivacyUrl(privacyUrl); this.el.innerHTML = @@ -141,3 +149,44 @@ export class HeaderMenu { return div.innerHTML; } } + +export function attachMenu( + button: HTMLButtonElement, + header: HTMLElement, + options: HeaderMenuOptions, + event?: Event, + existingMenu?: HeaderMenu | null +): HeaderMenu { + const menu = existingMenu ?? new HeaderMenu(button, options); + menu.setButton(button); + if (!header.contains(menu.el)) header.appendChild(menu.el); + + const openWithFocus = (focusLast?: boolean) => menu.open(focusLast); + + button.onclick = () => (menu.isOpen ? menu.close() : openWithFocus()); + button.onkeydown = (e) => { + if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openWithFocus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + openWithFocus(true); + } + }; + + if (event) { + if (event instanceof KeyboardEvent) { + if (event.key === 'ArrowUp') { + event.preventDefault(); + openWithFocus(true); + } else if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openWithFocus(); + } + } else { + openWithFocus(); + } + } + + return menu; +} diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index cb9406ca..c50c4600 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -1,12 +1,11 @@ import { Messages, type MessageActions, type MessageData } from './messages.js'; import { Input } from './input.js'; -import { MORE_ICON } from './icons.js'; +import { CLOSE_ICON, MORE_ICON } from './icons.js'; import type { HeaderMenu } from './menu.js'; export const PANEL_ID = 'cc-panel'; const BOT_ICON = ``; -const CLOSE_ICON = ``; let menuChunk: Promise | undefined; @@ -169,31 +168,13 @@ export class Panel { // Messages reads its labels from this same object as it renders. if (this.options.messageActions) Object.assign(this.options.messageActions, actionLabels); this.renderHeader(); - this.headerMenu?.update({ - moreLabel: labels.moreLabel, - newConversationLabel: labels.newConversationLabel, - downloadTranscriptLabel: labels.downloadTranscriptLabel, - privacyLabel: labels.privacyLabel, - privacyUrl: labels.privacyUrl, - }); + this.headerMenu?.update(labels); this.input.setLabels(labels.placeholder, labels.sendLabel, labels.stopLabel); this.footer.innerHTML = labels.footerText; } private renderHeader(): void { - const { - branding, - closeLabel, - inline, - onClose, - moreLabel = 'More options', - newConversationLabel = 'New conversation', - downloadTranscriptLabel = 'Download transcript', - privacyLabel = 'Privacy', - privacyUrl, - onNewConversation, - onDownloadTranscript, - } = this.options; + const { branding, closeLabel, inline, onClose } = this.options; const active = (this.el.getRootNode() as ShadowRoot | Document).activeElement; const moreHadFocus = this.moreBtn && (this.moreBtn === active || this.headerMenu?.el.contains(active)); const closeHadFocus = !inline && this.header.contains(active) && !moreHadFocus; @@ -223,40 +204,24 @@ export class Panel { moreBtn.className = 'cc-header-more'; moreBtn.setAttribute('aria-haspopup', 'menu'); moreBtn.setAttribute('aria-expanded', 'false'); - moreBtn.setAttribute('aria-label', moreLabel); + moreBtn.setAttribute('aria-label', this.options.moreLabel ?? 'More options'); moreBtn.innerHTML = MORE_ICON; this.moreBtn = moreBtn; - const openMenu = (focusLast?: boolean) => { - void loadMenu().then(({ HeaderMenu }) => { - this.headerMenu ??= new HeaderMenu(moreBtn, { - moreLabel, - newConversationLabel, - downloadTranscriptLabel, - privacyLabel, - privacyUrl, - onNewConversation, - onDownloadTranscript, - }); - this.headerMenu.setButton(moreBtn); - if (!this.header.contains(this.headerMenu.el)) this.header.appendChild(this.headerMenu.el); - this.headerMenu.open(focusLast); + const load = (e: Event) => { + void loadMenu().then(({ attachMenu }) => { + this.headerMenu = attachMenu(moreBtn, this.header, this.options, e, this.headerMenu); }); }; - - moreBtn.onclick = () => (this.headerMenu?.isOpen ? this.headerMenu.close() : openMenu()); - moreBtn.onkeydown = (e) => { - if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openMenu(); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - openMenu(true); - } - }; + moreBtn.onclick = load; + moreBtn.onkeydown = load; + moreBtn.onpointerenter = () => void loadMenu(); this.header.appendChild(moreBtn); - if (this.headerMenu) this.header.appendChild(this.headerMenu.el); + if (this.headerMenu) { + this.headerMenu.setButton(moreBtn); + this.header.appendChild(this.headerMenu.el); + } if (!inline) { const closeBtn = document.createElement('button'); @@ -273,7 +238,6 @@ export class Panel { show(): void { this.el.classList.add('cc-visible'); - void loadMenu().catch(() => undefined); if (!this.inline) { this.setHidden(false); this.input.focus(); diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index 375f95ca..fc66bac8 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -89,17 +89,9 @@ const en: WidgetLocaleStrings = { const locales: Record = { en }; -export function getWidgetLocale( - code: string, - overrides?: Partial & { moreOptions?: string } -): WidgetLocaleStrings { +export function getWidgetLocale(code: string, overrides?: Partial): WidgetLocaleStrings { const base = locales[code] ?? en; - if (!overrides) return base; - const merged = { ...base, ...overrides }; - if (overrides.moreOptions && !overrides.moreButton) { - merged.moreButton = overrides.moreOptions; - } - return merged; + return overrides ? { ...base, ...overrides } : base; } /** Adds or replaces a language. Strings it leaves out stay in English. */ diff --git a/packages/widget/src/styles/menu.css b/packages/widget/src/styles/menu.css index 36445629..4b1ff63d 100644 --- a/packages/widget/src/styles/menu.css +++ b/packages/widget/src/styles/menu.css @@ -34,8 +34,12 @@ white-space: nowrap; } -.cc-menu-item:hover, +.cc-menu-item:hover { + background: var(--cc-bg-input); +} + .cc-menu-item:focus-visible { background: var(--cc-bg-input); - outline: none; + outline: 2px solid transparent; + box-shadow: var(--cc-focus-ring); } diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 4de612bc..ca7571c5 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -475,7 +475,7 @@ export class Widget { } /** Saves the visible conversation as a text file. */ - downloadTranscript(): void { + private downloadTranscript(): void { const text = this.messages .filter((m) => m.content?.trim()) .map((m) => `${m.role === 'user' ? 'Visitor' : 'Assistant'}: ${m.content}`) @@ -484,7 +484,7 @@ export class Widget { a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' })); a.download = 'transcript.txt'; a.click(); - URL.revokeObjectURL(a.href); + setTimeout(() => URL.revokeObjectURL(a.href), 100); } private reset(forget: boolean, show = true): void { diff --git a/packages/widget/tests/dom/menu.test.ts b/packages/widget/tests/dom/menu.test.ts index 9bd5b522..a06866d7 100644 --- a/packages/widget/tests/dom/menu.test.ts +++ b/packages/widget/tests/dom/menu.test.ts @@ -170,6 +170,13 @@ describe('Widget menu actions', () => { sessionStorage.clear(); }); + type Internals = { + messages: Array<{ id: string; role: string; content: string }>; + panel: { addMessage: (m: { id: string; role: string; content: string }) => void }; + downloadTranscript: () => void; + }; + const internals = (w: Widget) => w as unknown as Internals; + afterEach(() => { widget?.destroy(); widget = undefined; @@ -183,7 +190,7 @@ describe('Widget menu actions', () => { await vi.dynamicImportSettled(); const shadow = document.querySelector('#chatcops-root')?.shadowRoot; - const intern = widget as any; + const intern = internals(widget); intern.messages.push({ id: '1', role: 'user', content: 'Hello' }); intern.panel.addMessage({ id: '1', role: 'user', content: 'Hello' }); expect(shadow?.querySelectorAll('.cc-message-user')).toHaveLength(1); @@ -196,7 +203,7 @@ describe('Widget menu actions', () => { newItem.click(); expect(shadow?.querySelectorAll('.cc-message-user')).toHaveLength(0); - expect(intern.messages.filter((m: any) => m.role === 'user')).toHaveLength(0); + expect(intern.messages.filter((m) => m.role === 'user')).toHaveLength(0); }); it('Download transcript creates a .txt file with the right text', async () => { @@ -219,12 +226,12 @@ describe('Widget menu actions', () => { }; try { - const intern = widget as any; + const intern = internals(widget); intern.messages.push( { id: '1', role: 'user', content: 'Hello world' }, { id: '2', role: 'assistant', content: 'Hi there!' } ); - widget.downloadTranscript(); + intern.downloadTranscript(); expect(downloadedName).toBe('transcript.txt'); expect(capturedBlob).toBeDefined();