Skip to content
Open
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/announce-messages.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/widget/src/dom/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
11 changes: 10 additions & 1 deletion packages/widget/src/dom/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface MessageActions {
onRegenerate?: (messageId: string) => void;
retryLabel?: string;
regenerateLabel?: string;
typingLabel?: string;
}

export class Messages {
Expand All @@ -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);
}

Expand All @@ -53,7 +56,7 @@ export class Messages {
if (this.typingEl) return;
this.typingEl = document.createElement('div');
this.typingEl.className = 'cc-typing';
this.typingEl.innerHTML = '<div class="cc-typing-dot"></div><div class="cc-typing-dot"></div><div class="cc-typing-dot"></div>';
this.typingEl.innerHTML = `<span class="cc-sr-only">${this.escapeHtml(this.actions?.typingLabel ?? 'Typing...')}</span><div class="cc-typing-dot" aria-hidden="true"></div><div class="cc-typing-dot" aria-hidden="true"></div><div class="cc-typing-dot" aria-hidden="true"></div>`;
this.container.appendChild(this.typingEl);
this.scrollToBottom();
}
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions packages/widget/src/styles/widget.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions packages/widget/tests/dom/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...');
});
});
25 changes: 25 additions & 0 deletions packages/widget/tests/dom/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down