diff --git a/.changeset/announce-messages.md b/.changeset/announce-messages.md new file mode 100644 index 0000000..c9c2b88 --- /dev/null +++ b/.changeset/announce-messages.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": patch +--- + +Screen readers now announce new chat messages and the typing indicator, and the message box has an accessible name. diff --git a/packages/widget/src/dom/input.ts b/packages/widget/src/dom/input.ts index 1461c86..c1cbd1a 100644 --- a/packages/widget/src/dom/input.ts +++ b/packages/widget/src/dom/input.ts @@ -22,6 +22,7 @@ export class Input { this.textarea = document.createElement('textarea'); this.textarea.className = 'cc-input'; this.textarea.placeholder = options.placeholder; + this.textarea.setAttribute('aria-label', options.placeholder); this.textarea.rows = 1; this.textarea.maxLength = MAX_MESSAGE_LENGTH; this.textarea.addEventListener('input', () => this.autoResize()); diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index bbb8634..e457782 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -19,6 +19,7 @@ export interface MessageActions { onRegenerate?: (messageId: string) => void; retryLabel?: string; regenerateLabel?: string; + typingLabel?: string; } export class Messages { @@ -30,6 +31,8 @@ export class Messages { this.actions = actions; this.container = document.createElement('div'); this.container.className = 'cc-messages'; + this.container.setAttribute('role', 'log'); + this.container.setAttribute('aria-live', 'polite'); parent.appendChild(this.container); } @@ -53,7 +56,7 @@ export class Messages { if (this.typingEl) return; this.typingEl = document.createElement('div'); this.typingEl.className = 'cc-typing'; - this.typingEl.innerHTML = '
'; + this.typingEl.innerHTML = `${this.escapeHtml(this.actions?.typingLabel ?? 'Typing...')}`; this.container.appendChild(this.typingEl); this.scrollToBottom(); } @@ -121,6 +124,12 @@ export class Messages { delete el.dataset.status; } + if (msg.status === 'streaming') { + el.setAttribute('aria-busy', 'true'); + } else { + el.removeAttribute('aria-busy'); + } + if (msg.errorType) { el.dataset.errorType = msg.errorType; } else { diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index d1cd99a..a39f2ea 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -550,6 +550,15 @@ display: none; } +.cc-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + /* Messages wrapper (only present when pre-chat form is enabled) */ .cc-messages-wrapper { flex: 1; diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index ed4d125..fc5351c 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -173,6 +173,7 @@ export class Widget { }, retryLabel: this.locale.retryButton, regenerateLabel: this.locale.regenerateButton, + typingLabel: this.locale.typingIndicator, }, onSend: (text) => this.handleSend(text), onClose: () => this.close(), diff --git a/packages/widget/tests/dom/input.test.ts b/packages/widget/tests/dom/input.test.ts index b0c5a1b..9e7793e 100644 --- a/packages/widget/tests/dom/input.test.ts +++ b/packages/widget/tests/dom/input.test.ts @@ -139,4 +139,8 @@ describe('Input keyboard behavior', () => { expect(textarea.value).toBe(''); expect(sendButton.disabled).toBe(true); }); + + it('labels the textarea with the placeholder', () => { + expect(textarea.getAttribute('aria-label')).toBe('Type here...'); + }); }); diff --git a/packages/widget/tests/dom/messages.test.ts b/packages/widget/tests/dom/messages.test.ts index 7911da4..38af322 100644 --- a/packages/widget/tests/dom/messages.test.ts +++ b/packages/widget/tests/dom/messages.test.ts @@ -45,6 +45,31 @@ describe('Messages', () => { expect(parent.querySelector('.cc-typing')).toBeNull(); }); + it('announces new messages politely', () => { + const container = parent.querySelector('.cc-messages')!; + expect(container.getAttribute('role')).toBe('log'); + expect(container.getAttribute('aria-live')).toBe('polite'); + }); + + it('gives the typing indicator text and hides the dots', () => { + messages = new Messages(parent, { typingLabel: 'Escribiendo...' }); + messages.showTyping(); + const typing = parent.querySelectorAll('.cc-typing')[0]!; + expect(typing.querySelector('.cc-sr-only')?.textContent).toBe('Escribiendo...'); + typing.querySelectorAll('.cc-typing-dot').forEach((dot) => { + expect(dot.getAttribute('aria-hidden')).toBe('true'); + }); + }); + + it('marks a streaming message busy until it completes', () => { + messages.addMessage({ id: '5', role: 'assistant', content: 'Hel', status: 'streaming' }); + const el = parent.querySelector('[data-id="5"]')!; + expect(el.getAttribute('aria-busy')).toBe('true'); + + messages.updateMessage({ id: '5', role: 'assistant', content: 'Hello', status: 'complete' }); + expect(el.hasAttribute('aria-busy')).toBe(false); + }); + it('removes typing indicator when adding a message', () => { messages.showTyping(); messages.addMessage({ id: '4', role: 'assistant', content: 'Done' });