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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/widget-header-menu.md
Original file line number Diff line number Diff line change
@@ -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`).
6 changes: 6 additions & 0 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export interface WidgetConfig {
onPreChatSubmit?: (data: Record<string, string>) => void;
waitForConsent?: boolean;
onConsentRequest?: () => void;
privacyUrl?: string;
}

export type WidgetEventHandler<E extends WidgetEventType = WidgetEventType> = (...args: WidgetEvents[E]) => void;
Expand Down Expand Up @@ -219,9 +220,12 @@ export interface WidgetLocaleStrings {
disclosureLearnMore: string;
consentNotice: string;
consentButton: string;
moreButton: string;
downloadTranscript: string;
privacy: string;
}

export type WidgetUpdate = Pick<WidgetConfig, 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings'> & {
export type WidgetUpdate = Pick<WidgetConfig, 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings' | 'privacyUrl'> & {
theme?: Omit<ThemeConfig, 'position'>;
};
```
Expand Down
25 changes: 25 additions & 0 deletions e2e/tests/states.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
4 changes: 3 additions & 1 deletion packages/widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {},
Expand Down Expand Up @@ -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">
</script>
```

Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/dom/icons.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** Launcher icons, shared by the loader and the full widget. */
export const CHAT_ICON = `<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z"/><path d="M7 9h10v2H7zm0-3h10v2H7z"/></svg>`;
export const CLOSE_ICON = `<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
export const MORE_ICON = `<svg viewBox="0 0 24 24"><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></svg>`;

192 changes: 192 additions & 0 deletions packages/widget/src/dom/menu.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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<HTMLElement>('[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<HeaderMenuOptions>): 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 =
`<button type="button" role="menuitem" class="cc-menu-item" tabindex="-1" data-action="new">${this.escapeHtml(newConversationLabel)}</button>` +
`<button type="button" role="menuitem" class="cc-menu-item" tabindex="-1" data-action="download">${this.escapeHtml(downloadTranscriptLabel)}</button>` +
(validPrivacyUrl
? `<a role="menuitem" class="cc-menu-item" tabindex="-1" target="_blank" rel="noopener noreferrer" href="${this.escapeHtml(validPrivacyUrl)}">${this.escapeHtml(privacyLabel)}</a>`
: '');
}

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;
}
Loading