Skip to content

Commit

Permalink
fix(a11y): multiple a11y fixes
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
Catalin committed Aug 22, 2024
1 parent c206626 commit 5ee5334
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 25 deletions.
4 changes: 4 additions & 0 deletions packages/emoji-mart-data/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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={() => {
Expand Down
69 changes: 48 additions & 21 deletions packages/emoji-mart/src/components/Picker/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -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' ||
Expand All @@ -865,17 +881,20 @@ export default class Picker extends Component {
<div>
<div class="spacer"></div>
<div class="flex flex-middle">
<div class="search relative flex-grow">
<div class="search relative flex-grow" role="presentation">
<input
type="search"
ref={this.refs.searchInput}
placeholder={I18n.search}
onClick={this.handleSearchClick}
onInput={this.handleSearchInput}
onKeyDown={this.handleEmojisKeyDown}
autoComplete="off"
></input>
<span class="icon loupe flex">{Icons.search.loupe}</span>
aria-label={I18n.search_input_aria_label}
autocomplete="off"
/>
<span aria-hidden="true" class="icon loupe flex">
{Icons.search.loupe}
</span>
{this.state.searchResults && (
<button
title="Clear"
Expand All @@ -889,7 +908,6 @@ export default class Picker extends Component {
</button>
)}
</div>

{renderSkinTone && this.renderSkinToneButton()}
</div>
</div>
Expand All @@ -913,6 +931,11 @@ export default class Picker extends Component {
<div>
{!searchResults.length ? (
<div class={`padding-small align-${this.dir[0]}`}>
{this.props.previewPosition === 'none' && (
<p>
{I18n.search_no_results_1} {I18n.search_no_results_2}
</p>
)}
{this.props.onAddCustomEmoji && (
<a onClick={this.props.onAddCustomEmoji}>{I18n.add_custom}</a>
)}
Expand Down Expand Up @@ -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)
Expand All @@ -960,12 +984,15 @@ export default class Picker extends Component {
<div
data-id={category.target ? category.target.id : category.id}
class="category"
role="group"
aria-labelledby={`${category.id}-heading`}
ref={root}
>
<div
aria-label={categoryName + ' emojis'}
aria-level="5"
role="heading"
id={`${category.id}-heading`}
class={`sticky padding-small align-${this.dir[0]}`}
>
{categoryName}
Expand Down Expand Up @@ -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 (
<div aria-live="polite" class="sr-only">
{contents}
<div
aria-live="polite"
class="sr-only"
aria-atomic="true"
>
{this.getResultMessage()}
</div>
)
}
Expand Down Expand Up @@ -1166,8 +1188,9 @@ export default class Picker extends Component {
const lineWidth = this.props.perLine * this.props.emojiButtonSize

return (
<section
<form
id="root"
role="search"
class="flex flex-column"
dir={this.dir}
style={{
Expand All @@ -1191,7 +1214,7 @@ export default class Picker extends Component {
width: this.props.dynamicWidth ? '100%' : lineWidth,
height: '100%',
}}
aria-label={I18n.a11y?.available_emojis ?? 'Available emojis'}
role="presentation"
>
{this.props.searchPosition == 'static' && this.renderSearch()}
{this.renderSearchResults()}
Expand All @@ -1202,8 +1225,12 @@ export default class Picker extends Component {
{this.props.navPosition == 'bottom' && this.renderNav()}
{this.props.previewPosition == 'bottom' && this.renderPreview()}
{this.state.showSkins && this.renderSkins()}
{this.renderLiveRegion()}
</section>
<ScreenReaderAnnouncement
level="polite"
identity="emoji-picker"
text={this.getResultMessage()}
/>
</form>
)
}
}
16 changes: 12 additions & 4 deletions packages/emoji-mart/src/components/Picker/PickerStyles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div aria-live={level} className="sr-only" aria-atomic="true">
{message}
</div>
)
}

export default ScreenReaderAnnouncement

0 comments on commit 5ee5334

Please sign in to comment.