diff --git a/packages/fuselage/src/components/Message/Message.spec.tsx b/packages/fuselage/src/components/Message/Message.spec.tsx index 575844b9c5..dc361dd876 100644 --- a/packages/fuselage/src/components/Message/Message.spec.tsx +++ b/packages/fuselage/src/components/Message/Message.spec.tsx @@ -1,4 +1,5 @@ import { composeStories } from '@storybook/react-webpack5'; +import { axe } from 'jest-axe'; import { render } from '../../testing'; @@ -10,4 +11,69 @@ describe('[Message Component]', () => { it('renders without crashing', () => { render(); }); + + describe('Accessibility', () => { + it('should have no a11y violations', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have role="article" on message container', () => { + const { container } = render(); + const message = container.querySelector('.rcx-message'); + expect(message).toHaveAttribute('role', 'article'); + }); + + it('should have aria-label on message container', () => { + const { container } = render(); + const message = container.querySelector('.rcx-message'); + expect(message).toHaveAttribute('aria-label'); + }); + + it('should be keyboard accessible when clickable', () => { + const onClick = jest.fn(); + const { container } = render( + + ); + const message = container.querySelector('.rcx-message'); + expect(message).toHaveAttribute('tabIndex', '0'); + }); + + it('should indicate pending state with aria-busy', () => { + const { container } = render(); + const message = container.querySelector('.rcx-message'); + expect(message).toHaveAttribute('aria-busy', 'true'); + }); + + it('should indicate selected state with aria-selected', () => { + const { container } = render(); + const message = container.querySelector('.rcx-message'); + expect(message).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('Keyboard Navigation', () => { + it('should be focusable when clickable', () => { + const { container } = render(); + const message = container.querySelector('.rcx-message'); + expect(message).toHaveAttribute('tabIndex', '0'); + }); + + it('should not be focusable when not clickable', () => { + const { container } = render(); + const message = container.querySelector('.rcx-message'); + expect(message).not.toHaveAttribute('tabIndex'); + }); + }); + + describe('ARIA Attributes', () => { + it('should support custom aria-label', () => { + const { container } = render( + + ); + const message = container.querySelector('.rcx-message'); + expect(message).toHaveAttribute('aria-label', 'Custom message label'); + }); + }); }); diff --git a/packages/fuselage/src/components/Message/Message.tsx b/packages/fuselage/src/components/Message/Message.tsx index 5bdb8db46a..58683b981c 100644 --- a/packages/fuselage/src/components/Message/Message.tsx +++ b/packages/fuselage/src/components/Message/Message.tsx @@ -28,6 +28,7 @@ export type MessageProps = AllHTMLAttributes & { isEditing?: boolean; isPending?: boolean; highlight?: boolean; + 'aria-label'?: string; }; const Message = Object.assign( @@ -40,6 +41,7 @@ const Message = Object.assign( isEditing, isPending, highlight, + 'aria-label': ariaLabel, ...props }, ref, @@ -47,6 +49,11 @@ const Message = Object.assign( return (
{ + it('renders without crashing', () => { + render(Test content); + }); + + describe('Accessibility', () => { + it('should have no a11y violations', async () => { + const { container } = render( + Test message content + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have role="region"', () => { + const { container } = render( + Test content + ); + const body = container.querySelector('.rcx-message-body'); + expect(body).toHaveAttribute('role', 'region'); + }); + + it('should have aria-label', () => { + const { container } = render( + Test content + ); + const body = container.querySelector('.rcx-message-body'); + expect(body).toHaveAttribute('aria-label', 'Message content'); + }); + + it('should apply clamp class when clamp prop is provided', () => { + const { container } = render( + Test content + ); + const body = container.querySelector('.rcx-message-body'); + expect(body).toHaveClass('rcx-message-body--clamp'); + expect(body).toHaveClass('rcx-message-body--clamp-2'); + }); + + it('should handle different clamp values', () => { + const { container: container2 } = render( + Test content + ); + const body2 = container2.querySelector('.rcx-message-body'); + expect(body2).toHaveClass('rcx-message-body--clamp-3'); + + const { container: container3 } = render( + Test content + ); + const body3 = container3.querySelector('.rcx-message-body'); + expect(body3).toHaveClass('rcx-message-body--clamp-4'); + }); + }); +}); diff --git a/packages/fuselage/src/components/Message/MessageBody.tsx b/packages/fuselage/src/components/Message/MessageBody.tsx index 12156bdf54..f9396197ca 100644 --- a/packages/fuselage/src/components/Message/MessageBody.tsx +++ b/packages/fuselage/src/components/Message/MessageBody.tsx @@ -8,6 +8,8 @@ export type MessageBodyProps = HTMLAttributes & { const MessageBody = ({ clamp, className, ...props }: MessageBodyProps) => (
{ + it('renders without crashing', () => { + render(Test header); + }); + + describe('Accessibility', () => { + it('should have no a11y violations', async () => { + const { container } = render( + Test header content + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have role="group"', () => { + const { container } = render( + Test header + ); + const header = container.querySelector('.rcx-message-header'); + expect(header).toHaveAttribute('role', 'group'); + }); + + it('should have aria-label', () => { + const { container } = render( + Test header + ); + const header = container.querySelector('.rcx-message-header'); + expect(header).toHaveAttribute('aria-label', 'Message header'); + }); + + it('should render children correctly', () => { + const { getByText } = render( + + Author Name + Timestamp + + ); + expect(getByText('Author Name')).toBeInTheDocument(); + expect(getByText('Timestamp')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/fuselage/src/components/Message/MessageHeader.tsx b/packages/fuselage/src/components/Message/MessageHeader.tsx index 22945dbb5c..de43fabe3b 100644 --- a/packages/fuselage/src/components/Message/MessageHeader.tsx +++ b/packages/fuselage/src/components/Message/MessageHeader.tsx @@ -3,7 +3,12 @@ import type { HTMLAttributes } from 'react'; export type MessageHeaderProps = HTMLAttributes; const MessageHeader = ({ children, ...props }: MessageHeaderProps) => ( -
+
{children}
diff --git a/packages/fuselage/src/components/Message/MessageMetrics/MessageMetrics.tsx b/packages/fuselage/src/components/Message/MessageMetrics/MessageMetrics.tsx index f70ac03508..4c1e80b0e7 100644 --- a/packages/fuselage/src/components/Message/MessageMetrics/MessageMetrics.tsx +++ b/packages/fuselage/src/components/Message/MessageMetrics/MessageMetrics.tsx @@ -10,7 +10,12 @@ export type MessageMetricsProps = HTMLAttributes; const MessageMetrics = Object.assign( (props: MessageMetricsProps) => ( -
+
), { diff --git a/packages/fuselage/src/components/Message/MessageReactions/MessageReaction.spec.tsx b/packages/fuselage/src/components/Message/MessageReactions/MessageReaction.spec.tsx new file mode 100644 index 0000000000..f26d142493 --- /dev/null +++ b/packages/fuselage/src/components/Message/MessageReactions/MessageReaction.spec.tsx @@ -0,0 +1,176 @@ +import { axe } from 'jest-axe'; +import { fireEvent } from '@testing-library/react'; + +import { render } from '../../../testing'; + +import { MessageReaction } from './MessageReaction'; +import { MessageReactionAction } from './MessageReactionAction'; + +describe('[MessageReaction Component]', () => { + it('renders without crashing', () => { + render(); + }); + + describe('Accessibility', () => { + it('should have no a11y violations', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have role="button"', () => { + const { container } = render(); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + expect(reaction).toHaveAttribute('role', 'button'); + }); + + it('should be keyboard accessible', () => { + const { container } = render(); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + expect(reaction).toHaveAttribute('tabIndex', '0'); + }); + + it('should have descriptive aria-label', () => { + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + expect(reaction).toHaveAttribute('aria-label', 'smile, 5 reactions'); + }); + + it('should indicate when user has reacted', () => { + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + expect(reaction).toHaveAttribute('aria-label', 'smile, 5 reactions, you reacted'); + expect(reaction).toHaveAttribute('aria-pressed', 'true'); + }); + + it('should have aria-pressed false when user has not reacted', () => { + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + expect(reaction).toHaveAttribute('aria-pressed', 'false'); + }); + }); + + describe('Keyboard Navigation', () => { + it('should trigger onClick when Enter key is pressed', () => { + const onClick = jest.fn(); + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + + fireEvent.keyDown(reaction!, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should trigger onClick when Space key is pressed', () => { + const onClick = jest.fn(); + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + + fireEvent.keyDown(reaction!, { key: ' ' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should not trigger onClick for other keys', () => { + const onClick = jest.fn(); + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + + fireEvent.keyDown(reaction!, { key: 'a' }); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should call custom onKeyDown handler', () => { + const onKeyDown = jest.fn(); + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + + fireEvent.keyDown(reaction!, { key: 'Tab' }); + expect(onKeyDown).toHaveBeenCalledTimes(1); + }); + }); + + describe('Visual States', () => { + it('should apply mine class when user has reacted', () => { + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + expect(reaction).toHaveClass('rcx-message-reactions__reaction--mine'); + }); + + it('should not apply mine class when user has not reacted', () => { + const { container } = render( + + ); + const reaction = container.querySelector('.rcx-message-reactions__reaction'); + expect(reaction).not.toHaveClass('rcx-message-reactions__reaction--mine'); + }); + }); +}); + +describe('[MessageReactionAction Component]', () => { + it('renders without crashing', () => { + render(); + }); + + describe('Accessibility', () => { + it('should have no a11y violations', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have role="button"', () => { + const { container } = render(); + const action = container.querySelector('.rcx-message-reactions__reaction--action'); + expect(action).toHaveAttribute('role', 'button'); + }); + + it('should be keyboard accessible', () => { + const { container } = render(); + const action = container.querySelector('.rcx-message-reactions__reaction--action'); + expect(action).toHaveAttribute('tabIndex', '0'); + }); + + it('should have descriptive aria-label', () => { + const { container } = render(); + const action = container.querySelector('.rcx-message-reactions__reaction--action'); + expect(action).toHaveAttribute('aria-label', 'Add reaction'); + }); + }); + + describe('Keyboard Navigation', () => { + it('should trigger onClick when Enter key is pressed', () => { + const onClick = jest.fn(); + const { container } = render(); + const action = container.querySelector('.rcx-message-reactions__reaction--action'); + + fireEvent.keyDown(action!, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should trigger onClick when Space key is pressed', () => { + const onClick = jest.fn(); + const { container } = render(); + const action = container.querySelector('.rcx-message-reactions__reaction--action'); + + fireEvent.keyDown(action!, { key: ' ' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/fuselage/src/components/Message/MessageReactions/MessageReaction.tsx b/packages/fuselage/src/components/Message/MessageReactions/MessageReaction.tsx index b7e6830280..e1743fd3c8 100644 --- a/packages/fuselage/src/components/Message/MessageReactions/MessageReaction.tsx +++ b/packages/fuselage/src/components/Message/MessageReactions/MessageReaction.tsx @@ -13,9 +13,17 @@ type MessageReactionProps = { export const MessageReaction = forwardRef( function Reaction( - { name, counter, mine, children, className, ...props }, + { name, counter, mine, children, className, onClick, onKeyDown, ...props }, ref, ) { + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick?.(event as any); + } + onKeyDown?.(event); + }; + return (
( ref={ref} role='button' tabIndex={0} + aria-label={`${name || 'Reaction'}${counter ? `, ${counter} reactions` : ''}${mine ? ', you reacted' : ''}`} + aria-pressed={mine} + onClick={onClick} + onKeyDown={handleKeyDown} {...props} > {children || ( diff --git a/packages/fuselage/src/components/Message/MessageReactions/MessageReactionAction.tsx b/packages/fuselage/src/components/Message/MessageReactions/MessageReactionAction.tsx index 9eb6567116..f20937b544 100644 --- a/packages/fuselage/src/components/Message/MessageReactions/MessageReactionAction.tsx +++ b/packages/fuselage/src/components/Message/MessageReactions/MessageReactionAction.tsx @@ -4,19 +4,34 @@ import { Icon } from '../../Icon'; export const MessageReactionAction = ({ className, + onClick, + onKeyDown, ...props -}: HTMLAttributes) => ( -
- -
-); +}: HTMLAttributes) => { + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick?.(event as any); + } + onKeyDown?.(event); + }; + + return ( +
+ +
+ ); +}; diff --git a/packages/fuselage/src/components/Message/MessageReactions/MessageReactions.tsx b/packages/fuselage/src/components/Message/MessageReactions/MessageReactions.tsx index df0fbd5d9d..10011b97fa 100644 --- a/packages/fuselage/src/components/Message/MessageReactions/MessageReactions.tsx +++ b/packages/fuselage/src/components/Message/MessageReactions/MessageReactions.tsx @@ -12,6 +12,8 @@ const MessageReactions = forwardRef(
diff --git a/packages/fuselage/src/components/Message/MessageTimestamp.spec.tsx b/packages/fuselage/src/components/Message/MessageTimestamp.spec.tsx new file mode 100644 index 0000000000..45af6c222d --- /dev/null +++ b/packages/fuselage/src/components/Message/MessageTimestamp.spec.tsx @@ -0,0 +1,57 @@ +import { axe } from 'jest-axe'; + +import { render } from '../../testing'; + +import MessageTimestamp from './MessageTimestamp'; + +describe('[MessageTimestamp Component]', () => { + it('renders without crashing', () => { + render(10:30 AM); + }); + + describe('Accessibility', () => { + it('should have no a11y violations', async () => { + const { container } = render( + + 10:30 AM + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should use semantic time element', () => { + const { container } = render( + 10:30 AM + ); + const timestamp = container.querySelector('time'); + expect(timestamp).toBeInTheDocument(); + }); + + it('should have datetime attribute when provided', () => { + const dateTime = '2026-01-04T10:30:00Z'; + const { container } = render( + 10:30 AM + ); + const timestamp = container.querySelector('time'); + expect(timestamp).toHaveAttribute('dateTime', dateTime); + }); + + it('should render readable time text', () => { + const { getByText } = render( + + 10:30 AM + + ); + expect(getByText('10:30 AM')).toBeInTheDocument(); + }); + + it('should apply correct CSS classes', () => { + const { container } = render( + 10:30 AM + ); + const timestamp = container.querySelector('time'); + expect(timestamp).toHaveClass('rcx-message-header__time'); + }); + }); +}); diff --git a/packages/fuselage/src/components/Message/MessageTimestamp.tsx b/packages/fuselage/src/components/Message/MessageTimestamp.tsx index 0ebef09060..d88c5fd215 100644 --- a/packages/fuselage/src/components/Message/MessageTimestamp.tsx +++ b/packages/fuselage/src/components/Message/MessageTimestamp.tsx @@ -1,9 +1,16 @@ import type { HTMLAttributes } from 'react'; -export type MessageTimestampProps = HTMLAttributes; +export type MessageTimestampProps = HTMLAttributes & { + /** ISO 8601 timestamp for screen readers */ + dateTime?: string; +}; -const MessageTimestamp = (props: MessageTimestampProps) => ( - +const MessageTimestamp = ({ dateTime, ...props }: MessageTimestampProps) => ( +