feat(a11y): label icon-only buttons and inputs for screen readers
Header, message action, send/stop, history, model selector and dark-mode controls had no accessible name (icon-only buttons). Adds roles/labels and selected/disabled state; no visual change.
This commit is contained in:
1 parent
0e5b38fd15
commit
b441339bb4
8 files changed
+84
-7
No files matched your search
@@ -0,0 +1,39 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||||||
|
import React from 'react';
|
||||||
|
import { ChatInput } from '../components/chat/ChatInput';
|
||||||
|
|
||||||
|
describe('ChatInput', () => {
|
||||||
|
it('sends trimmed text, clears the field and disables send while empty', () => {
|
||||||
|
const onSend = jest.fn();
|
||||||
|
render(<ChatInput onSend={onSend} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Send message').props.accessibilityState.disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.changeText(screen.getByLabelText('Message'), ' hello ');
|
||||||
|
fireEvent.press(screen.getByLabelText('Send message'));
|
||||||
|
|
||||||
|
expect(onSend).toHaveBeenCalledWith('hello');
|
||||||
|
expect(screen.getByLabelText('Message').props.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a stop button instead of send while loading', () => {
|
||||||
|
const onStop = jest.fn();
|
||||||
|
render(<ChatInput onSend={jest.fn()} onStop={onStop} isLoading />);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('Send message')).toBeNull();
|
||||||
|
fireEvent.press(screen.getByLabelText('Stop generating'));
|
||||||
|
expect(onStop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the field when editing and lets the user cancel', () => {
|
||||||
|
const onCancelEdit = jest.fn();
|
||||||
|
render(<ChatInput onSend={jest.fn()} editingText="old text" onCancelEdit={onCancelEdit} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Editing message')).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText('Message').props.value).toBe('old text');
|
||||||
|
|
||||||
|
fireEvent.press(screen.getByLabelText('Cancel editing'));
|
||||||
|
expect(onCancelEdit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByLabelText('Message').props.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,8 @@ export default function ChatLayout() {
|
|||||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push('/chat/history')}
|
onPress={() => router.push('/chat/history')}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Chat history"
|
||||||
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -43,6 +45,8 @@ export default function ChatLayout() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={clearChat}
|
onPress={clearChat}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="New chat"
|
||||||
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -53,6 +57,8 @@ export default function ChatLayout() {
|
|||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push('/settings')}
|
onPress={() => router.push('/settings')}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Settings"
|
||||||
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|||||||
+10
-1
@@ -20,7 +20,12 @@ export default function HistoryScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colors.backgroundColor }]}>
|
<View style={[styles.container, { backgroundColor: colors.backgroundColor }]}>
|
||||||
<View style={[styles.header, { borderBottomColor: colors.border, backgroundColor: colors.backgroundColor }]}>
|
<View style={[styles.header, { borderBottomColor: colors.border, backgroundColor: colors.backgroundColor }]}>
|
||||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={styles.backButton}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Back"
|
||||||
|
>
|
||||||
<ArrowLeft size={24} color={colors.text} />
|
<ArrowLeft size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.title, { color: colors.text }]}>Chat History</Text>
|
<Text style={[styles.title, { color: colors.text }]}>Chat History</Text>
|
||||||
@@ -43,6 +48,8 @@ export default function HistoryScreen() {
|
|||||||
{ backgroundColor: colors.cardBackground, borderColor: colors.border }
|
{ backgroundColor: colors.cardBackground, borderColor: colors.border }
|
||||||
]}
|
]}
|
||||||
onPress={() => handleLoadChat(item.id)}
|
onPress={() => handleLoadChat(item.id)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Open chat: ${item.title}`}
|
||||||
>
|
>
|
||||||
<View style={styles.chatInfo}>
|
<View style={styles.chatInfo}>
|
||||||
<Text style={[styles.chatTitle, { color: colors.text }]} numberOfLines={1}>
|
<Text style={[styles.chatTitle, { color: colors.text }]} numberOfLines={1}>
|
||||||
@@ -55,6 +62,8 @@ export default function HistoryScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.deleteButton}
|
style={styles.deleteButton}
|
||||||
onPress={() => deleteChatSession(item.id)}
|
onPress={() => deleteChatSession(item.id)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Delete chat: ${item.title}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={20} color={colors.errorText} />
|
<Trash2 size={20} color={colors.errorText} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}>
|
<View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}>
|
||||||
<Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text>
|
<Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text>
|
||||||
<TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}>
|
<TouchableOpacity
|
||||||
|
onPress={handleCancelEdit}
|
||||||
|
style={styles.editCancelBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Cancel editing"
|
||||||
|
>
|
||||||
<X size={16} color={colors.textMuted} />
|
<X size={16} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -80,6 +85,7 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, { color: colors.text }]}
|
style={[styles.input, { color: colors.text }]}
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
|
accessibilityLabel="Message"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={colors.textMuted}
|
||||||
value={text}
|
value={text}
|
||||||
onChangeText={setText}
|
onChangeText={setText}
|
||||||
@@ -94,6 +100,8 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
|
|||||||
style={[styles.stopButton, { backgroundColor: colors.errorText }]}
|
style={[styles.stopButton, { backgroundColor: colors.errorText }]}
|
||||||
onPress={handleStop}
|
onPress={handleStop}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Stop generating"
|
||||||
>
|
>
|
||||||
<Square size={16} color={colors.backgroundColor} />
|
<Square size={16} color={colors.backgroundColor} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -106,6 +114,9 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
|
|||||||
onPress={handleSend}
|
onPress={handleSend}
|
||||||
disabled={!text.trim()}
|
disabled={!text.trim()}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Send message"
|
||||||
|
accessibilityState={{ disabled: !text.trim() }}
|
||||||
>
|
>
|
||||||
<Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} />
|
<Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export function CodeBlock({ language, content, copied, onCopy }: CodeBlockProps)
|
|||||||
<View style={styles.codeBlockWrapper}>
|
<View style={styles.codeBlockWrapper}>
|
||||||
<View style={styles.codeBlockHeader}>
|
<View style={styles.codeBlockHeader}>
|
||||||
<Text style={styles.codeBlockLanguage}>{language}</Text>
|
<Text style={styles.codeBlockLanguage}>{language}</Text>
|
||||||
<TouchableOpacity onPress={() => onCopy(content)} style={styles.copyButton}>
|
<TouchableOpacity
|
||||||
|
onPress={() => onCopy(content)}
|
||||||
|
style={styles.copyButton}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
<CopyIcon size={14} color="#A0A0A0" />
|
<CopyIcon size={14} color="#A0A0A0" />
|
||||||
<Text style={styles.copyText}>{copied ? 'Copied!' : 'Copy'}</Text>
|
<Text style={styles.copyText}>{copied ? 'Copied!' : 'Copy'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -50,24 +50,24 @@ export function MessageActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
|
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Copy message">
|
||||||
<Copy size={16} color={colors.textMuted} />
|
<Copy size={16} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{isUser && onEdit && (
|
{isUser && onEdit && (
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Edit message">
|
||||||
<Pencil size={16} color={colors.textMuted} />
|
<Pencil size={16} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isUser && onRegenerate && (
|
{!isUser && onRegenerate && (
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Regenerate response">
|
||||||
<RefreshCw size={16} color={colors.textMuted} />
|
<RefreshCw size={16} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Delete message">
|
||||||
<Trash2 size={16} color={colors.textMuted} />
|
<Trash2 size={16} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function ModelSelector() {
|
|||||||
style={[styles.headerButton, { backgroundColor: colors.cardBackground }]}
|
style={[styles.headerButton, { backgroundColor: colors.cardBackground }]}
|
||||||
onPress={() => setModalVisible(true)}
|
onPress={() => setModalVisible(true)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Model: ${activeModelName}. Tap to change`}
|
||||||
>
|
>
|
||||||
<ConnectionBadge isConnected={isConnected} compact />
|
<ConnectionBadge isConnected={isConnected} compact />
|
||||||
<Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}>
|
<Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}>
|
||||||
@@ -70,6 +72,8 @@ export function ModelSelector() {
|
|||||||
onPress={handleRefresh}
|
onPress={handleRefresh}
|
||||||
style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]}
|
style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Refresh models"
|
||||||
>
|
>
|
||||||
{isRefreshing ? (
|
{isRefreshing ? (
|
||||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||||
@@ -108,6 +112,9 @@ export function ModelSelector() {
|
|||||||
isSelected && { borderColor: colors.tint }
|
isSelected && { borderColor: colors.tint }
|
||||||
]}
|
]}
|
||||||
onPress={() => handleSelect(item.id)}
|
onPress={() => handleSelect(item.id)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={item.id}
|
||||||
|
accessibilityState={{ selected: isSelected }}
|
||||||
>
|
>
|
||||||
<View style={styles.itemContent}>
|
<View style={styles.itemContent}>
|
||||||
<Text style={[styles.itemName, { color: colors.text }]}>
|
<Text style={[styles.itemName, { color: colors.text }]}>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function AppearanceCard() {
|
|||||||
<Switch
|
<Switch
|
||||||
value={isDarkMode}
|
value={isDarkMode}
|
||||||
onValueChange={toggleTheme}
|
onValueChange={toggleTheme}
|
||||||
|
accessibilityLabel="Dark Mode"
|
||||||
trackColor={{ false: '#E2E8F0', true: colors.tint }}
|
trackColor={{ false: '#E2E8F0', true: colors.tint }}
|
||||||
thumbColor={'#FFFFFF'}
|
thumbColor={'#FFFFFF'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user