Skip to content
Open
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
64 changes: 64 additions & 0 deletions packages/react/src/hooks/useKeyboardNav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useCallback } from 'react';

/**
* Returns an onKeyDown handler that enables arrow-key navigation
* between focusable siblings inside a toolbar or menu container.
*
* @param {object} options
* @param {string} options.selector - CSS selector for focusable items (default: '[role="button"],button,a')
* @param {'horizontal'|'vertical'|'both'} options.direction - navigation axis (default: 'horizontal')
* @param {Function} options.onEscape - called when Escape is pressed
* @returns {Function} onKeyDown handler
*/
const useKeyboardNav = ({
selector = '[role="button"],button,a,[tabindex="0"]',
direction = 'horizontal',
onEscape,
} = {}) => {
const handleKeyDown = useCallback(
(e) => {
const container = e.currentTarget;
const items = Array.from(container.querySelectorAll(selector)).filter(
(el) => !el.disabled && el.getAttribute('aria-disabled') !== 'true'
);

if (!items.length) return;

const currentIndex = items.indexOf(document.activeElement);

const goNext =
(direction === 'horizontal' && e.key === 'ArrowRight') ||
(direction === 'vertical' && e.key === 'ArrowDown') ||
direction === 'both' && (e.key === 'ArrowRight' || e.key === 'ArrowDown');

const goPrev =
(direction === 'horizontal' && e.key === 'ArrowLeft') ||
(direction === 'vertical' && e.key === 'ArrowUp') ||
direction === 'both' && (e.key === 'ArrowLeft' || e.key === 'ArrowUp');

if (goNext) {
e.preventDefault();
const nextIndex = (currentIndex + 1) % items.length;
items[nextIndex].focus();
} else if (goPrev) {
e.preventDefault();
const prevIndex = (currentIndex - 1 + items.length) % items.length;
items[prevIndex].focus();
} else if (e.key === 'Escape' && onEscape) {
e.preventDefault();
onEscape();
} else if (e.key === 'Home') {
e.preventDefault();
items[0].focus();
} else if (e.key === 'End') {
e.preventDefault();
items[items.length - 1].focus();
}
},
[selector, direction, onEscape]
);

return handleKeyDown;
};

export default useKeyboardNav;
1 change: 1 addition & 0 deletions packages/react/src/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as EmbeddedChat } from './views/EmbeddedChat';
export { default as useKeyboardNav } from './hooks/useKeyboardNav';
76 changes: 76 additions & 0 deletions packages/react/src/stories/EmbeddedChatAccessibility.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { EmbeddedChat } from '..';

export default {
title: 'EmbeddedChat/Accessibility (WCAG 2.1)',
component: EmbeddedChat,
parameters: {
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'label', enabled: true },
{ id: 'button-name', enabled: true },
{ id: 'aria-required-attr', enabled: true },
{ id: 'aria-roles', enabled: true },
],
},
},
},
};

/**
* Full WCAG 2.1 AA accessible EmbeddedChat.
*
* What's covered in this build:
* - Skip-to-content link (visible on Tab key press)
* - Chat textarea: aria-label, aria-multiline, aria-disabled
* - Send button: aria-label="Send message"
* - Formatting toolbar: role="toolbar", arrow-key navigation (useKeyboardNav)
* - All toolbar buttons: aria-label (emoji, file, link, formatters, more)
* - More button: aria-expanded, aria-haspopup
* - Message toolbox: role="toolbar", arrow-key navigation
* - AudioMessageRecorder: aria-label on record/stop/cancel, role="timer" + aria-live
* - VideoMessageRecorder: aria-label on all controls, role="timer" + aria-live
* - MessageReactions: role="button", aria-pressed, aria-label, keyboard Enter/Space
* - EmojiPicker: role="dialog", aria-modal, aria-label
* - ChatHeader: role="banner", aria-label
* - LoginForm: htmlFor/id label pairing, aria-required, aria-invalid, aria-describedby
* - Login error messages: role="alert"
* - Password toggle: dynamic aria-label (Show/Hide password)
*
* Test keyboard navigation:
* Tab — move between interactive elements
* Arrow keys — navigate within toolbars
* Enter/Space — activate buttons and reactions
* Escape — close menus
*/
export const AccessibleChat = {
args: {
host: process.env.STORYBOOK_RC_HOST || 'http://localhost:3000',
roomId: process.env.RC_ROOM_ID || 'GENERAL',
channelName: 'general',
anonymousMode: false,
showRoles: true,
showUsername: true,
enableThreads: true,
hideHeader: false,
auth: { flow: 'PASSWORD' },
dark: false,
},
};

export const AccessibleChatDark = {
args: {
...AccessibleChat.args,
dark: true,
channelName: 'general (dark)',
},
};

export const AccessibleChatAnonymous = {
args: {
...AccessibleChat.args,
anonymousMode: true,
channelName: 'general (anonymous)',
},
};
2 changes: 2 additions & 0 deletions packages/react/src/views/ChatHeader/ChatHeader.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ const ChatHeader = ({
css={styles.chatHeaderParent}
className={`ec-chat-header ${classNames} ${className}`}
style={{ ...styleOverrides, ...style }}
role="banner"
aria-label={`Chat header for ${channelInfo.name || channelName || 'channel'}`}
>
<Box css={styles.chatHeaderChild}>
<Box css={styles.channelDescription}>
Expand Down
7 changes: 4 additions & 3 deletions packages/react/src/views/ChatInput/AudioMessageRecorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ const AudioMessageRecorder = (props) => {
ghost
square
disabled={disabled}
aria-label="Record audio message"
onClick={handleRecordButtonClick}
>
<Icon size="1.25rem" name="mic" />
Expand All @@ -166,18 +167,18 @@ const AudioMessageRecorder = (props) => {
{state === 'recording' && (
<>
<Tooltip text="Cancel Recording" position="top">
<ActionButton ghost onClick={handleCancelRecordButton}>
<ActionButton ghost aria-label="Cancel audio recording" onClick={handleCancelRecordButton}>
<Icon size="1.25rem" name="circle-cross" />
</ActionButton>
</Tooltip>
<Box css={styles.record}>
<Box css={styles.record} role="timer" aria-live="polite" aria-label={`Recording time: ${time}`}>
<Box is="span" css={styles.dot} />
<Box is="span" css={styles.timer}>
{time}
</Box>
</Box>
<Tooltip text="Finish Recording" position="top">
<ActionButton ghost onClick={handleStopRecordButton}>
<ActionButton ghost aria-label="Stop and send audio recording" onClick={handleStopRecordButton}>
<Icon name="circle-check" size="1.25rem" />
</ActionButton>
</Tooltip>
Expand Down
10 changes: 10 additions & 0 deletions packages/react/src/views/ChatInput/ChatInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,15 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
: 'This room is read only'
: 'Sign in to chat'
}
aria-label={
isUserAuthenticated
? `Message ${channelInfo.name || 'channel'}`
: 'Sign in to chat'
}
aria-multiline="true"
aria-disabled={
!isUserAuthenticated || !canSendMsg || isRecordingMessage || isChannelArchived
}
css={css`
${styles.textInput}
${isChannelArchived &&
Expand Down Expand Up @@ -646,6 +655,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
type="primary"
disabled={disableButton || isRecordingMessage}
icon="send"
aria-label="Send message"
/>
) : null
) : (
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import VideoMessageRecorder from './VideoMessageRecoder';
import { getChatInputFormattingToolbarStyles } from './ChatInput.styles';
import formatSelection from '../../lib/formatSelection';
import InsertLinkToolBox from './InsertLinkToolBox';
import useKeyboardNav from '../../hooks/useKeyboardNav';

const ChatInputFormattingToolbar = ({
messageRef,
Expand Down Expand Up @@ -51,6 +52,8 @@ const ChatInputFormattingToolbar = ({
const [isPopoverOpen, setPopoverOpen] = useState(false);
const popoverRef = useRef(null);

const handleToolbarKeyDown = useKeyboardNav({ direction: 'horizontal' });

const handleClickToOpenFiles = () => {
inputRef.current.click();
};
Expand Down Expand Up @@ -104,6 +107,7 @@ const ChatInputFormattingToolbar = ({
square
ghost
disabled={isRecordingMessage}
aria-label="Insert emoji"
onClick={() => {
if (isRecordingMessage) return;
setEmojiOpen(true);
Expand Down Expand Up @@ -152,6 +156,7 @@ const ChatInputFormattingToolbar = ({
square
ghost
disabled={isRecordingMessage}
aria-label="Upload file"
onClick={() => {
if (isRecordingMessage) return;
handleClickToOpenFiles();
Expand Down Expand Up @@ -181,6 +186,7 @@ const ChatInputFormattingToolbar = ({
square
ghost
disabled={isRecordingMessage}
aria-label="Insert link"
onClick={() => {
if (isRecordingMessage) return;
setInsertLinkOpen(true);
Expand Down Expand Up @@ -222,6 +228,7 @@ const ChatInputFormattingToolbar = ({
square
disabled={isRecordingMessage}
ghost
aria-label={`Format ${item.name}`}
onClick={() => {
if (isRecordingMessage) return;
formatSelection(messageRef, item.pattern);
Expand All @@ -243,6 +250,9 @@ const ChatInputFormattingToolbar = ({
css={styles.chatFormat}
className={`ec-chat-input-formatting-toolbar ${classNames}`}
style={styleOverrides}
role="toolbar"
aria-label="Message formatting"
onKeyDown={handleToolbarKeyDown}
>
<Box
css={css`
Expand Down Expand Up @@ -323,6 +333,9 @@ const ChatInputFormattingToolbar = ({
square
ghost
disabled={isRecordingMessage}
aria-label="More formatting options"
aria-expanded={isPopoverOpen}
aria-haspopup="true"
onClick={() => {
if (isRecordingMessage) return;
setPopoverOpen(!isPopoverOpen);
Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/views/ChatInput/VideoMessageRecoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ const VideoMessageRecorder = (props) => {
ghost
square
disabled={disabled}
aria-label="Record video message"
onClick={openWindowToRecord}
>
<Icon size="1.25rem" name="video-recorder" />
Expand Down Expand Up @@ -219,6 +220,7 @@ const VideoMessageRecorder = (props) => {
>
<ActionButton
ghost
aria-label={isRecording ? 'Stop recording' : 'Start recording'}
onClick={
isRecording ? handleStopRecording : handleStartRecording
}
Expand All @@ -232,7 +234,7 @@ const VideoMessageRecorder = (props) => {
/>
</ActionButton>
</Tooltip>
<Box css={styles.record}>
<Box css={styles.record} role="timer" aria-live="polite" aria-label={`Recording time: ${time}`}>
<Box
is="span"
css={isRecording ? styles.dot : styles.oppositeDot}
Expand All @@ -244,7 +246,7 @@ const VideoMessageRecorder = (props) => {
<Box css={styles.spacer} />

<Box css={styles.rightSection}>
<Button onClick={closeWindowStopRecord}>Cancel</Button>
<Button aria-label="Cancel video recording" onClick={closeWindowStopRecord}>Cancel</Button>
<Button
onClick={handleSendRecording}
disabled={isSendDisabled}
Expand Down
30 changes: 29 additions & 1 deletion packages/react/src/views/EmbeddedChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,32 @@ const EmbeddedChat = (props) => {
style={{ ...style, ...styleOverrides }}
>
<GlobalStyles />
<a
href="#ec-chat-main"
style={{
position: 'absolute',
top: '-999px',
left: '-999px',
zIndex: 9999,
padding: '8px 16px',
background: '#1d74f5',
color: '#fff',
borderRadius: '4px',
fontWeight: 700,
fontSize: '13px',
textDecoration: 'none',
}}
onFocus={(e) => {
e.target.style.top = '8px';
e.target.style.left = '8px';
}}
onBlur={(e) => {
e.target.style.top = '-999px';
e.target.style.left = '-999px';
}}
>
Skip to chat
</a>
<ToastBarProvider position={toastBarPosition}>
{hideHeader ? null : (
<ChatHeader
Expand All @@ -251,7 +277,9 @@ const EmbeddedChat = (props) => {
/>
)}

<ChatLayout />
<div id="ec-chat-main" tabIndex={-1} style={{ outline: 'none', display: 'contents' }}>
<ChatLayout />
</div>

<div id="overlay-items" />
</ToastBarProvider>
Expand Down
7 changes: 6 additions & 1 deletion packages/react/src/views/EmojiPicker/EmojiPicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ const CustomEmojiPicker = ({
height="auto"
width="auto"
>
<Box css={styles.emojiPicker}>
<Box
css={styles.emojiPicker}
role="dialog"
aria-modal="true"
aria-label="Emoji picker"
>
<EmojiPicker
height={400}
width={350}
Expand Down
Loading