From 6d2b0fa3f0f32c874863e7fe035270dc31cdcc58 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 6 Aug 2019 13:57:45 +0200 Subject: Refactor composer Dropdown's component a bit to make it closer to upstream --- .../glitch/features/compose/components/dropdown.js | 155 +++++++++------------ 1 file changed, 63 insertions(+), 92 deletions(-) (limited to 'app/javascript/flavours/glitch/features/compose') diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 764dcea69..6a5f4575e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -12,33 +12,71 @@ import DropdownMenu from './dropdown_menu'; import { isUserTouching } from 'flavours/glitch/util/is_mobile'; import { assignHandlers } from 'flavours/glitch/util/react_helpers'; -// Handlers. -const handlers = { +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { - // Closes the dropdown. - handleClose () { - this.setState({ open: false }); - }, + static propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + icon: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })).isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func, + }; + + state = { + needsModalUpdate: false, + open: false, + placement: 'bottom', + }; - // The enter key toggles the dropdown's open state, and the escape - // key closes it. - handleKeyDown ({ key }) { - const { - handleClose, - handleToggle, - } = this.handlers; - switch (key) { + // Toggles opening and closing the dropdown. + handleToggle = ({ target }) => { + const { onModalOpen } = this.props; + const { open } = this.state; + + if (isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + const modal = this.handleMakeModal(); + if (modal && onModalOpen) { + onModalOpen(modal); + } + } + } else { + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open }); + } + } + + handleKeyDown = (e) => { + switch (e.key) { case 'Enter': - handleToggle(key); + this.handleToggle(key); break; case 'Escape': - handleClose(); + this.handleClose(); break; } - }, + } + + handleClose = () => { + this.setState({ open: false }); + } // Creates an action modal object. - handleMakeModal () { + handleMakeModal = () => { const component = this; const { items, @@ -76,85 +114,37 @@ const handlers = { }) ), }; - }, - - // Toggles opening and closing the dropdown. - handleToggle ({ target }) { - const { handleMakeModal } = this.handlers; - const { onModalOpen } = this.props; - const { open } = this.state; - - // If this is a touch device, we open a modal instead of the - // dropdown. - if (isUserTouching()) { - - // This gets the modal to open. - const modal = handleMakeModal(); - - // If we can, we then open the modal. - if (modal && onModalOpen) { - onModalOpen(modal); - return; - } - } - - const { top } = target.getBoundingClientRect(); - this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); - // Otherwise, we just set our state to open. - this.setState({ open: !open }); - }, + } // If our modal is open and our props update, we need to also update // the modal. - handleUpdate () { - const { handleMakeModal } = this.handlers; + handleUpdate = () => { const { onModalOpen } = this.props; const { needsModalUpdate } = this.state; // Gets our modal object. - const modal = handleMakeModal(); + const modal = this.handleMakeModal(); // Reopens the modal with the new object. if (needsModalUpdate && modal && onModalOpen) { onModalOpen(modal); } - }, -}; - -// The component. -export default class ComposerOptionsDropdown extends React.PureComponent { - - // Constructor. - constructor (props) { - super(props); - assignHandlers(this, handlers); - this.state = { - needsModalUpdate: false, - open: false, - placement: 'bottom', - }; } // Updates our modal as necessary. componentDidUpdate (prevProps) { - const { handleUpdate } = this.handlers; const { items } = this.props; const { needsModalUpdate } = this.state; if (needsModalUpdate && items.find( (item, i) => item.on !== prevProps.items[i].on )) { - handleUpdate(); + this.handleUpdate(); this.setState({ needsModalUpdate: false }); } } // Rendering. render () { - const { - handleClose, - handleKeyDown, - handleToggle, - } = this.handlers; const { active, disabled, @@ -175,7 +165,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { return (
@@ -209,22 +199,3 @@ export default class ComposerOptionsDropdown extends React.PureComponent { } } - -// Props. -ComposerOptionsDropdown.propTypes = { - active: PropTypes.bool, - disabled: PropTypes.bool, - icon: PropTypes.string, - items: PropTypes.arrayOf(PropTypes.shape({ - icon: PropTypes.string, - meta: PropTypes.node, - name: PropTypes.string.isRequired, - on: PropTypes.bool, - text: PropTypes.node, - })).isRequired, - onChange: PropTypes.func, - onModalClose: PropTypes.func, - onModalOpen: PropTypes.func, - title: PropTypes.string, - value: PropTypes.string, -}; -- cgit From d10f6036cfeebec5b2c160db8659d2c19d29fe9c Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 6 Aug 2019 14:18:09 +0200 Subject: Implement keyboard navigation in glitch-soc composer --- .../flavours/glitch/components/icon_button.js | 9 + .../glitch/features/compose/components/dropdown.js | 44 +++- .../features/compose/components/dropdown_menu.js | 254 ++++++++++++--------- 3 files changed, 194 insertions(+), 113 deletions(-) (limited to 'app/javascript/flavours/glitch/features/compose') diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index c1e2f664c..521353238 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -13,6 +13,7 @@ export default class IconButton extends React.PureComponent { onClick: PropTypes.func, onMouseDown: PropTypes.func, onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, pressed: PropTypes.bool, @@ -45,6 +46,12 @@ export default class IconButton extends React.PureComponent { } } + handleKeyPress = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + } + handleMouseDown = (e) => { if (!this.props.disabled && this.props.onMouseDown) { this.props.onMouseDown(e); @@ -121,6 +128,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} onMouseDown={this.handleMouseDown} onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} style={style} tabIndex={tabIndex} disabled={disabled} @@ -142,6 +150,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} onMouseDown={this.handleMouseDown} onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} style={style} tabIndex={tabIndex} disabled={disabled} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 6a5f4575e..60035b705 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -36,11 +36,12 @@ export default class ComposerOptionsDropdown extends React.PureComponent { state = { needsModalUpdate: false, open: false, + openedViaKeyboard: undefined, placement: 'bottom', }; // Toggles opening and closing the dropdown. - handleToggle = ({ target }) => { + handleToggle = ({ target, type }) => { const { onModalOpen } = this.props; const { open } = this.state; @@ -55,23 +56,52 @@ export default class ComposerOptionsDropdown 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 }); + this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' }); } } handleKeyDown = (e) => { switch (e.key) { - case 'Enter': - this.handleToggle(key); - break; case 'Escape': this.handleClose(); break; } } + 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.handleToggle(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + } + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } this.setState({ open: false }); } @@ -174,6 +204,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent { icon={icon} inverted onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} size={18} style={{ height: null, @@ -192,6 +225,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { onChange={onChange} onClose={this.handleClose} value={value} + openedViaKeyboard={this.state.openedViaKeyboard} />
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js index 19d35a8f4..f812be7a9 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -14,91 +14,6 @@ import { withPassive } from 'flavours/glitch/util/dom_helpers'; import Motion from 'flavours/glitch/util/optional_motion'; import { assignHandlers } from 'flavours/glitch/util/react_helpers'; -class ComposerOptionsDropdownContentItem extends ImmutablePureComponent { - - static propTypes = { - active: PropTypes.bool, - name: PropTypes.string, - onChange: PropTypes.func, - onClose: PropTypes.func, - options: PropTypes.shape({ - icon: PropTypes.string, - meta: PropTypes.node, - on: PropTypes.bool, - text: PropTypes.node, - }), - }; - - handleActivate = (e) => { - const { - name, - onChange, - onClose, - options: { on }, - } = this.props; - - // If the escape key was pressed, we close the dropdown. - if (e.key === 'Escape' && onClose) { - onClose(); - - // Otherwise, we both close the dropdown and change the value. - } else if (onChange && (!e.key || e.key === 'Enter')) { - e.preventDefault(); // Prevents change in focus on click - if ((on === null || typeof on === 'undefined') && onClose) { - onClose(); - } - onChange(name); - } - } - - // Rendering. - render () { - const { - active, - options: { - icon, - meta, - on, - text, - }, - } = this.props; - const computedClass = classNames('composer--options--dropdown--content--item', { - active, - lengthy: meta, - 'toggled-off': !on && on !== null && typeof on !== 'undefined', - 'toggled-on': on, - 'with-icon': icon, - }); - - let prefix = null; - - if (on !== null && typeof on !== 'undefined') { - prefix = ; - } else if (icon) { - prefix = - } - - // The result. - return ( -
- {prefix} - -
- {text} - {meta} -
-
- ); - } - -}; - // The spring to use with our motion. const springMotion = spring(1, { damping: 35, @@ -116,10 +31,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent on: PropTypes.bool, text: PropTypes.node, })), - onChange: PropTypes.func, - onClose: PropTypes.func, + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, style: PropTypes.object, value: PropTypes.string, + openedViaKeyboard: PropTypes.bool, }; static defaultProps = { @@ -128,14 +44,13 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent state = { mounted: false, + value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined, }; // When the document is clicked elsewhere, we close the dropdown. - handleDocumentClick = ({ target }) => { - const { node } = this; - const { onClose } = this.props; - if (onClose && node && !node.contains(target)) { - onClose(); + handleDocumentClick = (e) => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); } } @@ -148,6 +63,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent componentDidMount () { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('touchend', this.handleDocumentClick, withPassive); + if (this.focusedItem) { + this.focusedItem.focus(); + } else { + this.node.firstChild.focus(); + } this.setState({ mounted: true }); } @@ -157,6 +77,138 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent document.removeEventListener('touchend', this.handleDocumentClick, withPassive); } + handleClick = (e) => { + const name = e.currentTarget.getAttribute('data-index'); + + const { + onChange, + onClose, + items, + } = this.props; + + const { on } = this.props.items.find(item => item.name === name); + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined')) { + onClose(); + } + onChange(name); + } + + // Handle changes differently whether the dropdown is a list of options or actions + handleChange = (name) => { + if (this.props.value) { + this.props.onChange(name); + } else { + this.setState({ value: name }); + } + } + + handleKeyDown = e => { + const { items } = this.props; + const name = e.currentTarget.getAttribute('data-index'); + const index = items.findIndex(item => { + return (item.name === name); + }); + let element; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + case ' ': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1]; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1]; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + 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; + } + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + break; + case 'Home': + element = this.node.firstChild; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + case 'End': + element = this.node.lastChild; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + } + } + + setFocusRef = c => { + this.focusedItem = c; + } + + renderItem = (item) => { + const { name, icon, meta, on, text } = item; + + const active = (name === (this.props.value || this.state.value)); + + const computedClass = classNames('composer--options--dropdown--content--item', { + active, + lengthy: meta, + 'toggled-off': !on && on !== null && typeof on !== 'undefined', + 'toggled-on': on, + 'with-icon': icon, + }); + + let prefix = null; + + if (on !== null && typeof on !== 'undefined') { + prefix = ; + } else if (icon) { + prefix = + } + + return ( +
+ {prefix} + +
+ {text} + {meta} +
+
+ ); + } + // Rendering. render () { const { mounted } = this.state; @@ -165,7 +217,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent onChange, onClose, style, - value, } = this.props; // The result. @@ -189,27 +240,14 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
- {items ? items.map( - ({ - name, - ...rest - }) => ( - - ) - ) : null} + {!!items && items.map(item => this.renderItem(item))}
)} -- cgit