{
+ 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) => (
+
);
export default MessageTimestamp;
diff --git a/packages/fuselage/src/components/Message/Messages.styles.scss b/packages/fuselage/src/components/Message/Messages.styles.scss
index 91360aa3a8..b47dad906b 100644
--- a/packages/fuselage/src/components/Message/Messages.styles.scss
+++ b/packages/fuselage/src/components/Message/Messages.styles.scss
@@ -4,6 +4,7 @@
@use '../../styles/mixins/size.scss';
@use '../../styles/mixins/templates.scss';
@use '../../styles/typography.scss';
+@use '../../styles/mixins/breakpoints.scss';
@use './mixins.scss';
@import './MessageMetrics/MessageMetrics.styles.scss';
@@ -96,7 +97,7 @@ $message-highlight-colors-background-other-color: theme(
padding-block: lengths.padding(8) lengths.padding(4);
- padding-inline: lengths.padding(20);
+ padding-inline: clamp(lengths.padding(8), 3vw, lengths.padding(20));
// background-color: $message-background-color;
@@ -143,6 +144,20 @@ $message-highlight-colors-background-other-color: theme(
cursor: pointer;
}
+ // Responsive layout adjustments
+ @include breakpoints.on-breakpoint(xs) {
+ flex-direction: column;
+ padding-inline: lengths.padding(8);
+
+ .rcx-message-left-container {
+ margin-bottom: lengths.margin(2);
+ }
+ }
+
+ @include breakpoints.on-breakpoint(sm) {
+ flex-direction: row;
+ }
+
&-header {
@extend %rcx-margins-block;
display: flex;
@@ -226,6 +241,10 @@ $message-highlight-colors-background-other-color: theme(
color: colors.font(default);
+ // Responsive font size
+ font-size: clamp(0.875rem, 2.5vw, 1rem);
+ line-height: 1.5;
+
& h1 {
@include typography.use-font-scale(h1);
}