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 7fe13649..4485dea1 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -17,6 +17,12 @@ "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", diff --git a/api/widget.api.md b/api/widget.api.md index 85f3baff..d9568103 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -155,6 +155,7 @@ export interface WidgetConfig { onPreChatSubmit?: (data: Record) => void; waitForConsent?: boolean; onConsentRequest?: () => void; + privacyUrl?: string; } export type WidgetEventHandler = (...args: WidgetEvents[E]) => void; @@ -219,9 +220,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 82f8df6f..6281029b 100644 --- a/e2e/tests/states.spec.ts +++ b/e2e/tests/states.spec.ts @@ -254,6 +254,31 @@ 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-assistant').last()).toContainText('Hello from the test server.'); + 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 fa7eaeaa..3f0adb8d 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..6eb8a10e --- /dev/null +++ b/packages/widget/src/dom/menu.ts @@ -0,0 +1,192 @@ +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 ?? 'More options'); + 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; + this.btn.setAttribute('aria-haspopup', 'menu'); + this.btn.setAttribute('aria-expanded', String(this.isOpen)); + } + + 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 = '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 = + `` + + `` + + (validPrivacyUrl + ? `${this.escapeHtml(privacyLabel)}` + : ''); + } + + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + 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 21e9338c..c50c4600 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -1,10 +1,21 @@ import { Messages, type MessageActions, type MessageData } from './messages.js'; import { Input } from './input.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; + +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'; @@ -20,14 +31,34 @@ export interface PanelOptions { closeLabel: string; sendLabel: string; stopLabel: string; + moreLabel?: string; + newConversationLabel?: string; + downloadTranscriptLabel?: string; + privacyLabel?: string; + privacyUrl?: string; messageActions?: MessageActions; onSend: (text: string) => void; onStop: () => 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' + | 'stopLabel' + | 'moreLabel' + | 'newConversationLabel' + | 'downloadTranscriptLabel' + | 'privacyLabel' + | 'privacyUrl' +>; export class Panel { private el: HTMLDivElement; @@ -35,6 +66,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; @@ -55,6 +88,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; } @@ -91,6 +129,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'; @@ -124,13 +168,17 @@ 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(labels); this.input.setLabels(labels.placeholder, labels.sendLabel, labels.stopLabel); 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 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); @@ -152,6 +200,29 @@ 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', this.options.moreLabel ?? 'More options'); + moreBtn.innerHTML = MORE_ICON; + this.moreBtn = moreBtn; + + const load = (e: Event) => { + void loadMenu().then(({ attachMenu }) => { + this.headerMenu = attachMenu(moreBtn, this.header, this.options, e, this.headerMenu); + }); + }; + moreBtn.onclick = load; + moreBtn.onkeydown = load; + moreBtn.onpointerenter = () => void loadMenu(); + + this.header.appendChild(moreBtn); + if (this.headerMenu) { + this.headerMenu.setButton(moreBtn); + this.header.appendChild(this.headerMenu.el); + } + if (!inline) { const closeBtn = document.createElement('button'); closeBtn.className = 'cc-header-close'; @@ -159,9 +230,10 @@ 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 { @@ -173,6 +245,7 @@ export class Panel { } hide(): void { + this.headerMenu?.close(); this.el.classList.remove('cc-visible'); if (!this.inline) this.setHidden(true); } @@ -196,6 +269,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 d30746a0..fc66bac8 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -38,6 +38,9 @@ export interface WidgetLocaleStrings { disclosureLearnMore: string; consentNotice: string; consentButton: string; + moreButton: string; + downloadTranscript: string; + privacy: string; } const en: WidgetLocaleStrings = { @@ -79,6 +82,9 @@ 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 }; 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..4b1ff63d --- /dev/null +++ b/packages/widget/src/styles/menu.css @@ -0,0 +1,45 @@ +.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 { + background: var(--cc-bg-input); +} + +.cc-menu-item:focus-visible { + background: var(--cc-bg-input); + outline: 2px solid transparent; + box-shadow: var(--cc-focus-ring); +} diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index 1121d8b3..f9e70fa7 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 */ @@ -596,6 +601,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 c3ff11de..ca7571c5 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. */ @@ -289,6 +291,8 @@ export class Widget { onSend: (text) => this.handleSend(text), onStop: () => this.stopReply(), onClose: () => this.close(), + onNewConversation: () => this.newConversation(), + onDownloadTranscript: () => this.downloadTranscript(), }); if (this.config.disclosure === false) { @@ -470,6 +474,19 @@ export class Widget { this.reset(true); } + /** Saves the visible conversation as a text file. */ + private 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(); + setTimeout(() => URL.revokeObjectURL(a.href), 100); + } + private reset(forget: boolean, show = true): void { // Waiting for consent: there is nothing to reset, and the notice stays up. if (!this.consented) return; @@ -565,7 +582,7 @@ export class Widget { } private panelLabels(): PanelLabels { - const { branding, placeholder } = this.config; + const { branding, placeholder, privacyUrl } = this.config; const l = this.locale; return { branding: { @@ -578,6 +595,11 @@ export class Widget { closeLabel: l.closeButton, sendLabel: l.sendButton, stopLabel: l.stopButton, + 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..a06866d7 --- /dev/null +++ b/packages/widget/tests/dom/menu.test.ts @@ -0,0 +1,244 @@ +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(); + }); + + 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; + 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 = 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); + + 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) => 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 = internals(widget); + intern.messages.push( + { id: '1', role: 'user', content: 'Hello world' }, + { id: '2', role: 'assistant', content: 'Hi there!' } + ); + intern.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 fdf9f6fa..1235de97 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. @@ -308,6 +322,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; @@ -366,6 +381,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 |