Accessible emoiji picker

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
merge-requests/578/head
marcin mikołajczak 3 years ago
parent 8f53134b5e
commit 4d3f4c5680

@ -15,25 +15,64 @@ class EmojiSelector extends ImmutablePureComponent {
static propTypes = { static propTypes = {
onReact: PropTypes.func.isRequired, onReact: PropTypes.func.isRequired,
onUnfocus: PropTypes.func,
visible: PropTypes.bool, visible: PropTypes.bool,
focused: PropTypes.bool,
} }
static defaultProps = { static defaultProps = {
onReact: () => {}, onReact: () => {},
onUnfocus: () => {},
visible: false, visible: false,
} }
handleBlur = e => {
const { focused, onUnfocus } = this.props;
if (focused && (!e.relatedTarget || !e.relatedTarget.classList.contains('emoji-react-selector__emoji'))) {
onUnfocus();
}
}
handleKeyUp = i => e => {
switch (e.key) {
case 'Left':
case 'ArrowLeft':
if (i !== 0) {
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`).focus();
}
break;
case 'Right':
case 'ArrowRight':
if (i !== this.props.allowedEmoji.size - 1) {
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`).focus();
}
break;
}
}
setRef = c => {
this.node = c;
}
render() { render() {
const { onReact, visible, allowedEmoji } = this.props; const { onReact, visible, focused, allowedEmoji } = this.props;
return ( return (
<div className={classNames('emoji-react-selector', { 'emoji-react-selector--visible': visible })}> <div
className={classNames('emoji-react-selector', { 'emoji-react-selector--visible': visible, 'emoji-react-selector--focused': focused })}
onBlur={this.handleBlur}
ref={this.setRef}
>
{allowedEmoji.map((emoji, i) => ( {allowedEmoji.map((emoji, i) => (
<button <button
key={i} key={i}
className='emoji-react-selector__emoji' className='emoji-react-selector__emoji'
dangerouslySetInnerHTML={{ __html: emojify(emoji) }} dangerouslySetInnerHTML={{ __html: emojify(emoji) }}
onClick={onReact(emoji)} onClick={onReact(emoji)}
onKeyUp={this.handleKeyUp(i)}
tabIndex={(visible || focused) ? 0 : -1}
/> />
))} ))}
</div> </div>

@ -13,6 +13,8 @@ export default class IconButton extends React.PureComponent {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired, icon: PropTypes.string.isRequired,
onClick: PropTypes.func, onClick: PropTypes.func,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onMouseEnter: PropTypes.func, onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func, onMouseLeave: PropTypes.func,
size: PropTypes.number, size: PropTypes.number,
@ -37,6 +39,8 @@ export default class IconButton extends React.PureComponent {
animate: false, animate: false,
overlay: false, overlay: false,
tabIndex: '0', tabIndex: '0',
onKeyUp: () => {},
onKeyDown: () => {},
onClick: () => {}, onClick: () => {},
onMouseEnter: () => {}, onMouseEnter: () => {},
onMouseLeave: () => {}, onMouseLeave: () => {},
@ -94,6 +98,8 @@ export default class IconButton extends React.PureComponent {
title={title} title={title}
className={classes} className={classes}
onClick={this.handleClick} onClick={this.handleClick}
onKeyUp={this.props.onKeyUp}
onKeyDown={this.props.onKeyDown}
onMouseEnter={this.props.onMouseEnter} onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave} onMouseLeave={this.props.onMouseLeave}
tabIndex={tabIndex} tabIndex={tabIndex}
@ -119,6 +125,8 @@ export default class IconButton extends React.PureComponent {
title={title} title={title}
className={classes} className={classes}
onClick={this.handleClick} onClick={this.handleClick}
onKeyUp={this.props.onKeyUp}
onKeyDown={this.props.onKeyDown}
onMouseEnter={this.props.onMouseEnter} onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave} onMouseLeave={this.props.onMouseLeave}
tabIndex={tabIndex} tabIndex={tabIndex}

@ -48,6 +48,7 @@ const messages = defineMessages({
reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' },
reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' },
reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' },
emojiPickerExpand: { id: 'status.reactions_expand', defaultMessage: 'Select emoji' },
}); });
const mapStateToProps = state => { const mapStateToProps = state => {
@ -103,6 +104,7 @@ class ActionBar extends React.PureComponent {
state = { state = {
emojiSelectorVisible: false, emojiSelectorVisible: false,
emojiSelectorFocused: false,
} }
handleReplyClick = () => { handleReplyClick = () => {
@ -165,10 +167,23 @@ class ActionBar extends React.PureComponent {
} else { } else {
this.props.onOpenUnauthorizedModal(); this.props.onOpenUnauthorizedModal();
} }
this.setState({ emojiSelectorVisible: false }); this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false });
}; };
} }
handleEmojiSelectorExpand = e => {
if (e.key === 'Enter') {
this.setState({ emojiSelectorFocused: true });
const firstEmoji = this.node.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji.focus();
}
e.preventDefault();
}
handleEmojiSelectorUnfocus = () => {
this.setState({ emojiSelectorFocused: false });
}
handleDeleteClick = () => { handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history); this.props.onDelete(this.props.status, this.context.router.history);
} }
@ -258,13 +273,13 @@ class ActionBar extends React.PureComponent {
componentDidMount() { componentDidMount() {
document.addEventListener('click', e => { document.addEventListener('click', e => {
if (this.node && !this.node.contains(e.target)) if (this.node && !this.node.contains(e.target))
this.setState({ emojiSelectorVisible: false }); this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false });
}); });
} }
render() { render() {
const { status, intl, me, isStaff, allowedEmoji } = this.props; const { status, intl, me, isStaff, allowedEmoji } = this.props;
const { emojiSelectorVisible } = this.state; const { emojiSelectorVisible, emojiSelectorFocused } = this.state;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
@ -364,7 +379,12 @@ class ActionBar extends React.PureComponent {
onMouseLeave={this.handleLikeButtonLeave} onMouseLeave={this.handleLikeButtonLeave}
ref={this.setRef} ref={this.setRef}
> >
<EmojiSelector onReact={this.handleReactClick} visible={emojiSelectorVisible} /> <EmojiSelector
onReact={this.handleReactClick}
visible={emojiSelectorVisible}
focused={emojiSelectorFocused}
onUnfocus={this.handleEmojiSelectorUnfocus}
/>
<IconButton <IconButton
className='star-icon' className='star-icon'
animate animate
@ -375,6 +395,14 @@ class ActionBar extends React.PureComponent {
text={meEmojiTitle} text={meEmojiTitle}
onClick={this.handleLikeButtonClick} onClick={this.handleLikeButtonClick}
/> />
<IconButton
className='emoji-picker-expand'
animate
title={intl.formatMessage(messages.emojiPickerExpand)}
icon='caret-down'
onKeyUp={this.handleEmojiSelectorExpand}
onHover
/>
</div> </div>
{shareButton} {shareButton}

@ -87,6 +87,22 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
} }
.emoji-picker-expand {
display: none;
}
&:focus-within {
.emoji-picker-expand {
display: inline-flex;
width: 0;
overflow: hidden;
&:focus-within {
width: unset;
}
}
}
} }
.detailed-status__wrapper { .detailed-status__wrapper {

@ -80,7 +80,7 @@
transition: 0.1s; transition: 0.1s;
z-index: 999; z-index: 999;
&--visible { &--visible, &--focused {
opacity: 1; opacity: 1;
pointer-events: all; pointer-events: all;
} }
@ -99,7 +99,7 @@
transition: 0.1s; transition: 0.1s;
} }
&:hover { &:hover, &:focus {
img { img {
width: 36px; width: 36px;
height: 36px; height: 36px;

@ -666,3 +666,21 @@ a.status-card.compact:hover {
border-radius: 4px; border-radius: 4px;
} }
} }
.status__action-bar, .detailed-status__action-bar {
.emoji-picker-expand {
display: none;
}
&:focus-within {
.emoji-picker-expand {
display: inline-flex;
width: 0;
overflow: hidden;
&:focus-within {
width: unset;
}
}
}
}

Loading…
Cancel
Save