Introduce focus traps for ModalHost, add button role to DropZone

This commit is contained in:
Scott Nonnenberg 2021-10-04 10:14:00 -07:00 committed by GitHub
parent adaeb81c32
commit 48229332ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 371 additions and 294 deletions

View file

@ -4,6 +4,8 @@
import React, { useEffect } from 'react';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import FocusTrap from 'focus-trap-react';
import { Theme, themeClassName } from '../util/theme';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
@ -56,17 +58,19 @@ export const ModalHost = React.memo(
return root
? createPortal(
<div
role="presentation"
className={classNames(
'module-modal-host__overlay',
theme ? themeClassName(theme) : undefined
)}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
>
{children}
</div>,
<FocusTrap>
<div
role="presentation"
className={classNames(
'module-modal-host__overlay',
theme ? themeClassName(theme) : undefined
)}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
>
{children}
</div>
</FocusTrap>,
root
)
: null;

View file

@ -18,6 +18,8 @@ import {
last,
zipObject,
} from 'lodash';
import FocusTrap from 'focus-trap-react';
import { Emoji } from './Emoji';
import { dataByCategory, search } from './lib';
import { LocalizerType } from '../../types/Util';
@ -301,124 +303,126 @@ export const EmojiPicker = React.memo(
);
return (
<div className="module-emoji-picker" ref={ref} style={style}>
<header className="module-emoji-picker__header">
<button
type="button"
onClick={handleToggleSearch}
title={i18n('EmojiPicker--search-placeholder')}
className={classNames(
'module-emoji-picker__button',
'module-emoji-picker__button--icon',
searchMode
? 'module-emoji-picker__button--icon--close'
: 'module-emoji-picker__button--icon--search'
<FocusTrap>
<div className="module-emoji-picker" ref={ref} style={style}>
<header className="module-emoji-picker__header">
<button
type="button"
onClick={handleToggleSearch}
title={i18n('EmojiPicker--search-placeholder')}
className={classNames(
'module-emoji-picker__button',
'module-emoji-picker__button--icon',
searchMode
? 'module-emoji-picker__button--icon--close'
: 'module-emoji-picker__button--icon--search'
)}
aria-label={i18n('EmojiPicker--search-placeholder')}
/>
{searchMode ? (
<div className="module-emoji-picker__header__search-field">
<input
ref={focusOnRender}
className="module-emoji-picker__header__search-field__input"
placeholder={i18n('EmojiPicker--search-placeholder')}
onChange={handleSearchChange}
/>
</div>
) : (
categories.map(cat =>
cat === 'recents' && firstRecent.length === 0 ? null : (
<button
type="button"
key={cat}
data-category={cat}
title={cat}
onClick={handleSelectCategory}
className={classNames(
'module-emoji-picker__button',
'module-emoji-picker__button--icon',
`module-emoji-picker__button--icon--${cat}`,
selectedCategory === cat
? 'module-emoji-picker__button--selected'
: null
)}
aria-label={i18n(`EmojiPicker__button--${cat}`)}
/>
)
)
)}
aria-label={i18n('EmojiPicker--search-placeholder')}
/>
{searchMode ? (
<div className="module-emoji-picker__header__search-field">
<input
ref={focusOnRender}
className="module-emoji-picker__header__search-field__input"
placeholder={i18n('EmojiPicker--search-placeholder')}
onChange={handleSearchChange}
/>
</header>
{emojiGrid.length > 0 ? (
<div>
<AutoSizer>
{({ width, height }) => (
<Grid
key={searchText}
className="module-emoji-picker__body"
width={width}
height={height}
columnCount={COL_COUNT}
columnWidth={38}
rowHeight={getRowHeight}
rowCount={emojiGrid.length}
cellRenderer={cellRenderer}
scrollToRow={scrollToRow}
scrollToAlignment="start"
onSectionRendered={onSectionRendered}
/>
)}
</AutoSizer>
</div>
) : (
categories.map(cat =>
cat === 'recents' && firstRecent.length === 0 ? null : (
<div
className={classNames(
'module-emoji-picker__body',
'module-emoji-picker__body--empty'
)}
>
{i18n('EmojiPicker--empty')}
<Emoji
shortName="slightly_frowning_face"
size={16}
style={{ marginLeft: '4px' }}
/>
</div>
)}
<footer className="module-emoji-picker__footer">
{Boolean(onClickSettings) && (
<button
aria-label={i18n('CustomizingPreferredReactions__title')}
className="module-emoji-picker__button module-emoji-picker__button--footer module-emoji-picker__button--settings"
onClick={onClickSettings}
title={i18n('CustomizingPreferredReactions__title')}
type="button"
/>
)}
<div className="module-emoji-picker__footer__skin-tones">
{[0, 1, 2, 3, 4, 5].map(tone => (
<button
type="button"
key={cat}
data-category={cat}
title={cat}
onClick={handleSelectCategory}
key={tone}
data-tone={tone}
onClick={handlePickTone}
title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
className={classNames(
'module-emoji-picker__button',
'module-emoji-picker__button--icon',
`module-emoji-picker__button--icon--${cat}`,
selectedCategory === cat
'module-emoji-picker__button--footer',
selectedTone === tone
? 'module-emoji-picker__button--selected'
: null
)}
aria-label={i18n(`EmojiPicker__button--${cat}`)}
/>
)
)
)}
</header>
{emojiGrid.length > 0 ? (
<div>
<AutoSizer>
{({ width, height }) => (
<Grid
key={searchText}
className="module-emoji-picker__body"
width={width}
height={height}
columnCount={COL_COUNT}
columnWidth={38}
rowHeight={getRowHeight}
rowCount={emojiGrid.length}
cellRenderer={cellRenderer}
scrollToRow={scrollToRow}
scrollToAlignment="start"
onSectionRendered={onSectionRendered}
/>
)}
</AutoSizer>
</div>
) : (
<div
className={classNames(
'module-emoji-picker__body',
'module-emoji-picker__body--empty'
>
<Emoji shortName="hand" skinTone={tone} size={20} />
</button>
))}
</div>
{Boolean(onClickSettings) && (
<div className="module-emoji-picker__footer__settings-spacer" />
)}
>
{i18n('EmojiPicker--empty')}
<Emoji
shortName="slightly_frowning_face"
size={16}
style={{ marginLeft: '4px' }}
/>
</div>
)}
<footer className="module-emoji-picker__footer">
{Boolean(onClickSettings) && (
<button
aria-label={i18n('CustomizingPreferredReactions__title')}
className="module-emoji-picker__button module-emoji-picker__button--footer module-emoji-picker__button--settings"
onClick={onClickSettings}
title={i18n('CustomizingPreferredReactions__title')}
type="button"
/>
)}
<div className="module-emoji-picker__footer__skin-tones">
{[0, 1, 2, 3, 4, 5].map(tone => (
<button
type="button"
key={tone}
data-tone={tone}
onClick={handlePickTone}
title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
className={classNames(
'module-emoji-picker__button',
'module-emoji-picker__button--footer',
selectedTone === tone
? 'module-emoji-picker__button--selected'
: null
)}
>
<Emoji shortName="hand" skinTone={tone} size={20} />
</button>
))}
</div>
{Boolean(onClickSettings) && (
<div className="module-emoji-picker__footer__settings-spacer" />
)}
</footer>
</div>
</footer>
</div>
</FocusTrap>
);
}
)

View file

@ -3,6 +3,8 @@
import * as React from 'react';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { useRestoreFocus } from '../../hooks/useRestoreFocus';
import { StickerPackType, StickerType } from '../../state/ducks/stickers';
import { LocalizerType } from '../../types/Util';
@ -146,187 +148,193 @@ export const StickerPicker = React.memo(
const showLongText = showPickerHint;
return (
<div className="module-sticker-picker" ref={ref} style={style}>
<div className="module-sticker-picker__header">
<div className="module-sticker-picker__header__packs">
<div
className="module-sticker-picker__header__packs__slider"
style={{
transform: `translateX(-${getPacksPageOffset(
packsPage,
packs.length
)}px)`,
}}
>
{hasPacks ? (
<button
type="button"
onClick={recentsHandler}
className={classNames({
'module-sticker-picker__header__button': true,
'module-sticker-picker__header__button--recents': true,
'module-sticker-picker__header__button--selected':
currentTab === 'recents',
})}
aria-label={i18n('stickers--StickerPicker--Recents')}
/>
) : null}
{packs.map((pack, i) => (
<button
type="button"
key={pack.id}
onClick={packsHandlers[i]}
className={classNames(
'module-sticker-picker__header__button',
{
'module-sticker-picker__header__button--selected':
currentTab === pack.id,
'module-sticker-picker__header__button--error':
pack.status === 'error',
}
)}
>
{pack.cover ? (
<img
className="module-sticker-picker__header__button__image"
src={pack.cover.url}
alt={pack.title}
title={pack.title}
/>
) : (
<div className="module-sticker-picker__header__button__image-placeholder" />
)}
</button>
))}
</div>
{!isUsingKeyboard && packsPage > 0 ? (
<button
type="button"
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--prev-page'
)}
onClick={onClickPrevPackPage}
aria-label={i18n('stickers--StickerPicker--PrevPage')}
/>
) : null}
{!isUsingKeyboard && !isLastPacksPage(packsPage, packs.length) ? (
<button
type="button"
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--next-page'
)}
onClick={onClickNextPackPage}
aria-label={i18n('stickers--StickerPicker--NextPage')}
/>
) : null}
</div>
<button
type="button"
ref={addPackRef}
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--add-pack',
{
'module-sticker-picker__header__button--hint': showPickerHint,
}
)}
onClick={onClickAddPack}
aria-label={i18n('stickers--StickerPicker--AddPack')}
/>
</div>
<div
className={classNames('module-sticker-picker__body', {
'module-sticker-picker__body--empty': isEmpty,
})}
>
{showPickerHint ? (
<div
className={classNames(
'module-sticker-picker__body__text',
'module-sticker-picker__body__text--hint',
{
'module-sticker-picker__body__text--pin': showEmptyText,
}
)}
>
{i18n('stickers--StickerPicker--Hint')}
</div>
) : null}
{!hasPacks ? (
<div className="module-sticker-picker__body__text">
{i18n('stickers--StickerPicker--NoPacks')}
</div>
) : null}
{pendingCount > 0 ? (
<div className="module-sticker-picker__body__text">
{i18n('stickers--StickerPicker--DownloadPending')}
</div>
) : null}
{downloadError ? (
<div
className={classNames(
'module-sticker-picker__body__text',
'module-sticker-picker__body__text--error'
)}
>
{stickers.length > 0
? i18n('stickers--StickerPicker--DownloadError')
: i18n('stickers--StickerPicker--Empty')}
</div>
) : null}
{hasPacks && showEmptyText ? (
<div
className={classNames('module-sticker-picker__body__text', {
'module-sticker-picker__body__text--error': !isRecents,
})}
>
{isRecents
? i18n('stickers--StickerPicker--NoRecents')
: i18n('stickers--StickerPicker--Empty')}
</div>
) : null}
{!isEmpty ? (
<div
className={classNames('module-sticker-picker__body__content', {
'module-sticker-picker__body__content--under-text': showText,
'module-sticker-picker__body__content--under-long-text': showLongText,
})}
>
{stickers.map(({ packId, id, url }, index: number) => {
const maybeFocusRef = index === 0 ? focusRef : undefined;
return (
<FocusTrap>
<div className="module-sticker-picker" ref={ref} style={style}>
<div className="module-sticker-picker__header">
<div className="module-sticker-picker__header__packs">
<div
className="module-sticker-picker__header__packs__slider"
style={{
transform: `translateX(-${getPacksPageOffset(
packsPage,
packs.length
)}px)`,
}}
>
{hasPacks ? (
<button
type="button"
ref={maybeFocusRef}
key={`${packId}-${id}`}
className="module-sticker-picker__body__cell"
onClick={() => onPickSticker(packId, id)}
>
<img
className="module-sticker-picker__body__cell__image"
src={url}
alt={packTitle}
/>
</button>
);
})}
{Array(pendingCount)
.fill(0)
.map((_, i) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className="module-sticker-picker__body__cell__placeholder"
role="presentation"
onClick={recentsHandler}
className={classNames({
'module-sticker-picker__header__button': true,
'module-sticker-picker__header__button--recents': true,
'module-sticker-picker__header__button--selected':
currentTab === 'recents',
})}
aria-label={i18n('stickers--StickerPicker--Recents')}
/>
) : null}
{packs.map((pack, i) => (
<button
type="button"
key={pack.id}
onClick={packsHandlers[i]}
className={classNames(
'module-sticker-picker__header__button',
{
'module-sticker-picker__header__button--selected':
currentTab === pack.id,
'module-sticker-picker__header__button--error':
pack.status === 'error',
}
)}
>
{pack.cover ? (
<img
className="module-sticker-picker__header__button__image"
src={pack.cover.url}
alt={pack.title}
title={pack.title}
/>
) : (
<div className="module-sticker-picker__header__button__image-placeholder" />
)}
</button>
))}
</div>
{!isUsingKeyboard && packsPage > 0 ? (
<button
type="button"
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--prev-page'
)}
onClick={onClickPrevPackPage}
aria-label={i18n('stickers--StickerPicker--PrevPage')}
/>
) : null}
{!isUsingKeyboard &&
!isLastPacksPage(packsPage, packs.length) ? (
<button
type="button"
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--next-page'
)}
onClick={onClickNextPackPage}
aria-label={i18n('stickers--StickerPicker--NextPage')}
/>
) : null}
</div>
) : null}
<button
type="button"
ref={addPackRef}
className={classNames(
'module-sticker-picker__header__button',
'module-sticker-picker__header__button--add-pack',
{
'module-sticker-picker__header__button--hint': showPickerHint,
}
)}
onClick={onClickAddPack}
aria-label={i18n('stickers--StickerPicker--AddPack')}
/>
</div>
<div
className={classNames('module-sticker-picker__body', {
'module-sticker-picker__body--empty': isEmpty,
})}
>
{showPickerHint ? (
<div
className={classNames(
'module-sticker-picker__body__text',
'module-sticker-picker__body__text--hint',
{
'module-sticker-picker__body__text--pin': showEmptyText,
}
)}
>
{i18n('stickers--StickerPicker--Hint')}
</div>
) : null}
{!hasPacks ? (
<div className="module-sticker-picker__body__text">
{i18n('stickers--StickerPicker--NoPacks')}
</div>
) : null}
{pendingCount > 0 ? (
<div className="module-sticker-picker__body__text">
{i18n('stickers--StickerPicker--DownloadPending')}
</div>
) : null}
{downloadError ? (
<div
className={classNames(
'module-sticker-picker__body__text',
'module-sticker-picker__body__text--error'
)}
>
{stickers.length > 0
? i18n('stickers--StickerPicker--DownloadError')
: i18n('stickers--StickerPicker--Empty')}
</div>
) : null}
{hasPacks && showEmptyText ? (
<div
className={classNames('module-sticker-picker__body__text', {
'module-sticker-picker__body__text--error': !isRecents,
})}
>
{isRecents
? i18n('stickers--StickerPicker--NoRecents')
: i18n('stickers--StickerPicker--Empty')}
</div>
) : null}
{!isEmpty ? (
<div
className={classNames(
'module-sticker-picker__body__content',
{
'module-sticker-picker__body__content--under-text': showText,
'module-sticker-picker__body__content--under-long-text': showLongText,
}
)}
>
{stickers.map(({ packId, id, url }, index: number) => {
const maybeFocusRef = index === 0 ? focusRef : undefined;
return (
<button
type="button"
ref={maybeFocusRef}
key={`${packId}-${id}`}
className="module-sticker-picker__body__cell"
onClick={() => onPickSticker(packId, id)}
>
<img
className="module-sticker-picker__body__cell__image"
src={url}
alt={packTitle}
/>
</button>
);
})}
{Array(pendingCount)
.fill(0)
.map((_, i) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className="module-sticker-picker__body__cell__placeholder"
role="presentation"
/>
))}
</div>
) : null}
</div>
</div>
</div>
</FocusTrap>
);
}
)