diff --git a/app/soapbox/actions/modal.js b/app/soapbox/actions/modal.js index eaa5a315d..72604ecc6 100644 --- a/app/soapbox/actions/modal.js +++ b/app/soapbox/actions/modal.js @@ -9,8 +9,9 @@ export function openModal(type, props) { }; } -export function closeModal() { +export function closeModal(type) { return { type: MODAL_CLOSE, + modalType: type, }; } diff --git a/app/soapbox/components/button.js b/app/soapbox/components/button.js index 6705b77df..214ce7b53 100644 --- a/app/soapbox/components/button.js +++ b/app/soapbox/components/button.js @@ -66,7 +66,7 @@ export default class Button extends React.PureComponent { if (this.props.to) { return ( - + {btn} ); diff --git a/app/soapbox/components/dropdown_menu.js b/app/soapbox/components/dropdown_menu.js index 2f2412928..37f1a8c2b 100644 --- a/app/soapbox/components/dropdown_menu.js +++ b/app/soapbox/components/dropdown_menu.js @@ -45,7 +45,9 @@ class DropdownMenu extends React.PureComponent { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus(); + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus({ preventScroll: true }); + } this.setState({ mounted: true }); } @@ -66,38 +68,42 @@ class DropdownMenu extends React.PureComponent { handleKeyDown = e => { const items = Array.from(this.node.getElementsByTagName('a')); const index = items.indexOf(document.activeElement); - let element; + let element = null; switch(e.key) { case 'ArrowDown': - element = items[index+1]; - if (element) { - element.focus(); - } + element = items[index+1] || items[0]; break; case 'ArrowUp': - element = items[index-1]; - if (element) { - element.focus(); + element = items[index-1] || items[items.length-1]; + break; + case 'Tab': + if (e.shiftKey) { + element = items[index-1] || items[items.length-1]; + } else { + element = items[index+1] || items[0]; } break; case 'Home': element = items[0]; - if (element) { - element.focus(); - } break; case 'End': element = items[items.length-1]; - if (element) { - element.focus(); - } break; + case 'Escape': + this.props.onClose(); + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); } } - handleItemKeyDown = e => { - if (e.key === 'Enter') { + handleItemKeyPress = e => { + if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } } @@ -151,7 +157,7 @@ class DropdownMenu extends React.PureComponent { ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onAuxClick={this.handleAuxClick} - onKeyDown={this.handleItemKeyDown} + onKeyPress={this.handleItemKeyPress} data-index={i} target={newTab ? '_blank' : null} data-method={isLogout ? 'delete' : null} @@ -226,19 +232,36 @@ export default class Dropdown extends React.PureComponent { } handleClose = () => { + if (this.activeElement) { + this.activeElement.focus(); + this.activeElement = null; + } this.props.onClose(this.state.id); } - handleKeyDown = e => { + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { switch(e.key) { case ' ': case 'Enter': this.handleClick(e); + e.stopPropagation(); e.preventDefault(); break; - case 'Escape': - this.handleClose(); - break; } } @@ -276,7 +299,7 @@ export default class Dropdown extends React.PureComponent { const open = this.state.id === openDropdownId; return ( -
+
diff --git a/app/soapbox/components/icon_button.js b/app/soapbox/components/icon_button.js index 21ed4ca95..0d290c781 100644 --- a/app/soapbox/components/icon_button.js +++ b/app/soapbox/components/icon_button.js @@ -13,8 +13,10 @@ export default class IconButton extends React.PureComponent { title: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, onClick: PropTypes.func, + onMouseDown: PropTypes.func, onKeyUp: PropTypes.func, onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, size: PropTypes.number, @@ -54,6 +56,30 @@ export default class IconButton extends React.PureComponent { } } + handleMouseDown = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + } + + handleKeyDown = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + + handleKeyUp = (e) => { + if (!this.props.disabled && this.props.onKeyUp) { + this.props.onKeyUp(e); + } + } + + handleKeyPress = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + } + render() { const style = { fontSize: `${this.props.size}px`, @@ -98,8 +124,10 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} - onKeyUp={this.props.onKeyUp} - onKeyDown={this.props.onKeyDown} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} + onKeyUp={this.handleKeyUp} + onKeyPress={this.handleKeyPress} onMouseEnter={this.props.onMouseEnter} onMouseLeave={this.props.onMouseLeave} tabIndex={tabIndex} @@ -125,8 +153,10 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} - onKeyUp={this.props.onKeyUp} - onKeyDown={this.props.onKeyDown} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} + onKeyUp={this.handleKeyUp} + onKeyPress={this.handleKeyPress} onMouseEnter={this.props.onMouseEnter} onMouseLeave={this.props.onMouseLeave} tabIndex={tabIndex} diff --git a/app/soapbox/components/modal_root.js b/app/soapbox/components/modal_root.js index 1ffb12d3c..a8e8fe746 100644 --- a/app/soapbox/components/modal_root.js +++ b/app/soapbox/components/modal_root.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import 'wicg-inert'; import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; import { connect } from 'react-redux'; import { openModal } from '../actions/modal'; @@ -74,8 +75,31 @@ class ModalRoot extends React.PureComponent { } }; + + handleKeyDown = (e) => { + if (e.key === 'Tab') { + const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); + const index = focusable.indexOf(e.target); + + let element; + + if (e.shiftKey) { + element = focusable[index - 1] || focusable[focusable.length - 1]; + } else { + element = focusable[index + 1] || focusable[0]; + } + + if (element) { + element.focus(); + e.stopPropagation(); + e.preventDefault(); + } + } + } + componentDidMount() { window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); } componentDidUpdate(prevProps) { @@ -101,6 +125,7 @@ class ModalRoot extends React.PureComponent { componentWillUnmount() { window.removeEventListener('keyup', this.handleKeyUp); + window.removeEventListener('keydown', this.handleKeyDown); } getSiblings = () => { diff --git a/app/soapbox/components/status.js b/app/soapbox/components/status.js index 151a3e926..95c7ffb0c 100644 --- a/app/soapbox/components/status.js +++ b/app/soapbox/components/status.js @@ -213,6 +213,21 @@ class Status extends ImmutablePureComponent { this.props.OnOpenAudio(media, startTime); } + handleHotkeyOpenMedia = e => { + const { onOpenMedia, onOpenVideo } = this.props; + const status = this._properStatus(); + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(status.getIn(['media_attachments', 0]), 0); + } else { + onOpenMedia(status.get('media_attachments'), 0); + } + } + } + handleHotkeyReply = e => { e.preventDefault(); this.props.onReply(this._properStatus(), this.context.router.history); @@ -461,6 +476,7 @@ class Status extends ImmutablePureComponent { moveDown: this.handleHotkeyMoveDown, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, react: this.handleHotkeyReact, }; diff --git a/app/soapbox/containers/dropdown_menu_container.js b/app/soapbox/containers/dropdown_menu_container.js index 73c8a1e53..f79b19202 100644 --- a/app/soapbox/containers/dropdown_menu_container.js +++ b/app/soapbox/containers/dropdown_menu_container.js @@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({ }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, onClose(id) { - dispatch(closeModal()); + dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); }, }); diff --git a/app/soapbox/containers/soapbox.js b/app/soapbox/containers/soapbox.js index 27f7ef614..8a0c327da 100644 --- a/app/soapbox/containers/soapbox.js +++ b/app/soapbox/containers/soapbox.js @@ -15,6 +15,8 @@ import UI from '../features/ui'; // import Introduction from '../features/introduction'; import { preload } from '../actions/preload'; import { IntlProvider } from 'react-intl'; +import { previewState as previewMediaState } from 'soapbox/features/ui/components/media_modal'; +import { previewState as previewVideoState } from 'soapbox/features/ui/components/video_modal'; import ErrorBoundary from '../components/error_boundary'; import { fetchInstance } from 'soapbox/actions/instance'; import { fetchSoapboxConfig } from 'soapbox/actions/soapbox'; @@ -104,6 +106,10 @@ class SoapboxMount extends React.PureComponent { this.maybeUpdateMessages(prevProps); } + shouldUpdateScroll(_, { location }) { + return location.state !== previewMediaState && location.state !== previewVideoState; + } + render() { const { me, themeCss, locale, customCss } = this.props; if (me === null) return null; @@ -137,7 +143,7 @@ class SoapboxMount extends React.PureComponent { - + {!me && } diff --git a/app/soapbox/features/compose/components/compose_form.js b/app/soapbox/features/compose/components/compose_form.js index 502fbd7f8..0f34cc24d 100644 --- a/app/soapbox/features/compose/components/compose_form.js +++ b/app/soapbox/features/compose/components/compose_form.js @@ -314,10 +314,6 @@ export default class ComposeForm extends ImmutablePureComponent { />
-
- -
- + { !condensed &&
diff --git a/app/soapbox/features/compose/components/privacy_dropdown.js b/app/soapbox/features/compose/components/privacy_dropdown.js index 2e5f4e5f4..f6fd27d28 100644 --- a/app/soapbox/features/compose/components/privacy_dropdown.js +++ b/app/soapbox/features/compose/components/privacy_dropdown.js @@ -50,7 +50,7 @@ class PrivacyDropdownMenu extends React.PureComponent { const index = items.findIndex(item => { return (item.value === value); }); - let element; + let element = null; switch(e.key) { case 'Escape': @@ -60,34 +60,32 @@ class PrivacyDropdownMenu extends React.PureComponent { this.handleClick(e); break; case 'ArrowDown': - element = this.node.childNodes[index + 1]; - if (element) { - element.focus(); - this.props.onChange(element.getAttribute('data-index')); - } + element = this.node.childNodes[index + 1] || this.node.firstChild; break; case 'ArrowUp': - element = this.node.childNodes[index - 1]; - if (element) { - element.focus(); - this.props.onChange(element.getAttribute('data-index')); + element = this.node.childNodes[index - 1] || this.node.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = this.node.childNodes[index - 1] || this.node.lastChild; + } else { + element = this.node.childNodes[index + 1] || this.node.firstChild; } break; case 'Home': element = this.node.firstChild; - if (element) { - element.focus(); - this.props.onChange(element.getAttribute('data-index')); - } break; case 'End': element = this.node.lastChild; - if (element) { - element.focus(); - this.props.onChange(element.getAttribute('data-index')); - } break; } + + if (element) { + element.focus(); + this.props.onChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } } handleClick = e => { @@ -102,7 +100,7 @@ class PrivacyDropdownMenu extends React.PureComponent { componentDidMount() { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem) this.focusedItem.focus(); + if (this.focusedItem) this.focusedItem.focus({ preventScroll: true }); this.setState({ mounted: true }); } @@ -192,6 +190,9 @@ class PrivacyDropdown extends React.PureComponent { } } else { const { top } = target.getBoundingClientRect(); + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); this.setState({ open: !this.state.open }); } @@ -214,7 +215,25 @@ class PrivacyDropdown extends React.PureComponent { } } + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } this.setState({ open: false }); } @@ -240,6 +259,8 @@ class PrivacyDropdown extends React.PureComponent { active={open} inverted onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} style={{ height: null, lineHeight: '27px' }} />
diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.js index 5bac4fff9..03890cb26 100644 --- a/app/soapbox/features/status/index.js +++ b/app/soapbox/features/status/index.js @@ -263,6 +263,21 @@ class Status extends ImmutablePureComponent { this.props.dispatch(openModal('VIDEO', { media, time })); } + handleHotkeyOpenMedia = e => { + const { onOpenMedia, onOpenVideo } = this.props; + const status = this._properStatus(); + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(status.getIn(['media_attachments', 0]), 0); + } else { + onOpenMedia(status.get('media_attachments'), 0); + } + } + } + handleMuteClick = (account) => { this.props.dispatch(initMuteModal(account)); } @@ -548,6 +563,7 @@ class Status extends ImmutablePureComponent { openProfile: this.handleHotkeyOpenProfile, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, react: this.handleHotkeyReact, }; diff --git a/app/soapbox/features/ui/components/hotkeys_modal.js b/app/soapbox/features/ui/components/hotkeys_modal.js index c646a0923..6d1434135 100644 --- a/app/soapbox/features/ui/components/hotkeys_modal.js +++ b/app/soapbox/features/ui/components/hotkeys_modal.js @@ -62,12 +62,8 @@ class HotkeysModal extends ImmutablePureComponent { - x - - - - h - + a + @@ -78,6 +74,14 @@ class HotkeysModal extends ImmutablePureComponent { + + x + + + + h + + up, k @@ -106,10 +110,6 @@ class HotkeysModal extends ImmutablePureComponent { esc - - g + h - - @@ -119,6 +119,10 @@ class HotkeysModal extends ImmutablePureComponent { + + + + diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 35c768593..010242b0d 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -155,6 +155,7 @@ const keyMap = { goToRequests: 'g r', toggleHidden: 'x', toggleSensitive: 'h', + openMedia: 'a', }; class SwitchingColumnsArea extends React.PureComponent { diff --git a/app/soapbox/features/video/index.js b/app/soapbox/features/video/index.js index 2b70d6db3..286b9755e 100644 --- a/app/soapbox/features/video/index.js +++ b/app/soapbox/features/video/index.js @@ -461,7 +461,7 @@ class Video extends React.PureComponent {
- +
diff --git a/app/soapbox/reducers/modal.js b/app/soapbox/reducers/modal.js index 6572d619e..69a991fa7 100644 --- a/app/soapbox/reducers/modal.js +++ b/app/soapbox/reducers/modal.js @@ -10,7 +10,7 @@ export default function modal(state = initialState, action) { case MODAL_OPEN: return { modalType: action.modalType, modalProps: action.modalProps }; case MODAL_CLOSE: - return initialState; + return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state; default: return state; } diff --git a/app/styles/components/account-header.scss b/app/styles/components/account-header.scss index 55386987a..cc3cbd95e 100644 --- a/app/styles/components/account-header.scss +++ b/app/styles/components/account-header.scss @@ -221,6 +221,7 @@ } &:hover, + &:focus, &.active { border-bottom: 2px solid var(--primary-text-color); } diff --git a/app/styles/components/columns.scss b/app/styles/components/columns.scss index 5bd24e1e2..205546845 100644 --- a/app/styles/components/columns.scss +++ b/app/styles/components/columns.scss @@ -480,7 +480,8 @@ color: var(--primary-text-color--faint); background: transparent; - &:hover { + &:hover, + &:focus { color: hsla(var(--primary-text-color_hsl), 0.8); } diff --git a/app/styles/components/compose-form.scss b/app/styles/components/compose-form.scss index 9daba415c..23c2ae359 100644 --- a/app/styles/components/compose-form.scss +++ b/app/styles/components/compose-form.scss @@ -62,9 +62,8 @@ .emoji-picker-dropdown { position: absolute; - top: 5px; + top: 10px; right: 5px; - z-index: 1; } .compose-form__autosuggest-wrapper { @@ -141,7 +140,6 @@ } } - .emoji-picker-wrapper, .autosuggest-textarea__suggestions-wrapper { position: relative; height: 0; diff --git a/app/styles/ui.scss b/app/styles/ui.scss index f96f26aae..bf8dd9f7e 100644 --- a/app/styles/ui.scss +++ b/app/styles/ui.scss @@ -43,7 +43,7 @@ &:hover, &:active, &:focus { - color: var(--primary-text-color--faint); + color: var(--primary-text-color); } &.disabled { @@ -93,7 +93,7 @@ &:hover, &:active, &:focus { - color: var(--primary-text-color--faint); + color: var(--primary-text-color); transition: color 200ms ease-out; } @@ -148,10 +148,6 @@ padding: 20px; margin-bottom: 20px; - .emoji-picker-wrapper { - .emoji-picker-dropdown { top: 10px; } - } - .compose-form { flex: 1 1; padding: 0 0 0 20px !important; diff --git a/package.json b/package.json index cc252e867..6ce9d397e 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,8 @@ "webpack-bundle-analyzer": "^4.0.0", "webpack-cli": "^3.3.2", "webpack-merge": "^5.2.0", - "websocket.js": "^0.1.12" + "websocket.js": "^0.1.12", + "wicg-inert": "^3.1.1" }, "devDependencies": { "axios-mock-adapter": "^1.18.1", diff --git a/yarn.lock b/yarn.lock index 031ea8ab9..edbe92d7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13062,6 +13062,11 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +wicg-inert@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/wicg-inert/-/wicg-inert-3.1.1.tgz#b033fd4fbfb9e3fd709e5d84becbdf2e06e5c229" + integrity sha512-PhBaNh8ur9Xm4Ggy4umelwNIP6pPP1bv3EaWaKqfb/QNme2rdLjm7wIInvV4WhxVHhzA4Spgw9qNSqWtB/ca2A== + wide-align@^1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
g + h
g + n