Skip to content

Commit 38e75fe

Browse files
committed
fix: improve post editor accessibility
1 parent 20c62c0 commit 38e75fe

5 files changed

Lines changed: 139 additions & 24 deletions

File tree

docs/ACCESSIBILITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Use **assertive** only for urgent errors or time-sensitive status.
3838

3939
- Give the primary `<main>` a stable id such as `main-content` so skip links and **Alt+M** work everywhere. There should be **exactly one** `<main>` (or `role="main"`) per view.
4040
- For horizontal toolbars, add `data-roving-root` on the toolbar container. **Left/Right arrow** moves among buttons, links, tabs, and elements marked with `data-roving-item` (including those using `tabindex="-1"` for roving patterns).
41+
- Rich post editors should expose a named multiline textbox, a named formatting toolbar with pressed states, and helper text connected through `aria-describedby`. Post composer message lists should use `role="log"` with polite updates so new discussion activity is announced without interrupting the current task.
4142

4243
## What automation does _not_ prove
4344

src/app/components/messaging/MessageComposer.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useState, useRef, useCallback } from 'react';
3+
import { useState, useRef, useCallback, useId } from 'react';
44
import { FiSend, FiPaperclip, FiX, FiFile, FiImage, FiFileText } from 'react-icons/fi';
55
import RichTextEditor from '@/app/components/ui/RichTextEditor';
66

@@ -39,6 +39,7 @@ export default function MessageComposer({
3939
}: MessageComposerProps) {
4040
const [content, setContent] = useState('');
4141
const fileInputRef = useRef<HTMLInputElement>(null);
42+
const editorHelpId = useId();
4243

4344
const handleContentChange = useCallback(
4445
(newContent: string) => {
@@ -161,6 +162,8 @@ export default function MessageComposer({
161162
content={content}
162163
onChange={handleContentChange}
163164
placeholder="Type your message..."
165+
ariaLabel="Message content"
166+
describedBy={editorHelpId}
164167
/>
165168
</div>
166169

@@ -182,7 +185,7 @@ export default function MessageComposer({
182185
</div>
183186

184187
{/* Helper Text */}
185-
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 ml-12">
188+
<p id={editorHelpId} className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 ml-12">
186189
Press{' '}
187190
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-[10px] font-mono">
188191
Enter

src/app/components/social/GroupDiscussionThread.tsx

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import React, { useMemo, useState, useEffect, useRef } from 'react';
3+
import React, { useMemo, useState, useEffect, useRef, useId } from 'react';
44
import { motion, AnimatePresence } from 'framer-motion';
55
import { Paperclip, Send } from 'lucide-react';
66
import RichTextEditor from '@/app/components/ui/RichTextEditor';
@@ -26,6 +26,7 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
2626
const [files, setFiles] = useState<File[]>([]);
2727
const messagesEndRef = useRef<HTMLDivElement>(null);
2828
const fileInputRef = useRef<HTMLInputElement>(null);
29+
const editorHelpId = useId();
2930

3031
const attachments = useMemo<Attachment[]>(
3132
() =>
@@ -56,37 +57,60 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
5657
}
5758
};
5859

60+
const handleEditorKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
61+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
62+
event.preventDefault();
63+
handlePost();
64+
}
65+
};
66+
5967
return (
6068
<div className="flex flex-col h-full max-h-[600px] bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
61-
<div className="flex-1 overflow-y-auto space-y-4 p-4">
69+
<div
70+
className="flex-1 overflow-y-auto space-y-4 p-4"
71+
role="log"
72+
aria-label="Discussion messages"
73+
aria-live="polite"
74+
aria-relevant="additions text"
75+
>
6276
{messages.length === 0 ? (
63-
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
77+
<div className="text-center py-8 text-gray-500 dark:text-gray-400" role="status">
6478
<p className="text-sm">No messages yet. Start the conversation!</p>
6579
</div>
6680
) : (
6781
<AnimatePresence>
6882
{messages.map((m, index) => (
69-
<motion.div
83+
<motion.article
7084
key={m.id}
7185
initial={{ opacity: 0, y: 10 }}
7286
animate={{ opacity: 1, y: 0 }}
7387
transition={{ delay: index * 0.05 }}
7488
className="flex items-start gap-3 group"
89+
aria-labelledby={`message-${m.id}-sender`}
7590
>
7691
{/* Avatar */}
77-
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 border-2 border-white dark:border-gray-700 flex items-center justify-center text-sm font-medium text-purple-600 dark:text-purple-400">
92+
<div
93+
className="flex-shrink-0 w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 border-2 border-white dark:border-gray-700 flex items-center justify-center text-sm font-medium text-purple-600 dark:text-purple-400"
94+
aria-hidden="true"
95+
>
7896
{getInitials(m.senderName)}
7997
</div>
8098

8199
{/* Message Content */}
82100
<div className="flex-1 min-w-0">
83101
<div className="flex items-center gap-2 mb-1">
84-
<span className="text-sm font-medium text-gray-900 dark:text-gray-50">
102+
<span
103+
id={`message-${m.id}-sender`}
104+
className="text-sm font-medium text-gray-900 dark:text-gray-50"
105+
>
85106
{m.senderName}
86107
</span>
87-
<span className="text-xs text-gray-500 dark:text-gray-400">
108+
<time
109+
className="text-xs text-gray-500 dark:text-gray-400"
110+
dateTime={new Date(m.createdAt).toISOString()}
111+
>
88112
{formatDistanceToNow(new Date(m.createdAt), { addSuffix: true })}
89-
</span>
113+
</time>
90114
</div>
91115
<div
92116
className="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3"
@@ -109,16 +133,31 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
109133
</div>
110134
)}
111135
</div>
112-
</motion.div>
136+
</motion.article>
113137
))}
114138
</AnimatePresence>
115139
)}
116140
<div ref={messagesEndRef} />
117141
</div>
118142

119143
{/* Message Input */}
120-
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-3 bg-gray-50 dark:bg-gray-900/50">
121-
<RichTextEditor content={content} onChange={setContent} placeholder="Share an update..." />
144+
<form
145+
className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-3 bg-gray-50 dark:bg-gray-900/50"
146+
onSubmit={(event) => {
147+
event.preventDefault();
148+
handlePost();
149+
}}
150+
aria-label="Create discussion post"
151+
>
152+
<div onKeyDown={handleEditorKeyDown}>
153+
<RichTextEditor
154+
content={content}
155+
onChange={setContent}
156+
placeholder="Share an update..."
157+
ariaLabel="Discussion post content"
158+
describedBy={editorHelpId}
159+
/>
160+
</div>
122161
<div className="flex items-center justify-between">
123162
<label className="inline-flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer hover:text-purple-600 dark:hover:text-purple-400 transition-colors">
124163
<Paperclip size={16} />
@@ -128,16 +167,17 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
128167
type="file"
129168
className="hidden"
130169
multiple
170+
aria-label="Attach files to discussion post"
131171
onChange={(e) => {
132172
const fl = Array.from(e.target.files || []);
133173
setFiles((prev) => [...prev, ...fl]);
134174
}}
135175
/>
136176
</label>
137177
<button
138-
onClick={handlePost}
139178
disabled={!content || content === '<p></p>' || content.trim() === ''}
140179
className="px-4 py-2 rounded-md bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors flex items-center gap-2"
180+
type="submit"
141181
>
142182
<Send size={16} />
143183
Post
@@ -155,17 +195,21 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
155195
>
156196
{file.name}
157197
<button
198+
type="button"
158199
onClick={() => setFiles(files.filter((_, i) => i !== index))}
159200
className="hover:text-purple-900 dark:hover:text-purple-100"
201+
aria-label={`Remove ${file.name}`}
160202
>
161203
×
162204
</button>
163205
</span>
164206
))}
165207
</div>
166208
)}
167-
<p className="text-xs text-gray-500 dark:text-gray-400">Press Cmd/Ctrl + Enter to post</p>
168-
</div>
209+
<p id={editorHelpId} className="text-xs text-gray-500 dark:text-gray-400">
210+
Press Cmd/Ctrl + Enter to post
211+
</p>
212+
</form>
169213
</div>
170214
);
171215
}

src/app/components/social/__tests__/GroupDiscussionThread.test.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,24 @@ import { describe, it, expect, vi } from 'vitest';
33

44
// Mock the RichTextEditor to avoid TipTap dependency in tests
55
vi.mock('@/app/components/ui/RichTextEditor', () => ({
6-
default: ({ content, onChange }: { content: string; onChange: (v: string) => void }) => (
7-
<textarea data-testid="rte" value={content} onChange={(e) => onChange(e.target.value)} />
6+
default: ({
7+
content,
8+
onChange,
9+
ariaLabel,
10+
describedBy,
11+
}: {
12+
content: string;
13+
onChange: (v: string) => void;
14+
ariaLabel?: string;
15+
describedBy?: string;
16+
}) => (
17+
<textarea
18+
aria-label={ariaLabel}
19+
aria-describedby={describedBy}
20+
data-testid="rte"
21+
value={content}
22+
onChange={(e) => onChange(e.target.value)}
23+
/>
824
),
925
}));
1026

@@ -16,8 +32,30 @@ describe('GroupDiscussionThread', () => {
1632
render(<GroupDiscussionThread messages={[]} onPost={onPost} />);
1733

1834
fireEvent.change(screen.getByTestId('rte'), { target: { value: '<p>Hello</p>' } });
19-
fireEvent.click(screen.getByText('Post'));
35+
fireEvent.click(screen.getByRole('button', { name: 'Post' }));
2036

2137
expect(onPost).toHaveBeenCalledWith('<p>Hello</p>', undefined);
2238
});
39+
40+
it('labels the post form, editor, and message log for assistive tech', () => {
41+
render(<GroupDiscussionThread messages={[]} onPost={vi.fn()} />);
42+
43+
expect(screen.getByRole('log', { name: 'Discussion messages' })).toBeInTheDocument();
44+
expect(screen.getByRole('form', { name: 'Create discussion post' })).toBeInTheDocument();
45+
expect(screen.getByRole('textbox', { name: 'Discussion post content' })).toHaveAccessibleDescription(
46+
'Press Cmd/Ctrl + Enter to post',
47+
);
48+
expect(screen.getByRole('status')).toHaveTextContent('No messages yet');
49+
});
50+
51+
it('posts content with the documented keyboard shortcut', () => {
52+
const onPost = vi.fn();
53+
render(<GroupDiscussionThread messages={[]} onPost={onPost} />);
54+
55+
const editor = screen.getByRole('textbox', { name: 'Discussion post content' });
56+
fireEvent.change(editor, { target: { value: '<p>Keyboard post</p>' } });
57+
fireEvent.keyDown(editor, { key: 'Enter', ctrlKey: true });
58+
59+
expect(onPost).toHaveBeenCalledWith('<p>Keyboard post</p>', undefined);
60+
});
2361
});

src/app/components/ui/RichTextEditor.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@ import { useEditor, EditorContent } from '@tiptap/react';
44
import StarterKit from '@tiptap/starter-kit';
55
import Placeholder from '@tiptap/extension-placeholder';
66
import { Bold, Italic, List, ListOrdered, Code, Strikethrough } from 'lucide-react';
7-
import { useEffect } from 'react';
7+
import { useEffect, useId } from 'react';
8+
import type { ReactNode } from 'react';
89

910
interface RichTextEditorProps {
1011
content: string;
1112
onChange: (content: string) => void;
1213
placeholder?: string;
14+
ariaLabel?: string;
15+
describedBy?: string;
1316
}
1417

1518
export default function RichTextEditor({
1619
content,
1720
onChange,
1821
placeholder = 'Type your message...',
22+
ariaLabel = 'Post editor',
23+
describedBy,
1924
}: RichTextEditorProps) {
25+
const editorId = useId();
26+
const toolbarId = `${editorId}-toolbar`;
27+
2028
const editor = useEditor({
2129
extensions: [
2230
StarterKit,
@@ -32,6 +40,12 @@ export default function RichTextEditor({
3240
},
3341
editorProps: {
3442
attributes: {
43+
role: 'textbox',
44+
'aria-label': ariaLabel,
45+
'aria-describedby': [describedBy, toolbarId].filter(Boolean).join(' '),
46+
'aria-multiline': 'true',
47+
'aria-placeholder': placeholder,
48+
spellcheck: 'true',
3549
class:
3650
'prose prose-sm dark:prose-invert focus:outline-none max-w-none min-h-[44px] text-sm',
3751
},
@@ -58,7 +72,7 @@ export default function RichTextEditor({
5872
isActive: boolean;
5973
onClick: () => void;
6074
label: string;
61-
children: React.ReactNode;
75+
children: ReactNode;
6276
}) => (
6377
<button
6478
onClick={onClick}
@@ -68,6 +82,8 @@ export default function RichTextEditor({
6882
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
6983
}`}
7084
aria-label={label}
85+
aria-pressed={isActive}
86+
title={label}
7187
type="button"
7288
>
7389
{children}
@@ -77,7 +93,12 @@ export default function RichTextEditor({
7793
return (
7894
<div className="border border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 overflow-hidden transition-all duration-200 focus-within:ring-2 focus-within:ring-violet-500/30 focus-within:border-violet-300 dark:focus-within:border-violet-700">
7995
{/* Toolbar */}
80-
<div className="border-b border-gray-100 dark:border-gray-700 px-2 py-1.5 flex items-center gap-0.5 bg-gray-50/50 dark:bg-gray-800/50">
96+
<div
97+
id={toolbarId}
98+
className="border-b border-gray-100 dark:border-gray-700 px-2 py-1.5 flex items-center gap-0.5 bg-gray-50/50 dark:bg-gray-800/50"
99+
role="toolbar"
100+
aria-label={`${ariaLabel} formatting controls`}
101+
>
81102
<ToolbarButton
82103
isActive={editor.isActive('bold')}
83104
onClick={() => editor.chain().focus().toggleBold().run()}
@@ -100,7 +121,11 @@ export default function RichTextEditor({
100121
<Strikethrough size={14} />
101122
</ToolbarButton>
102123

103-
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1" />
124+
<div
125+
className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1"
126+
role="separator"
127+
aria-orientation="vertical"
128+
/>
104129

105130
<ToolbarButton
106131
isActive={editor.isActive('bulletList')}
@@ -117,7 +142,11 @@ export default function RichTextEditor({
117142
<ListOrdered size={14} />
118143
</ToolbarButton>
119144

120-
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1" />
145+
<div
146+
className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1"
147+
role="separator"
148+
aria-orientation="vertical"
149+
/>
121150

122151
<ToolbarButton
123152
isActive={editor.isActive('code')}

0 commit comments

Comments
 (0)