diff --git a/packages/react/src/hooks/useKeyboardNav.js b/packages/react/src/hooks/useKeyboardNav.js new file mode 100644 index 0000000000..b9708a689a --- /dev/null +++ b/packages/react/src/hooks/useKeyboardNav.js @@ -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; diff --git a/packages/react/src/index.js b/packages/react/src/index.js index b68316f2b7..293db9f2ea 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -1 +1,2 @@ export { default as EmbeddedChat } from './views/EmbeddedChat'; +export { default as useKeyboardNav } from './hooks/useKeyboardNav'; diff --git a/packages/react/src/stories/EmbeddedChatAccessibility.stories.js b/packages/react/src/stories/EmbeddedChatAccessibility.stories.js new file mode 100644 index 0000000000..d451f76ff6 --- /dev/null +++ b/packages/react/src/stories/EmbeddedChatAccessibility.stories.js @@ -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)', + }, +}; diff --git a/packages/react/src/views/ChatHeader/ChatHeader.js b/packages/react/src/views/ChatHeader/ChatHeader.js index 0986104ae5..21c5c5811f 100644 --- a/packages/react/src/views/ChatHeader/ChatHeader.js +++ b/packages/react/src/views/ChatHeader/ChatHeader.js @@ -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'}`} > diff --git a/packages/react/src/views/ChatInput/AudioMessageRecorder.js b/packages/react/src/views/ChatInput/AudioMessageRecorder.js index 35e8063a52..f167db1c94 100644 --- a/packages/react/src/views/ChatInput/AudioMessageRecorder.js +++ b/packages/react/src/views/ChatInput/AudioMessageRecorder.js @@ -153,6 +153,7 @@ const AudioMessageRecorder = (props) => { ghost square disabled={disabled} + aria-label="Record audio message" onClick={handleRecordButtonClick} > @@ -166,18 +167,18 @@ const AudioMessageRecorder = (props) => { {state === 'recording' && ( <> - + - + {time} - + diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index e753b689ae..7c1f3b0e99 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -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 && @@ -646,6 +655,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { type="primary" disabled={disableButton || isRecordingMessage} icon="send" + aria-label="Send message" /> ) : null ) : ( diff --git a/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js b/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js index 5d8c20a600..64fe725a81 100644 --- a/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js +++ b/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js @@ -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, @@ -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(); }; @@ -104,6 +107,7 @@ const ChatInputFormattingToolbar = ({ square ghost disabled={isRecordingMessage} + aria-label="Insert emoji" onClick={() => { if (isRecordingMessage) return; setEmojiOpen(true); @@ -152,6 +156,7 @@ const ChatInputFormattingToolbar = ({ square ghost disabled={isRecordingMessage} + aria-label="Upload file" onClick={() => { if (isRecordingMessage) return; handleClickToOpenFiles(); @@ -181,6 +186,7 @@ const ChatInputFormattingToolbar = ({ square ghost disabled={isRecordingMessage} + aria-label="Insert link" onClick={() => { if (isRecordingMessage) return; setInsertLinkOpen(true); @@ -222,6 +228,7 @@ const ChatInputFormattingToolbar = ({ square disabled={isRecordingMessage} ghost + aria-label={`Format ${item.name}`} onClick={() => { if (isRecordingMessage) return; formatSelection(messageRef, item.pattern); @@ -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} > { if (isRecordingMessage) return; setPopoverOpen(!isPopoverOpen); diff --git a/packages/react/src/views/ChatInput/VideoMessageRecoder.js b/packages/react/src/views/ChatInput/VideoMessageRecoder.js index b45c821757..44ec5d6d5d 100644 --- a/packages/react/src/views/ChatInput/VideoMessageRecoder.js +++ b/packages/react/src/views/ChatInput/VideoMessageRecoder.js @@ -183,6 +183,7 @@ const VideoMessageRecorder = (props) => { ghost square disabled={disabled} + aria-label="Record video message" onClick={openWindowToRecord} > @@ -219,6 +220,7 @@ const VideoMessageRecorder = (props) => { > { /> - + { - +