Implement keyboard navigation in glitch-soc composer

This commit is contained in:
Thibaut Girka 2019-08-06 14:18:09 +02:00
parent 3dedb60da6
commit 558628eb6d
3 changed files with 194 additions and 113 deletions

View file

@ -13,6 +13,7 @@ export default class IconButton extends React.PureComponent {
onClick: PropTypes.func, onClick: PropTypes.func,
onMouseDown: PropTypes.func, onMouseDown: PropTypes.func,
onKeyDown: PropTypes.func, onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
size: PropTypes.number, size: PropTypes.number,
active: PropTypes.bool, active: PropTypes.bool,
pressed: 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) => { handleMouseDown = (e) => {
if (!this.props.disabled && this.props.onMouseDown) { if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e); this.props.onMouseDown(e);
@ -121,6 +128,7 @@ export default class IconButton extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
style={style} style={style}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}
@ -142,6 +150,7 @@ export default class IconButton extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
style={style} style={style}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}

View file

@ -36,11 +36,12 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
state = { state = {
needsModalUpdate: false, needsModalUpdate: false,
open: false, open: false,
openedViaKeyboard: undefined,
placement: 'bottom', placement: 'bottom',
}; };
// Toggles opening and closing the dropdown. // Toggles opening and closing the dropdown.
handleToggle = ({ target }) => { handleToggle = ({ target, type }) => {
const { onModalOpen } = this.props; const { onModalOpen } = this.props;
const { open } = this.state; const { open } = this.state;
@ -55,23 +56,52 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
} }
} else { } else {
const { top } = target.getBoundingClientRect(); const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); 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) => { handleKeyDown = (e) => {
switch (e.key) { switch (e.key) {
case 'Enter':
this.handleToggle(key);
break;
case 'Escape': case 'Escape':
this.handleClose(); this.handleClose();
break; 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 = () => { handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ open: false }); this.setState({ open: false });
} }
@ -174,6 +204,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
icon={icon} icon={icon}
inverted inverted
onClick={this.handleToggle} onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
size={18} size={18}
style={{ style={{
height: null, height: null,
@ -192,6 +225,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
onChange={onChange} onChange={onChange}
onClose={this.handleClose} onClose={this.handleClose}
value={value} value={value}
openedViaKeyboard={this.state.openedViaKeyboard}
/> />
</Overlay> </Overlay>
</div> </div>

View file

@ -14,91 +14,6 @@ import { withPassive } from 'flavours/glitch/util/dom_helpers';
import Motion from 'flavours/glitch/util/optional_motion'; import Motion from 'flavours/glitch/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/util/react_helpers'; 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 = <Toggle checked={on} onChange={this.handleActivate} />;
} else if (icon) {
prefix = <Icon className='icon' fullwidth icon={icon} />
}
// The result.
return (
<div
className={computedClass}
onClick={this.handleActivate}
onKeyDown={this.handleActivate}
role='button'
tabIndex='0'
>
{prefix}
<div className='content'>
<strong>{text}</strong>
{meta}
</div>
</div>
);
}
};
// The spring to use with our motion. // The spring to use with our motion.
const springMotion = spring(1, { const springMotion = spring(1, {
damping: 35, damping: 35,
@ -116,10 +31,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
on: PropTypes.bool, on: PropTypes.bool,
text: PropTypes.node, text: PropTypes.node,
})), })),
onChange: PropTypes.func, onChange: PropTypes.func.isRequired,
onClose: PropTypes.func, onClose: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
value: PropTypes.string, value: PropTypes.string,
openedViaKeyboard: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -128,14 +44,13 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
state = { state = {
mounted: false, mounted: false,
value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
}; };
// When the document is clicked elsewhere, we close the dropdown. // When the document is clicked elsewhere, we close the dropdown.
handleDocumentClick = ({ target }) => { handleDocumentClick = (e) => {
const { node } = this; if (this.node && !this.node.contains(e.target)) {
const { onClose } = this.props; this.props.onClose();
if (onClose && node && !node.contains(target)) {
onClose();
} }
} }
@ -148,6 +63,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
componentDidMount () { componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, withPassive); document.addEventListener('touchend', this.handleDocumentClick, withPassive);
if (this.focusedItem) {
this.focusedItem.focus();
} else {
this.node.firstChild.focus();
}
this.setState({ mounted: true }); this.setState({ mounted: true });
} }
@ -157,6 +77,138 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
document.removeEventListener('touchend', this.handleDocumentClick, withPassive); 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 = <Toggle checked={on} onChange={this.handleClick} />;
} else if (icon) {
prefix = <Icon className='icon' fullwidth icon={icon} />
}
return (
<div
className={computedClass}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
role='option'
tabIndex='0'
key={name}
data-index={name}
ref={active ? this.setFocusRef : null}
>
{prefix}
<div className='content'>
<strong>{text}</strong>
{meta}
</div>
</div>
);
}
// Rendering. // Rendering.
render () { render () {
const { mounted } = this.state; const { mounted } = this.state;
@ -165,7 +217,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
onChange, onChange,
onClose, onClose,
style, style,
value,
} = this.props; } = this.props;
// The result. // The result.
@ -189,27 +240,14 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
<div <div
className='composer--options--dropdown--content' className='composer--options--dropdown--content'
ref={this.handleRef} ref={this.handleRef}
role='listbox'
style={{ style={{
...style, ...style,
opacity: opacity, opacity: opacity,
transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
}} }}
> >
{items ? items.map( {!!items && items.map(item => this.renderItem(item))}
({
name,
...rest
}) => (
<ComposerOptionsDropdownContentItem
active={name === value}
key={name}
name={name}
onChange={onChange}
onClose={onClose}
options={rest}
/>
)
) : null}
</div> </div>
)} )}
</Motion> </Motion>