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. This was added because while the input is focused, the tab navigation doesn't do anything in the current implementation, and it can be misleading if not disabled. (user can still hover, and they appear as normal btns).

- 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 to be able to give more context if necessary

- 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

chore(prettier): fix pretier

fix(picker): remove unused func, and add aria-expanded to skin btn

chore(pr): address pr comments
  • Loading branch information
Catalin committed Aug 30, 2024
1 parent 8c04a32 commit 0bc77c1
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 31 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
73 changes: 46 additions & 27 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 @@ -873,9 +889,12 @@ export default class Picker extends Component {
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 @@ -1039,6 +1066,8 @@ export default class Picker extends Component {
return null
}

const ariaSkinToneShown = this.state.showSkins ? 'true' : 'false'

return (
<div
class="flex flex-auto flex-center flex-middle"
Expand All @@ -1052,7 +1081,10 @@ export default class Picker extends Component {
type="button"
ref={this.refs.skinToneButton}
class="skin-tone-button flex flex-auto flex-center flex-middle"
aria-selected={this.state.showSkins ? 'true' : 'false'}
aria-selected={ariaSkinToneShown}
aria-expanded={ariaSkinToneShown}
aria-controls="skin-tone-options"
aria-haspopup="true"
aria-label={`${I18n.skins.choose}, ${I18n.skins[this.state.skin]}`}
title={I18n.skins.choose}
onClick={this.openSkins}
Expand All @@ -1067,24 +1099,6 @@ export default class Picker extends Component {
)
}

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>
)
}

renderSkins() {
const skinToneButton = this.refs.skinToneButton.current
const skinToneButtonRect = skinToneButton.getBoundingClientRect()
Expand Down Expand Up @@ -1117,6 +1131,7 @@ export default class Picker extends Component {
class="menu hidden"
data-position={position.top ? 'top' : 'bottom'}
style={position}
id="skin-tone-options"
>
{[...Array(6).keys()].map((i) => {
const skin = i + 1
Expand Down Expand Up @@ -1166,8 +1181,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 +1207,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 +1218,11 @@ 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"
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 0bc77c1

Please sign in to comment.