logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: b7d47c2aef23ec6219b6fb0038bc64629b285701
parent: 6270f9ce340ba8e120f743ff9bf1d76224871ca1
Author: Sorin Davidoi <sorin.davidoi@gmail.com>
Date:   Fri, 28 Jul 2017 04:37:30 +0200

Improve accessibility (part 4) (#4408)

* fix(dropdown_menu): Keyboard navigation

* fix(icon_button): Add aria-pressed attribute

* fix(privacy_dropdown): Make accessible

* fix(emoji_picker_dropdown): Make accessible

* fix(icon_button): Support tabIndex

* fix(actions_modal): Remove icon from tab order

* fix(dropdown_menu): Add role=group

* fix(setting_toggle): Toggle via space key

* fix(dropdown_menu): Remove redundant handling of Space key

* fix(emoji_picker_dropdown): Remove redundant Space key handling

* fix(privacy_dropdown): Remove redundant Space key handling

* fix(status): Switch to article and add aria-posinset, aria-setsize

* fix(status_list): Use role=feed and pass more ARIA props to Status

* chore(eslint): jsx-a11y/role-supports-aria-props

Diffstat:

M.eslintrc.yml2+-
Mapp/javascript/mastodon/components/dropdown_menu.js22+++++++++++++++++-----
Mapp/javascript/mastodon/components/icon_button.js5+++++
Mapp/javascript/mastodon/components/status.js21++++++++++++---------
Mapp/javascript/mastodon/components/status_list.js6+++---
Mapp/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js23++++++++++++++++++++---
Mapp/javascript/mastodon/features/compose/components/privacy_dropdown.js16++++++++++------
Mapp/javascript/mastodon/features/notifications/components/setting_toggle.js8+++++++-
Mapp/javascript/mastodon/features/ui/components/actions_modal.js2+-
9 files changed, 76 insertions(+), 29 deletions(-)

diff --git a/.eslintrc.yml b/.eslintrc.yml @@ -121,6 +121,6 @@ rules: jsx-a11y/onclick-has-focus: warn jsx-a11y/onclick-has-role: warn jsx-a11y/role-has-required-aria-props: warn - jsx-a11y/role-supports-aria-props: warn + jsx-a11y/role-supports-aria-props: off jsx-a11y/scope: warn jsx-a11y/tabindex-no-positive: warn diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js @@ -74,6 +74,18 @@ export default class DropdownMenu extends React.PureComponent { handleHide = () => this.setState({ expanded: false }) + handleToggle = (e) => { + if (e.key === 'Enter') { + if (this.props.isUserTouching()) { + this.handleShow(); + } else { + this.setState({ expanded: !this.state.expanded }); + } + } else if (e.key === 'Escape') { + this.setState({ expanded: false }); + } + } + renderItem = (item, i) => { if (item === null) { return <li key={`sep-${i}`} className='dropdown__sep' />; @@ -83,7 +95,7 @@ export default class DropdownMenu extends React.PureComponent { return ( <li className='dropdown__content-list-item' key={`${text}-${i}`}> - <a href={href} target='_blank' rel='noopener' onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'> + <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'> {text} </a> </li> @@ -107,7 +119,7 @@ export default class DropdownMenu extends React.PureComponent { } const dropdownItems = expanded && ( - <ul className='dropdown__content-list'> + <ul role='group' className='dropdown__content-list' onClick={this.handleHide}> {items.map(this.renderItem)} </ul> ); @@ -115,14 +127,14 @@ export default class DropdownMenu extends React.PureComponent { // No need to render the actual dropdown if we use the modal. If we // don't render anything <Dropdow /> breaks, so we just put an empty div. const dropdownContent = !isUserTouching ? ( - <DropdownContent className={directionClass}> + <DropdownContent className={directionClass} > {dropdownItems} </DropdownContent> ) : <div />; return ( - <Dropdown ref={this.setRef} active={isUserTouching ? false : undefined} onShow={this.handleShow} onHide={this.handleHide}> - <DropdownTrigger className='icon-button' style={iconStyle} aria-label={ariaLabel}> + <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}> + <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-pressed={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}> <i className={iconClassname} aria-hidden /> </DropdownTrigger> diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js @@ -12,12 +12,14 @@ export default class IconButton extends React.PureComponent { onClick: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, + pressed: PropTypes.bool, style: PropTypes.object, activeStyle: PropTypes.object, disabled: PropTypes.bool, inverted: PropTypes.bool, animate: PropTypes.bool, overlay: PropTypes.bool, + tabIndex: PropTypes.string, }; static defaultProps = { @@ -26,6 +28,7 @@ export default class IconButton extends React.PureComponent { disabled: false, animate: false, overlay: false, + tabIndex: '0', }; handleClick = (e) => { @@ -73,10 +76,12 @@ export default class IconButton extends React.PureComponent { {({ rotate }) => <button aria-label={this.props.title} + aria-pressed={this.props.pressed} title={this.props.title} className={classes.join(' ')} onClick={this.handleClick} style={style} + tabIndex={this.props.tabIndex} > <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> </button> diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js @@ -41,6 +41,8 @@ export default class Status extends ImmutablePureComponent { autoPlayGif: PropTypes.bool, muted: PropTypes.bool, intersectionObserverWrapper: PropTypes.object, + index: PropTypes.oneOf(PropTypes.string, PropTypes.number), + listLength: PropTypes.oneOf(PropTypes.string, PropTypes.number), }; state = { @@ -59,6 +61,7 @@ export default class Status extends ImmutablePureComponent { 'boostModal', 'autoPlayGif', 'muted', + 'listLength', ] updateOnStates = ['isExpanded'] @@ -67,8 +70,8 @@ export default class Status extends ImmutablePureComponent { if (!nextState.isIntersecting && nextState.isHidden) { // It's only if we're not intersecting (i.e. offscreen) and isHidden is true // that either "isIntersecting" or "isHidden" matter, and then they're - // the only things that matter. - return this.state.isIntersecting || !this.state.isHidden; + // the only things that matter (and updated ARIA attributes). + return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; } else if (nextState.isIntersecting && !this.state.isIntersecting) { // If we're going from a non-intersecting state to an intersecting state, // (i.e. offscreen to onscreen), then we definitely need to re-render @@ -169,7 +172,7 @@ export default class Status extends ImmutablePureComponent { // Exclude intersectionObserverWrapper from `other` variable // because intersection is managed in here. - const { status, account, intersectionObserverWrapper, ...other } = this.props; + const { status, account, intersectionObserverWrapper, index, listLength, ...other } = this.props; const { isExpanded, isIntersecting, isHidden } = this.state; if (status === null) { @@ -178,10 +181,10 @@ export default class Status extends ImmutablePureComponent { if (!isIntersecting && isHidden) { return ( - <div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}> + <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}> {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('content')} - </div> + </article> ); } @@ -195,14 +198,14 @@ export default class Status extends ImmutablePureComponent { const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; return ( - <div className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} > + <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength}> <div className='status__prepend'> <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} /> </div> <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} /> - </div> + </article> ); } @@ -231,7 +234,7 @@ export default class Status extends ImmutablePureComponent { } return ( - <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}> + <article aria-posinset={index} aria-setsize={listLength} className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}> <div className='status__info'> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> @@ -249,7 +252,7 @@ export default class Status extends ImmutablePureComponent { {media} <StatusActionBar {...this.props} /> - </div> + </article> ); } diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js @@ -113,11 +113,11 @@ export default class StatusList extends ImmutablePureComponent { if (isLoading || statusIds.size > 0 || !emptyMessage) { scrollableArea = ( <div className='scrollable' ref={this.setRef}> - <div className='status-list'> + <div role='feed' className='status-list'> {prepend} - {statusIds.map((statusId) => { - return <StatusContainer key={statusId} id={statusId} intersectionObserverWrapper={this.intersectionObserverWrapper} />; + {statusIds.map((statusId, index) => { + return <StatusContainer key={statusId} id={statusId} index={index} listLength={statusIds.size} intersectionObserverWrapper={this.intersectionObserverWrapper} />; })} {loadMore} diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -65,6 +65,22 @@ export default class EmojiPickerDropdown extends React.PureComponent { this.setState({ active: false }); } + onToggle = (e) => { + if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (this.state.active) { + this.onHideDropdown(); + } else { + this.onShowDropdown(); + } + } + } + + onEmojiPickerKeyDown = (e) => { + if (e.key === 'Escape') { + this.onHideDropdown(); + } + } + render () { const { intl } = this.props; @@ -104,10 +120,11 @@ export default class EmojiPickerDropdown extends React.PureComponent { }; const { active, loading } = this.state; + const title = intl.formatMessage(messages.emoji); return ( - <Dropdown ref={this.setRef} className='emoji-picker__dropdown' onShow={this.onShowDropdown} onHide={this.onHideDropdown}> - <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)}> + <Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}> + <DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-pressed={active} role='button' onKeyDown={this.onToggle} tabIndex={0} > <img className={`emojione ${active && loading ? 'pulse-loading' : ''}`} alt='🙂' @@ -118,7 +135,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { <DropdownContent className='dropdown__left'> { this.state.active && !this.state.loading && - (<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} categories={categories} search />) + (<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} onKeyDown={this.onEmojiPickerKeyDown} categories={categories} search />) } </DropdownContent> </Dropdown> diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -60,10 +60,14 @@ export default class PrivacyDropdown extends React.PureComponent { } handleClick = (e) => { - const value = e.currentTarget.getAttribute('data-index'); - e.preventDefault(); - this.setState({ open: false }); - this.props.onChange(value); + if (e.key === 'Escape') { + this.setState({ open: false }); + } else if (!e.key || e.key === 'Enter') { + const value = e.currentTarget.getAttribute('data-index'); + e.preventDefault(); + this.setState({ open: false }); + this.props.onChange(value); + } } onGlobalClick = (e) => { @@ -105,10 +109,10 @@ export default class PrivacyDropdown extends React.PureComponent { return ( <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> - <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> + <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} pressed={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> <div className='privacy-dropdown__dropdown'> {open && this.options.map(item => - <div role='button' tabIndex='0' key={item.value} data-index={item.value} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> + <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> <div className='privacy-dropdown__option__content'> <strong>{item.text}</strong> diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -18,13 +18,19 @@ export default class SettingToggle extends React.PureComponent { this.props.onChange(this.props.settingKey, target.checked); } + onKeyDown = e => { + if (e.key === ' ') { + this.props.onChange(this.props.settingKey, !e.target.checked); + } + } + render () { const { prefix, settings, settingKey, label, meta } = this.props; const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} /> + <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> {meta && <span className='setting-meta__label'>{meta}</span>} </div> diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js @@ -24,7 +24,7 @@ export default class ActionsModal extends ImmutablePureComponent { return ( <li key={`${text}-${i}`}> <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={active && 'active'}> - {icon && <IconButton title={text} icon={icon} />} + {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />} <div> <div>{text}</div> <div>{meta}</div>