Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Use **assertive** only for urgent errors or time-sensitive status.

- 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.
- 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).
- 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.

## What automation does _not_ prove

Expand Down
7 changes: 5 additions & 2 deletions src/app/components/messaging/MessageComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

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

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

const handleContentChange = useCallback(
(newContent: string) => {
Expand Down Expand Up @@ -161,6 +162,8 @@ export default function MessageComposer({
content={content}
onChange={handleContentChange}
placeholder="Type your message..."
ariaLabel="Message content"
describedBy={editorHelpId}
/>
</div>

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

{/* Helper Text */}
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 ml-12">
<p id={editorHelpId} className="text-[10px] text-gray-400 dark:text-gray-500 mt-2 ml-12">
Press{' '}
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-[10px] font-mono">
Enter
Expand Down
72 changes: 58 additions & 14 deletions src/app/components/social/GroupDiscussionThread.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

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

const attachments = useMemo<Attachment[]>(
() =>
Expand Down Expand Up @@ -56,37 +57,60 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
}
};

const handleEditorKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
handlePost();
}
};

return (
<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">
<div className="flex-1 overflow-y-auto space-y-4 p-4">
<div
className="flex-1 overflow-y-auto space-y-4 p-4"
role="log"
aria-label="Discussion messages"
aria-live="polite"
aria-relevant="additions text"
>
{messages.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-center py-8 text-gray-500 dark:text-gray-400" role="status">
<p className="text-sm">No messages yet. Start the conversation!</p>
</div>
) : (
<AnimatePresence>
{messages.map((m, index) => (
<motion.div
<motion.article
key={m.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="flex items-start gap-3 group"
aria-labelledby={`message-${m.id}-sender`}
>
{/* Avatar */}
<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">
<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"
aria-hidden="true"
>
{getInitials(m.senderName)}
</div>

{/* Message Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-50">
<span
id={`message-${m.id}-sender`}
className="text-sm font-medium text-gray-900 dark:text-gray-50"
>
{m.senderName}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
<time
className="text-xs text-gray-500 dark:text-gray-400"
dateTime={new Date(m.createdAt).toISOString()}
>
{formatDistanceToNow(new Date(m.createdAt), { addSuffix: true })}
</span>
</time>
</div>
<div
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"
Expand All @@ -109,16 +133,31 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
</div>
)}
</div>
</motion.div>
</motion.article>
))}
</AnimatePresence>
)}
<div ref={messagesEndRef} />
</div>

{/* Message Input */}
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-3 bg-gray-50 dark:bg-gray-900/50">
<RichTextEditor content={content} onChange={setContent} placeholder="Share an update..." />
<form
className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-3 bg-gray-50 dark:bg-gray-900/50"
onSubmit={(event) => {
event.preventDefault();
handlePost();
}}
aria-label="Create discussion post"
>
<div onKeyDown={handleEditorKeyDown}>
<RichTextEditor
content={content}
onChange={setContent}
placeholder="Share an update..."
ariaLabel="Discussion post content"
describedBy={editorHelpId}
/>
</div>
<div className="flex items-center justify-between">
<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">
<Paperclip size={16} />
Expand All @@ -128,16 +167,17 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
type="file"
className="hidden"
multiple
aria-label="Attach files to discussion post"
onChange={(e) => {
const fl = Array.from(e.target.files || []);
setFiles((prev) => [...prev, ...fl]);
}}
/>
</label>
<button
onClick={handlePost}
disabled={!content || content === '<p></p>' || content.trim() === ''}
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"
type="submit"
>
<Send size={16} />
Post
Expand All @@ -155,17 +195,21 @@ export default function GroupDiscussionThread({ messages, onPost }: GroupDiscuss
>
{file.name}
<button
type="button"
onClick={() => setFiles(files.filter((_, i) => i !== index))}
className="hover:text-purple-900 dark:hover:text-purple-100"
aria-label={`Remove ${file.name}`}
>
×
</button>
</span>
))}
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">Press Cmd/Ctrl + Enter to post</p>
</div>
<p id={editorHelpId} className="text-xs text-gray-500 dark:text-gray-400">
Press Cmd/Ctrl + Enter to post
</p>
</form>
</div>
);
}
44 changes: 41 additions & 3 deletions src/app/components/social/__tests__/GroupDiscussionThread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,24 @@ import { describe, it, expect, vi } from 'vitest';

// Mock the RichTextEditor to avoid TipTap dependency in tests
vi.mock('@/app/components/ui/RichTextEditor', () => ({
default: ({ content, onChange }: { content: string; onChange: (v: string) => void }) => (
<textarea data-testid="rte" value={content} onChange={(e) => onChange(e.target.value)} />
default: ({
content,
onChange,
ariaLabel,
describedBy,
}: {
content: string;
onChange: (v: string) => void;
ariaLabel?: string;
describedBy?: string;
}) => (
<textarea
aria-label={ariaLabel}
aria-describedby={describedBy}
data-testid="rte"
value={content}
onChange={(e) => onChange(e.target.value)}
/>
),
}));

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

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

expect(onPost).toHaveBeenCalledWith('<p>Hello</p>', undefined);
});

it('labels the post form, editor, and message log for assistive tech', () => {
render(<GroupDiscussionThread messages={[]} onPost={vi.fn()} />);

expect(screen.getByRole('log', { name: 'Discussion messages' })).toBeInTheDocument();
expect(screen.getByRole('form', { name: 'Create discussion post' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Discussion post content' })).toHaveAccessibleDescription(
'Press Cmd/Ctrl + Enter to post',
);
expect(screen.getByRole('status')).toHaveTextContent('No messages yet');
});

it('posts content with the documented keyboard shortcut', () => {
const onPost = vi.fn();
render(<GroupDiscussionThread messages={[]} onPost={onPost} />);

const editor = screen.getByRole('textbox', { name: 'Discussion post content' });
fireEvent.change(editor, { target: { value: '<p>Keyboard post</p>' } });
fireEvent.keyDown(editor, { key: 'Enter', ctrlKey: true });

expect(onPost).toHaveBeenCalledWith('<p>Keyboard post</p>', undefined);
});
});
39 changes: 34 additions & 5 deletions src/app/components/ui/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,27 @@ import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { Bold, Italic, List, ListOrdered, Code, Strikethrough } from 'lucide-react';
import { useEffect } from 'react';
import { useEffect, useId } from 'react';
import type { ReactNode } from 'react';

interface RichTextEditorProps {
content: string;
onChange: (content: string) => void;
placeholder?: string;
ariaLabel?: string;
describedBy?: string;
}

export default function RichTextEditor({
content,
onChange,
placeholder = 'Type your message...',
ariaLabel = 'Post editor',
describedBy,
}: RichTextEditorProps) {
const editorId = useId();
const toolbarId = `${editorId}-toolbar`;

const editor = useEditor({
extensions: [
StarterKit,
Expand All @@ -32,6 +40,12 @@ export default function RichTextEditor({
},
editorProps: {
attributes: {
role: 'textbox',
'aria-label': ariaLabel,
'aria-describedby': [describedBy, toolbarId].filter(Boolean).join(' '),
'aria-multiline': 'true',
'aria-placeholder': placeholder,
spellcheck: 'true',
class:
'prose prose-sm dark:prose-invert focus:outline-none max-w-none min-h-[44px] text-sm',
},
Expand All @@ -58,7 +72,7 @@ export default function RichTextEditor({
isActive: boolean;
onClick: () => void;
label: string;
children: React.ReactNode;
children: ReactNode;
}) => (
<button
onClick={onClick}
Expand All @@ -68,6 +82,8 @@ export default function RichTextEditor({
: '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'
}`}
aria-label={label}
aria-pressed={isActive}
title={label}
type="button"
>
{children}
Expand All @@ -77,7 +93,12 @@ export default function RichTextEditor({
return (
<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">
{/* Toolbar */}
<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">
<div
id={toolbarId}
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"
role="toolbar"
aria-label={`${ariaLabel} formatting controls`}
>
<ToolbarButton
isActive={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
Expand All @@ -100,7 +121,11 @@ export default function RichTextEditor({
<Strikethrough size={14} />
</ToolbarButton>

<div className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1" />
<div
className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1"
role="separator"
aria-orientation="vertical"
/>

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

<div className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1" />
<div
className="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1"
role="separator"
aria-orientation="vertical"
/>

<ToolbarButton
isActive={editor.isActive('code')}
Expand Down
Loading
Loading