From 5ee5334970bcd3788db5fc50f9bc6cac2c0a7fde Mon Sep 17 00:00:00 2001 From: Catalin Date: Thu, 22 Aug 2024 16:47:03 +0100 Subject: [PATCH] fix(a11y): multiple a11y fixes - SR & Live Region - add new componet to handle SR annoucements with delay, as focus imput interrupts the annoucements - screen reader announcement no longer announces individual emojis, but rather the count of emojis found OR no emoji found message if none are found - add aria-atomic for the aria-live region to ensure the SR reads the whole message - Navigation - disabled top navigation buttons while search input is focused - Search - changed the outer div to a form with role search to improve semantics - add aria-hidden on the loupe icon in the search bar - add aria-label to the search input - Search Result & Default Categories - render no result message in the preview section, when preview section is not rendered. previously the no result was only shown in the preview, but since the picker already supports hiding it, we don't want to loose the visual cue of "no emojis found" - add aria-label to the outer div around the all the moji categories - add aria-labelledby for each group of emojis pointing to the section header, and add role = group --- packages/emoji-mart-data/i18n/en.json | 4 ++ .../src/components/Navigation/Navigation.tsx | 1 + .../src/components/Picker/Picker.tsx | 69 +++++++++++++------ .../src/components/Picker/PickerStyles.scss | 16 +++-- .../ScreenReaderAnnouncement/index.tsx | 44 ++++++++++++ 5 files changed, 109 insertions(+), 25 deletions(-) create mode 100644 packages/emoji-mart/src/components/ScreenReaderAnnouncement/index.tsx diff --git a/packages/emoji-mart-data/i18n/en.json b/packages/emoji-mart-data/i18n/en.json index 68116c5b..2444f8bf 100644 --- a/packages/emoji-mart-data/i18n/en.json +++ b/packages/emoji-mart-data/i18n/en.json @@ -2,6 +2,10 @@ "search": "Search", "search_no_results_1": "Oh no!", "search_no_results_2": "That emoji couldn’t be found", + "emojis_found_plural": "emojis found", + "emoji_found_singular": "emoji found", + "search_input_aria_label": "Search emojis", + "available_emojis": "Available emojis", "pick": "Pick an emoji…", "add_custom": "Add custom emoji", "categories": { diff --git a/packages/emoji-mart/src/components/Navigation/Navigation.tsx b/packages/emoji-mart/src/components/Navigation/Navigation.tsx index 194432be..7dc8ef3c 100644 --- a/packages/emoji-mart/src/components/Navigation/Navigation.tsx +++ b/packages/emoji-mart/src/components/Navigation/Navigation.tsx @@ -111,6 +111,7 @@ export default class Navigation extends PureComponent { type="button" class="flex flex-grow flex-center" role="tab" + disabled={this.props.disabled} tabIndex={selected ? 0 : -1} onMouseDown={(e) => e.preventDefault()} onClick={() => { diff --git a/packages/emoji-mart/src/components/Picker/Picker.tsx b/packages/emoji-mart/src/components/Picker/Picker.tsx index 0550a8b9..9fea4882 100644 --- a/packages/emoji-mart/src/components/Picker/Picker.tsx +++ b/packages/emoji-mart/src/components/Picker/Picker.tsx @@ -10,6 +10,7 @@ import type { Category } from '@slidoapp/emoji-mart-data' import { Emoji } from '../Emoji' import { PureInlineComponent } from '../HOCs' import { Navigation } from '../Navigation' +import ScreenReaderAnnouncement from '../ScreenReaderAnnouncement' const Performance = { rowsPerRender: 10, @@ -707,6 +708,7 @@ export default class Picker extends Component { theme={this.state.theme} dir={this.dir} unfocused={!!this.state.searchResults} + disabled={!!this.state.searchResults} position={this.props.navPosition} onClick={this.handleCategoryClick} /> @@ -856,6 +858,20 @@ export default class Picker extends Component { ) } + getResultMessage() { + const { searchResults } = this.state + if (searchResults === undefined || searchResults === null) return '' + + if (searchResults.length <= 0) { + return I18n.search_no_results_2 + } else { + let count = searchResults.flat().length + let translation = + count === 1 ? I18n.emoji_found_singular : I18n.emojis_found_plural + return [count, translation].join(' ') + } + } + renderSearch() { const renderSkinTone = this.props.previewPosition == 'none' || @@ -865,7 +881,7 @@ export default class Picker extends Component {
-
@@ -913,6 +931,11 @@ export default class Picker extends Component {
{!searchResults.length ? (
+ {this.props.previewPosition === 'none' && ( +

+ {I18n.search_no_results_1} {I18n.search_no_results_2} +

+ )} {this.props.onAddCustomEmoji && ( {I18n.add_custom} )} @@ -951,6 +974,7 @@ export default class Picker extends Component { }} role="listbox" onKeyDown={this.handleEmojisKeyDown} + aria-label={I18n.available_emojis} > {categories.map((category) => { const { root, rows } = this.refs.categories.get(category.id) @@ -960,12 +984,15 @@ export default class Picker extends Component {
{categoryName} @@ -1067,20 +1094,15 @@ export default class Picker extends Component { ) } + // This is a hidden live region that announces the number of emojis found only renderLiveRegion() { - const emoji = this.getEmojiByPos(this.state.pos) - const noSearchResults = - this.state.searchResults == null || this.state.searchResults.length === 0 - - const contents = emoji - ? emoji.name - : noSearchResults - ? I18n.search_no_results_2 - : '' - return ( -
- {contents} +
+ {this.getResultMessage()}
) } @@ -1166,8 +1188,9 @@ export default class Picker extends Component { const lineWidth = this.props.perLine * this.props.emojiButtonSize return ( - + + ) } } diff --git a/packages/emoji-mart/src/components/Picker/PickerStyles.scss b/packages/emoji-mart/src/components/Picker/PickerStyles.scss index e68b6613..70c2b497 100644 --- a/packages/emoji-mart/src/components/Picker/PickerStyles.scss +++ b/packages/emoji-mart/src/components/Picker/PickerStyles.scss @@ -127,12 +127,15 @@ } .sr-only { - position: absolute; - left: -10000px; - top: auto; - width: 1px; + border: 0; + clip: rect(0, 0, 0, 0); height: 1px; + margin: -1px; overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; } a { @@ -293,6 +296,11 @@ button { &:hover { color: var(--color-a) } } + button:disabled { + color: var(--color-c); + cursor: not-allowed; + } + svg, img { width: var(--category-icon-size); height: var(--category-icon-size); diff --git a/packages/emoji-mart/src/components/ScreenReaderAnnouncement/index.tsx b/packages/emoji-mart/src/components/ScreenReaderAnnouncement/index.tsx new file mode 100644 index 00000000..1d4f3070 --- /dev/null +++ b/packages/emoji-mart/src/components/ScreenReaderAnnouncement/index.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'preact/hooks' + +type Level = 'assertive' | 'polite' + +type AnnouncementProps = { + text: string + level: Level + delay: number + timeout: number +} + +/** + * Component which will cause a screen reader to announce a message when required. + */ +const ScreenReaderAnnouncement = ({ + delay = 1500, + level, + text, + timeout = 2000, +}: AnnouncementProps) => { + const [message, setMessage] = useState('') + + useEffect(() => { + let timer = setTimeout(() => { + setMessage(text) + + timer = setTimeout(() => { + setMessage('') + }, timeout) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [delay, text, timeout]) + + return ( +
+ {message} +
+ ) +} + +export default ScreenReaderAnnouncement