From 13092271debdcc56343997ed12e2d94a359c26a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 18 Jul 2021 12:53:17 +0200 Subject: [PATCH 1/4] wip accessible emoji picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/emoji_selector.js | 42 +++++++++++++++++- app/soapbox/components/icon_button.js | 8 ++++ .../features/status/components/action_bar.js | 43 +++++++++++++++++-- app/soapbox/features/ui/index.js | 1 + app/soapbox/locales/pl.json | 1 + app/styles/components/detailed-status.scss | 16 +++++++ app/styles/components/emoji-reacts.scss | 6 ++- app/styles/components/status.scss | 22 +++++++++- 8 files changed, 130 insertions(+), 9 deletions(-) diff --git a/app/soapbox/components/emoji_selector.js b/app/soapbox/components/emoji_selector.js index 4cd0395e3..d288f901a 100644 --- a/app/soapbox/components/emoji_selector.js +++ b/app/soapbox/components/emoji_selector.js @@ -15,25 +15,63 @@ class EmojiSelector extends ImmutablePureComponent { static propTypes = { onReact: PropTypes.func.isRequired, + onUnfocus: PropTypes.func, visible: PropTypes.bool, + focused: PropTypes.bool, } static defaultProps = { onReact: () => {}, + onUnfocus: () => {}, 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() { - const { onReact, visible, allowedEmoji } = this.props; + const { onReact, visible, focused, allowedEmoji } = this.props; return ( -
+
{allowedEmoji.map((emoji, i) => (
diff --git a/app/soapbox/components/icon_button.js b/app/soapbox/components/icon_button.js index 949f4e5b4..21ed4ca95 100644 --- a/app/soapbox/components/icon_button.js +++ b/app/soapbox/components/icon_button.js @@ -13,6 +13,8 @@ export default class IconButton extends React.PureComponent { title: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, onClick: PropTypes.func, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, size: PropTypes.number, @@ -37,6 +39,8 @@ export default class IconButton extends React.PureComponent { animate: false, overlay: false, tabIndex: '0', + onKeyUp: () => {}, + onKeyDown: () => {}, onClick: () => {}, onMouseEnter: () => {}, onMouseLeave: () => {}, @@ -94,6 +98,8 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} + onKeyUp={this.props.onKeyUp} + onKeyDown={this.props.onKeyDown} onMouseEnter={this.props.onMouseEnter} onMouseLeave={this.props.onMouseLeave} tabIndex={tabIndex} @@ -119,6 +125,8 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} + onKeyUp={this.props.onKeyUp} + onKeyDown={this.props.onKeyDown} onMouseEnter={this.props.onMouseEnter} onMouseLeave={this.props.onMouseLeave} tabIndex={tabIndex} diff --git a/app/soapbox/features/status/components/action_bar.js b/app/soapbox/features/status/components/action_bar.js index 1336186d9..7bcda9cf9 100644 --- a/app/soapbox/features/status/components/action_bar.js +++ b/app/soapbox/features/status/components/action_bar.js @@ -48,6 +48,7 @@ const messages = defineMessages({ reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, + emojiPickerExpand: { id: 'status.reactions_expand', defaultMessage: 'Select emoji' }, }); const mapStateToProps = state => { @@ -107,6 +108,7 @@ class ActionBar extends React.PureComponent { state = { emojiSelectorVisible: false, + emojiSelectorFocused: false, } handleReplyClick = () => { @@ -169,10 +171,29 @@ class ActionBar extends React.PureComponent { } else { 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 }); + } + + handleHotkeyEmoji = () => { + const { emojiSelectorVisible } = this.state; + + this.setState({ emojiSelectorVisible: !emojiSelectorVisible }); + } + handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); } @@ -262,13 +283,13 @@ class ActionBar extends React.PureComponent { componentDidMount() { document.addEventListener('click', e => { if (this.node && !this.node.contains(e.target)) - this.setState({ emojiSelectorVisible: false }); + this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); }); } render() { const { status, intl, me, isStaff, isAdmin, allowedEmoji } = this.props; - const { emojiSelectorVisible } = this.state; + const { emojiSelectorVisible, emojiSelectorFocused } = this.state; const ownAccount = status.getIn(['account', 'id']) === me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -351,6 +372,7 @@ class ActionBar extends React.PureComponent { let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private'); + return (
@@ -377,7 +399,12 @@ class ActionBar extends React.PureComponent { onMouseLeave={this.handleLikeButtonLeave} ref={this.setRef} > - + +
{shareButton} diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index fd522c07f..2dee8512b 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -129,6 +129,7 @@ const keyMap = { forceNew: 'option+n', reply: 'r', favourite: 'f', + react: 'e', boost: 'b', mention: 'm', open: ['enter', 'o'], diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 3c6d845a9..2f0b6e1bc 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -692,6 +692,7 @@ "status.reactions.like": "Lubię", "status.reactions.open_mouth": "Wow", "status.reactions.weary": "Nuda…", + "status.reactions_expand": "Wybierz emoji", "status.read_more": "Czytaj dalej", "status.reblog": "Podbij", "status.reblog_private": "Podbij dla odbiorców oryginalnego wpisu", diff --git a/app/styles/components/detailed-status.scss b/app/styles/components/detailed-status.scss index 985f1a079..3daac0805 100644 --- a/app/styles/components/detailed-status.scss +++ b/app/styles/components/detailed-status.scss @@ -94,6 +94,22 @@ 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 { diff --git a/app/styles/components/emoji-reacts.scss b/app/styles/components/emoji-reacts.scss index d9a4450c7..9b2311099 100644 --- a/app/styles/components/emoji-reacts.scss +++ b/app/styles/components/emoji-reacts.scss @@ -80,7 +80,8 @@ transition: 0.1s; z-index: 999; - &--visible { + &--visible, + &--focused { opacity: 1; pointer-events: all; } @@ -99,7 +100,8 @@ transition: 0.1s; } - &:hover { + &:hover, + &:focus { img { width: 36px; height: 36px; diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index c3a975bee..a2d294fbf 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -434,7 +434,8 @@ background: var(--brand-color--med); transition: 0.2s; - &:hover { + &:hover, + &:focus { background: hsla(var(--brand-color_hsl), 0.5); text-decoration: none; } @@ -676,3 +677,22 @@ a.status-card.compact:hover { 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; + } + } + } +} \ No newline at end of file From 5ee92b47cec8227118ab41ef8933b2e7069c5fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 21 Jul 2021 13:58:22 +0200 Subject: [PATCH 2/4] Keyboard-accessible emoji picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/emoji_selector.js | 56 +++++++++++++------ app/soapbox/components/status.js | 32 ++++++++++- app/soapbox/components/status_action_bar.js | 14 ++++- app/soapbox/components/status_list.js | 4 +- .../features/status/components/action_bar.js | 29 ++++------ app/soapbox/features/status/index.js | 32 ++++++++++- .../features/ui/components/hotkeys_modal.js | 4 ++ app/soapbox/locales/pl.json | 1 + 8 files changed, 131 insertions(+), 41 deletions(-) diff --git a/app/soapbox/components/emoji_selector.js b/app/soapbox/components/emoji_selector.js index d288f901a..7fd52d965 100644 --- a/app/soapbox/components/emoji_selector.js +++ b/app/soapbox/components/emoji_selector.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { HotKeys } from 'react-hotkeys'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; import emojify from 'soapbox/features/emoji/emoji'; @@ -35,6 +36,8 @@ class EmojiSelector extends ImmutablePureComponent { } handleKeyUp = i => e => { + const { onUnfocus } = this.props; + switch (e.key) { case 'Left': case 'ArrowLeft': @@ -48,33 +51,54 @@ class EmojiSelector extends ImmutablePureComponent { this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`).focus(); } break; + case 'Escape': + onUnfocus(); + break; } } + handleReact = emoji => () => { + const { onReact, focused, onUnfocus } = this.props; + + onReact(emoji)(); + + if (focused) { + onUnfocus(); + } + } + + handlers = { + open: () => {}, + }; + setRef = c => { this.node = c; } render() { - const { onReact, visible, focused, allowedEmoji } = this.props; + const { visible, focused, allowedEmoji } = this.props; return ( -
- {allowedEmoji.map((emoji, i) => ( -
+
+ {allowedEmoji.map((emoji, i) => ( +
+ ); } diff --git a/app/soapbox/components/status.js b/app/soapbox/components/status.js index 1ff0b1e03..fcdddccb2 100644 --- a/app/soapbox/components/status.js +++ b/app/soapbox/components/status.js @@ -108,6 +108,7 @@ class Status extends ImmutablePureComponent { state = { showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), statusId: undefined, + emojiSelectorFocused: false, }; // Track height changes we know about to compensate scrolling @@ -255,6 +256,27 @@ class Status extends ImmutablePureComponent { this.handleToggleMediaVisibility(); } + handleHotkeyReact = () => { + this._expandEmojiSelector(); + } + + handleEmojiSelectorExpand = e => { + if (e.key === 'Enter') { + this._expandEmojiSelector(); + } + e.preventDefault(); + } + + handleEmojiSelectorUnfocus = () => { + this.setState({ emojiSelectorFocused: false }); + } + + _expandEmojiSelector = () => { + this.setState({ emojiSelectorFocused: true }); + const firstEmoji = this.node.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji.focus(); + }; + _properStatus() { const { status } = this.props; @@ -278,6 +300,7 @@ class Status extends ImmutablePureComponent { let { status, account, ...other } = this.props; + if (status === null) { return null; } @@ -443,6 +466,7 @@ class Status extends ImmutablePureComponent { moveDown: this.handleHotkeyMoveDown, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + react: this.handleHotkeyReact, }; const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`; @@ -506,7 +530,13 @@ class Status extends ImmutablePureComponent { )} - +
diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index b58991ea3..206170e24 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -91,6 +91,10 @@ class StatusActionBar extends ImmutablePureComponent { isStaff: PropTypes.bool.isRequired, isAdmin: PropTypes.bool.isRequired, allowedEmoji: ImmutablePropTypes.list, + emojiSelectorFocused: PropTypes.bool, + handleEmojiSelectorUnfocus: PropTypes.func.isRequired, + emojiSelectorFocused: PropTypes.bool, + handleEmojiSelectorUnfocus: PropTypes.func.isRequired, }; static defaultProps = { @@ -106,6 +110,7 @@ class StatusActionBar extends ImmutablePureComponent { updateOnProps = [ 'status', 'withDismiss', + 'emojiSelectorFocused', ] handleReplyClick = () => { @@ -359,7 +364,7 @@ class StatusActionBar extends ImmutablePureComponent { } render() { - const { status, intl, allowedEmoji } = this.props; + const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus } = this.props; const { emojiSelectorVisible } = this.state; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -422,7 +427,12 @@ class StatusActionBar extends ImmutablePureComponent { onMouseLeave={this.handleLikeButtonLeave} ref={this.setRef} > - + { if (featured) { - return this.props.featuredStatusIds.indexOf(id); + return this.props.featuredStatusIds.keySeq().findIndex(key => key === id); } else { - return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount(); + return this.props.statusIds.keySeq().findIndex(key => key === id) + this.getFeaturedStatusCount(); } } diff --git a/app/soapbox/features/status/components/action_bar.js b/app/soapbox/features/status/components/action_bar.js index 7bcda9cf9..8cc300e5d 100644 --- a/app/soapbox/features/status/components/action_bar.js +++ b/app/soapbox/features/status/components/action_bar.js @@ -100,6 +100,9 @@ class ActionBar extends React.PureComponent { isStaff: PropTypes.bool.isRequired, isAdmin: PropTypes.bool.isRequired, allowedEmoji: ImmutablePropTypes.list, + emojiSelectorFocused: PropTypes.bool, + handleEmojiSelectorExpand: PropTypes.func.isRequired, + handleEmojiSelectorUnfocus: PropTypes.func.isRequired, }; static defaultProps = { @@ -175,19 +178,6 @@ class ActionBar extends React.PureComponent { }; } - 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 }); - } - handleHotkeyEmoji = () => { const { emojiSelectorVisible } = this.state; @@ -283,13 +273,13 @@ class ActionBar extends React.PureComponent { componentDidMount() { document.addEventListener('click', e => { if (this.node && !this.node.contains(e.target)) - this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); + this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); }); } render() { - const { status, intl, me, isStaff, isAdmin, allowedEmoji } = this.props; - const { emojiSelectorVisible, emojiSelectorFocused } = this.state; + const { status, intl, me, isStaff, isAdmin, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorExpand, handleEmojiSelectorUnfocus } = this.props; + const { emojiSelectorVisible } = this.state; const ownAccount = status.getIn(['account', 'id']) === me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -403,7 +393,7 @@ class ActionBar extends React.PureComponent { onReact={this.handleReactClick} visible={emojiSelectorVisible} focused={emojiSelectorFocused} - onUnfocus={this.handleEmojiSelectorUnfocus} + onUnfocus={handleEmojiSelectorUnfocus} /> @@ -435,4 +425,5 @@ class ActionBar extends React.PureComponent { } -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ActionBar)); +export default injectIntl( + connect(mapStateToProps, mapDispatchToProps)(ActionBar)); diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.js index 08135b7f9..0ecf98e8c 100644 --- a/app/soapbox/features/status/index.js +++ b/app/soapbox/features/status/index.js @@ -146,6 +146,7 @@ class Status extends ImmutablePureComponent { fullscreen: false, showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), loadedStatusId: undefined, + emojiSelectorFocused: false, }; componentDidMount() { @@ -363,6 +364,10 @@ class Status extends ImmutablePureComponent { this.handleToggleMediaVisibility(); } + handleHotkeyReact = () => { + this._expandEmojiSelector(); + } + handleMoveUp = id => { const { status, ancestorsIds, descendantsIds } = this.props; @@ -397,6 +402,23 @@ class Status extends ImmutablePureComponent { } } + handleEmojiSelectorExpand = e => { + if (e.key === 'Enter') { + this._expandEmojiSelector(); + } + e.preventDefault(); + } + + handleEmojiSelectorUnfocus = () => { + this.setState({ emojiSelectorFocused: false }); + } + + _expandEmojiSelector = () => { + this.setState({ emojiSelectorFocused: true }); + const firstEmoji = this.status.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji.focus(); + }; + _selectChild(index, align_top) { const container = this.node; const element = container.querySelectorAll('.focusable')[index]; @@ -445,6 +467,10 @@ class Status extends ImmutablePureComponent { this.node = c; } + setStatusRef = c => { + this.status = c; + } + componentDidUpdate(prevProps, prevState) { const { params, status } = this.props; const { ancestorsIds } = prevProps; @@ -510,6 +536,7 @@ class Status extends ImmutablePureComponent { openProfile: this.handleHotkeyOpenProfile, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + react: this.handleHotkeyReact, }; return ( @@ -537,7 +564,7 @@ class Status extends ImmutablePureComponent { {ancestors} -
+
diff --git a/app/soapbox/features/ui/components/hotkeys_modal.js b/app/soapbox/features/ui/components/hotkeys_modal.js index 319e2e0e5..c646a0923 100644 --- a/app/soapbox/features/ui/components/hotkeys_modal.js +++ b/app/soapbox/features/ui/components/hotkeys_modal.js @@ -49,6 +49,10 @@ class HotkeysModal extends ImmutablePureComponent { f + + e + + b diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 2f0b6e1bc..5a87d306a 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -390,6 +390,7 @@ "keyboard_shortcuts.notifications": "aby otworzyć kolumnę powiadomień", "keyboard_shortcuts.pinned": "aby przejść do listy przypiętych wpisów", "keyboard_shortcuts.profile": "aby przejść do profilu autora wpisu", + "keyboard_shortcuts.react": "aby zareagować na wpis", "keyboard_shortcuts.reply": "aby odpowiedzieć", "keyboard_shortcuts.requests": "aby przejść do listy próśb o możliwość śledzenia", "keyboard_shortcuts.search": "aby przejść do pola wyszukiwania", From a2cd7764b7e01681b956331467653b38888c49dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 21 Jul 2021 14:07:22 +0200 Subject: [PATCH 3/4] Lint styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/styles/components/status.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index a2d294fbf..359ed2999 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -695,4 +695,4 @@ a.status-card.compact:hover { } } } -} \ No newline at end of file +} From c5dfd6c7d7370c0b4937620fcf8af419eefdba6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 21 Jul 2021 16:34:50 +0200 Subject: [PATCH 4/4] Update snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../__snapshots__/emoji_selector-test.js.snap | 111 +++++++++++------- .../__tests__/emoji_selector-test.js | 5 +- 2 files changed, 72 insertions(+), 44 deletions(-) diff --git a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap index 6b6307012..67ca647eb 100644 --- a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap @@ -2,55 +2,80 @@ exports[` renders correctly 1`] = `
-
`; diff --git a/app/soapbox/components/__tests__/emoji_selector-test.js b/app/soapbox/components/__tests__/emoji_selector-test.js index c8083c3aa..e907a8a82 100644 --- a/app/soapbox/components/__tests__/emoji_selector-test.js +++ b/app/soapbox/components/__tests__/emoji_selector-test.js @@ -4,7 +4,10 @@ import EmojiSelector from '../emoji_selector'; describe('', () => { it('renders correctly', () => { - const component = createComponent(); + const children = ; + children.__proto__.addEventListener = () => {}; + + const component = createComponent(children, {}, true); const tree = component.toJSON(); expect(tree).toMatchSnapshot(); });