Skip to content

Commit 505ceeb

Browse files
feat: improve post editor accessibility (WCAG 2.1)
- Add role=toolbar + aria-label to RichContentEditor toolbar - Add aria-pressed + aria-label to ToolbarButton - Add aria-hidden to decorative toolbar dividers - Add aria-label to editor content wrapper - Add role=dialog, aria-modal, aria-labelledby to MediaEmbedder modal - Add accessible label and aria-describedby to URL input - Add role=alert + aria-live=assertive to error paragraph - Add aria-label to media trigger buttons; icons aria-hidden - Add role=list + aria-label to CollaborativeEditingTools avatar list - Add descriptive alt text and role=listitem to collaborator avatars - Add aria-label with count to active collaborators badge - Convert ContentTemplateLibrary container to aside landmark - Add descriptive aria-label to each template button; icons aria-hidden - Add aria-pressed to EditorWorkspace preview toggle button - Add 12 unit tests covering all accessibility attributes
1 parent b7dc334 commit 505ceeb

6 files changed

Lines changed: 174 additions & 19 deletions

File tree

src/app/editor/EditorWorkspace.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function EditorWorkspace() {
2626
</h1>
2727
<button
2828
onClick={() => setIsPreviewMode((prev) => !prev)}
29+
aria-pressed={isPreviewMode}
2930
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
3031
isPreviewMode
3132
? 'bg-blue-600 text-white hover:bg-blue-700'

src/components/editor/CollaborativeEditingTools.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,24 @@ const COLLABORATORS = [
1010
export const CollaborativeEditingTools: React.FC = () => {
1111
return (
1212
<div className="flex items-center gap-2">
13-
<div className="flex -space-x-2">
13+
<div className="flex -space-x-2" role="list" aria-label="Active collaborators">
1414
{COLLABORATORS.map((user) => (
1515
// eslint-disable-next-line @next/next/no-img-element
1616
<img
1717
key={user.id}
18+
role="listitem"
1819
src={user.avatar}
19-
alt={user.name}
20+
alt={`${user.name} is editing`}
2021
title={`${user.name} is editing`}
2122
className="w-8 h-8 rounded-full border-2 border-white dark:border-gray-800"
2223
/>
2324
))}
2425
</div>
25-
<span className="text-xs text-green-500 font-medium px-2 py-1 bg-green-100 dark:bg-green-900 rounded-full">
26-
3 active
26+
<span
27+
aria-label={`${COLLABORATORS.length} active collaborators`}
28+
className="text-xs text-green-500 font-medium px-2 py-1 bg-green-100 dark:bg-green-900 rounded-full"
29+
>
30+
{COLLABORATORS.length} active
2731
</span>
2832
</div>
2933
);

src/components/editor/ContentTemplateLibrary.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export const ContentTemplateLibrary: React.FC<ContentTemplateLibraryProps> = ({
2626
};
2727

2828
return (
29-
<div className="p-4 bg-gray-50 dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 h-full w-64 hidden lg:block">
29+
<aside
30+
aria-label="Content templates"
31+
className="p-4 bg-gray-50 dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 h-full w-64 hidden lg:block"
32+
>
3033
<h3 className="font-semibold text-sm text-gray-500 uppercase mb-4 tracking-wider">
3134
Templates
3235
</h3>
@@ -35,16 +38,19 @@ export const ContentTemplateLibrary: React.FC<ContentTemplateLibraryProps> = ({
3538
<button
3639
key={template.id}
3740
onClick={() => insertTemplate(editor, template.id)}
41+
aria-label={`Insert ${template.name} template: ${template.description}`}
3842
className="w-full flex items-center gap-3 p-3 text-left rounded-lg bg-white dark:bg-gray-800 hover:bg-blue-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 transition-colors group"
3943
>
40-
<div className="text-gray-500 group-hover:text-blue-500">{getIcon(template.id)}</div>
44+
<div className="text-gray-500 group-hover:text-blue-500" aria-hidden="true">
45+
{getIcon(template.id)}
46+
</div>
4147
<div>
4248
<div className="font-medium text-sm">{template.name}</div>
4349
<div className="text-xs text-gray-400 truncate w-32">{template.description}</div>
4450
</div>
4551
</button>
4652
))}
4753
</div>
48-
</div>
54+
</aside>
4955
);
5056
};

src/components/editor/MediaEmbedder.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
1212
const [url, setUrl] = useState('');
1313
const [type, setType] = useState<'image' | 'youtube'>('image');
1414
const [urlError, setUrlError] = useState('');
15+
const dialogTitleId = 'media-embedder-title';
16+
const errorId = 'media-embedder-error';
1517

1618
const handleSubmit = (e: React.FormEvent) => {
1719
e.preventDefault();
@@ -39,32 +41,43 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
3941
setIsOpen(true);
4042
}}
4143
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
44+
aria-label="Add image"
4245
title="Add Image"
4346
>
44-
<ImageIcon className="w-5 h-5" />
47+
<ImageIcon className="w-5 h-5" aria-hidden="true" />
4548
</button>
4649
<button
4750
onClick={() => {
4851
setType('youtube');
4952
setIsOpen(true);
5053
}}
5154
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
55+
aria-label="Add YouTube video"
5256
title="Add YouTube Video"
5357
>
54-
<YoutubeIcon className="w-5 h-5" />
58+
<YoutubeIcon className="w-5 h-5" aria-hidden="true" />
5559
</button>
5660
</div>
5761
);
5862
}
5963

6064
return (
61-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
65+
<div
66+
role="dialog"
67+
aria-modal="true"
68+
aria-labelledby={dialogTitleId}
69+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
70+
>
6271
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg w-96">
63-
<h3 className="text-lg font-bold mb-4">
72+
<h3 id={dialogTitleId} className="text-lg font-bold mb-4">
6473
Add {type === 'image' ? 'Image' : 'YouTube Video'}
6574
</h3>
6675
<form onSubmit={handleSubmit}>
76+
<label htmlFor="media-url" className="sr-only">
77+
{type === 'image' ? 'Image URL' : 'YouTube video URL'}
78+
</label>
6779
<input
80+
id="media-url"
6881
type="url"
6982
value={url}
7083
onChange={(e) => {
@@ -73,9 +86,17 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
7386
}}
7487
placeholder={`Enter ${type} URL...`}
7588
className="w-full p-2 border rounded mb-1 dark:bg-gray-700 dark:border-gray-600"
89+
aria-describedby={errorId}
7690
required
7791
/>
78-
<p className="text-red-500 text-sm mb-3 min-h-[1.25rem]">{urlError}</p>
92+
<p
93+
id={errorId}
94+
role="alert"
95+
aria-live="assertive"
96+
className="text-red-500 text-sm mb-3 min-h-[1.25rem]"
97+
>
98+
{urlError}
99+
</p>
79100
<div className="flex justify-end gap-2">
80101
<button
81102
type="button"

src/components/editor/RichContentEditor.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
5252
<button
5353
onClick={onClick}
5454
disabled={disabled}
55+
aria-pressed={isActive}
56+
aria-label={title}
5557
className={`p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
5658
isActive ? 'bg-gray-200 dark:bg-gray-600 text-blue-600' : 'text-gray-600 dark:text-gray-300'
5759
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
@@ -65,7 +67,11 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
6567
<div className="flex h-[calc(100vh-100px)] bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
6668
<div className="flex flex-col flex-1 w-full min-w-0">
6769
{/* Toolbar */}
68-
<div className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 overflow-x-auto">
70+
<div
71+
role="toolbar"
72+
aria-label="Text formatting"
73+
className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 overflow-x-auto"
74+
>
6975
<div className="flex items-center gap-1">
7076
<ToolbarButton
7177
onClick={() => editor.chain().focus().toggleBold().run()}
@@ -88,7 +94,7 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
8894
>
8995
<Strikethrough className="w-4 h-4" />
9096
</ToolbarButton>
91-
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" />
97+
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" aria-hidden="true" />
9298
<ToolbarButton
9399
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
94100
isActive={editor.isActive('heading', { level: 1 })}
@@ -103,7 +109,7 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
103109
>
104110
<Heading2 className="w-4 h-4" />
105111
</ToolbarButton>
106-
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" />
112+
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" aria-hidden="true" />
107113
<ToolbarButton
108114
onClick={() => editor.chain().focus().toggleBulletList().run()}
109115
isActive={editor.isActive('bulletList')}
@@ -118,7 +124,7 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
118124
>
119125
<ListOrdered className="w-4 h-4" />
120126
</ToolbarButton>
121-
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" />
127+
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" aria-hidden="true" />
122128
<ToolbarButton
123129
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
124130
isActive={editor.isActive('codeBlock')}
@@ -133,9 +139,9 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
133139
>
134140
<Quote className="w-4 h-4" />
135141
</ToolbarButton>
136-
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" />
142+
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" aria-hidden="true" />
137143
<MediaEmbedder onAddImage={addImage} onAddYoutube={addYoutubeVideo} />
138-
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" />
144+
<div className="w-px h-6 bg-gray-300 dark:bg-gray-700 mx-1" aria-hidden="true" />
139145
<ToolbarButton
140146
onClick={() => editor.chain().focus().undo().run()}
141147
disabled={!editor.can().undo()}
@@ -158,7 +164,10 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
158164
</div>
159165

160166
{/* Editor Content */}
161-
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-800">
167+
<div
168+
className="flex-1 overflow-y-auto bg-white dark:bg-gray-800"
169+
aria-label="Post content editor"
170+
>
162171
<EditorContent editor={editor} className="h-full p-8" />
163172
</div>
164173
</div>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { CollaborativeEditingTools } from '../CollaborativeEditingTools';
5+
import { MediaEmbedder } from '../MediaEmbedder';
6+
import { ContentTemplateLibrary } from '../ContentTemplateLibrary';
7+
8+
// ─── CollaborativeEditingTools ────────────────────────────────────────────────
9+
10+
describe('CollaborativeEditingTools accessibility', () => {
11+
it('renders collaborator list with role=list and aria-label', () => {
12+
render(<CollaborativeEditingTools />);
13+
const list = screen.getByRole('list', { name: /active collaborators/i });
14+
expect(list).toBeInTheDocument();
15+
});
16+
17+
it('renders each collaborator avatar as a listitem with descriptive alt', () => {
18+
render(<CollaborativeEditingTools />);
19+
const items = screen.getAllByRole('listitem');
20+
expect(items.length).toBeGreaterThan(0);
21+
items.forEach((item) => {
22+
expect(item).toHaveAttribute('alt', expect.stringMatching(/is editing/i));
23+
});
24+
});
25+
26+
it('status badge has aria-label with collaborator count', () => {
27+
render(<CollaborativeEditingTools />);
28+
const badge = screen.getByLabelText(/3 active collaborators/i);
29+
expect(badge).toBeInTheDocument();
30+
});
31+
});
32+
33+
// ─── MediaEmbedder ────────────────────────────────────────────────────────────
34+
35+
describe('MediaEmbedder accessibility', () => {
36+
const noop = vi.fn();
37+
38+
it('image trigger button has aria-label', () => {
39+
render(<MediaEmbedder onAddImage={noop} onAddYoutube={noop} />);
40+
expect(screen.getByRole('button', { name: /add image/i })).toBeInTheDocument();
41+
});
42+
43+
it('youtube trigger button has aria-label', () => {
44+
render(<MediaEmbedder onAddImage={noop} onAddYoutube={noop} />);
45+
expect(screen.getByRole('button', { name: /add youtube video/i })).toBeInTheDocument();
46+
});
47+
48+
it('dialog has role=dialog, aria-modal, and aria-labelledby when open', () => {
49+
render(<MediaEmbedder onAddImage={noop} onAddYoutube={noop} />);
50+
fireEvent.click(screen.getByRole('button', { name: /add image/i }));
51+
52+
const dialog = screen.getByRole('dialog');
53+
expect(dialog).toHaveAttribute('aria-modal', 'true');
54+
expect(dialog).toHaveAttribute('aria-labelledby');
55+
56+
const titleId = dialog.getAttribute('aria-labelledby')!;
57+
expect(document.getElementById(titleId)).toBeInTheDocument();
58+
});
59+
60+
it('URL input has an accessible label', () => {
61+
render(<MediaEmbedder onAddImage={noop} onAddYoutube={noop} />);
62+
fireEvent.click(screen.getByRole('button', { name: /add image/i }));
63+
expect(screen.getByLabelText(/image url/i)).toBeInTheDocument();
64+
});
65+
66+
it('error paragraph has role=alert and aria-live=assertive', () => {
67+
render(<MediaEmbedder onAddImage={noop} onAddYoutube={noop} />);
68+
fireEvent.click(screen.getByRole('button', { name: /add image/i }));
69+
70+
const errorEl = screen.getByRole('alert');
71+
expect(errorEl).toHaveAttribute('aria-live', 'assertive');
72+
});
73+
74+
it('input is described by the error element', () => {
75+
render(<MediaEmbedder onAddImage={noop} onAddYoutube={noop} />);
76+
fireEvent.click(screen.getByRole('button', { name: /add image/i }));
77+
78+
const input = screen.getByLabelText(/image url/i);
79+
const errorEl = screen.getByRole('alert');
80+
expect(input).toHaveAttribute('aria-describedby', errorEl.id);
81+
});
82+
});
83+
84+
// ─── ContentTemplateLibrary ───────────────────────────────────────────────────
85+
86+
describe('ContentTemplateLibrary accessibility', () => {
87+
const mockEditor = {
88+
chain: () => ({ focus: () => ({ insertContent: () => ({ run: vi.fn() }) }) }),
89+
} as unknown as import('@tiptap/react').Editor;
90+
91+
it('renders as an aside with aria-label', () => {
92+
render(<ContentTemplateLibrary editor={mockEditor} />);
93+
const sidebar = screen.getByRole('complementary', { name: /content templates/i });
94+
expect(sidebar).toBeInTheDocument();
95+
});
96+
97+
it('each template button has a descriptive aria-label', () => {
98+
render(<ContentTemplateLibrary editor={mockEditor} />);
99+
const buttons = screen.getAllByRole('button');
100+
buttons.forEach((btn) => {
101+
const label = btn.getAttribute('aria-label') ?? '';
102+
expect(label.length).toBeGreaterThan(0);
103+
expect(label.toLowerCase()).toContain('insert');
104+
});
105+
});
106+
107+
it('template icons are hidden from assistive technology', () => {
108+
render(<ContentTemplateLibrary editor={mockEditor} />);
109+
// Icon wrappers should carry aria-hidden="true"
110+
const hiddenEls = document
111+
.querySelectorAll('[aria-hidden="true"]');
112+
expect(hiddenEls.length).toBeGreaterThan(0);
113+
});
114+
});

0 commit comments

Comments
 (0)