Merge branch 'userlist-accessibility' of github.com:KDSBrowne/bigbluebutton into userlist-accessibility

This commit is contained in:
Anton Georgiev 2017-05-17 11:27:17 -04:00
commit a89c70a04c
14 changed files with 342 additions and 88 deletions

View File

@ -18,6 +18,7 @@ const propTypes = {
class EmojiMenu extends Component {
constructor(props) {
super(props);
}
render() {
@ -28,8 +29,8 @@ class EmojiMenu extends Component {
} = this.props;
return (
<Dropdown ref="dropdown">
<DropdownTrigger>
<Dropdown autoFocus={true}>
<DropdownTrigger placeInTabOrder={true}>
<Button
role="button"
label={intl.formatMessage(intlMessages.statusTriggerLabel)}
@ -57,54 +58,63 @@ class EmojiMenu extends Component {
label={intl.formatMessage(intlMessages.raiseLabel)}
description={intl.formatMessage(intlMessages.raiseDesc)}
onClick={() => actions.setEmojiHandler('raiseHand')}
tabIndex={-1}
/>
<DropdownListItem
icon="happy"
label={intl.formatMessage(intlMessages.happyLabel)}
description={intl.formatMessage(intlMessages.happyDesc)}
onClick={() => actions.setEmojiHandler('happy')}
tabIndex={-1}
/>
<DropdownListItem
icon="undecided"
label={intl.formatMessage(intlMessages.undecidedLabel)}
description={intl.formatMessage(intlMessages.undecidedDesc)}
onClick={() => actions.setEmojiHandler('neutral')}
tabIndex={-1}
/>
<DropdownListItem
icon="sad"
label={intl.formatMessage(intlMessages.sadLabel)}
description={intl.formatMessage(intlMessages.sadDesc)}
onClick={() => actions.setEmojiHandler('sad')}
tabIndex={-1}
/>
<DropdownListItem
icon="confused"
label={intl.formatMessage(intlMessages.confusedLabel)}
description={intl.formatMessage(intlMessages.confusedDesc)}
onClick={() => actions.setEmojiHandler('confused')}
tabIndex={-1}
/>
<DropdownListItem
icon="time"
label={intl.formatMessage(intlMessages.awayLabel)}
description={intl.formatMessage(intlMessages.awayDesc)}
onClick={() => actions.setEmojiHandler('away')}
tabIndex={-1}
/>
<DropdownListItem
icon="thumbs_up"
label={intl.formatMessage(intlMessages.thumbsupLabel)}
description={intl.formatMessage(intlMessages.thumbsupDesc)}
onClick={() => actions.setEmojiHandler('thumbsUp')}
tabIndex={-1}
/>
<DropdownListItem
icon="thumbs_down"
label={intl.formatMessage(intlMessages.thumbsdownLabel)}
description={intl.formatMessage(intlMessages.thumbsdownDesc)}
onClick={() => actions.setEmojiHandler('thumbsDown')}
tabIndex={-1}
/>
<DropdownListItem
icon="applause"
label={intl.formatMessage(intlMessages.applauseLabel)}
description={intl.formatMessage(intlMessages.applauseDesc)}
onClick={() => actions.setEmojiHandler('applause')}
tabIndex={-1}
/>
<DropdownListSeparator />
<DropdownListItem
@ -112,6 +122,7 @@ class EmojiMenu extends Component {
label={intl.formatMessage(intlMessages.clearLabel)}
description={intl.formatMessage(intlMessages.clearDesc)}
onClick={() => actions.setEmojiHandler('none')}
tabIndex={-1}
/>
</DropdownList>
</DropdownContent>

View File

@ -83,15 +83,18 @@ class Dropdown extends Component {
handleShow() {
this.setState({ isOpen: true }, this.handleStateCallback);
const contentElement = findDOMNode(this.refs.content);
contentElement.querySelector(FOCUSABLE_CHILDREN).focus();
}
handleHide() {
const { autoFocus } = this.props;
this.setState({ isOpen: false }, this.handleStateCallback);
const triggerElement = findDOMNode(this.refs.trigger);
triggerElement.focus();
if (autoFocus) {
const triggerElement = findDOMNode(this.refs.trigger);
triggerElement.focus();
}
}
componentDidMount () {
@ -122,7 +125,14 @@ class Dropdown extends Component {
}
render() {
const { children, className, style, intl } = this.props;
const {
children,
className,
style, intl,
hasPopup,
ariaLive,
ariaRelevant,
} = this.props;
let trigger = children.find(x => x.type === DropdownTrigger);
let content = children.find(x => x.type === DropdownContent);
@ -143,7 +153,12 @@ class Dropdown extends Component {
});
return (
<div style={style} className={cx(styles.dropdown, className)}>
<div
style={style}
className={cx(styles.dropdown, className)}
aria-live={ariaLive}
aria-relevant={ariaRelevant}
aria-haspopup={hasPopup}>
{trigger}
{content}
{ this.state.isOpen ?

View File

@ -78,7 +78,7 @@ export default class DropdownList extends Component {
nextActiveItemIndex = this.childrenRefs.length - 1;
}
if ([KEY_CODES.TAB, KEY_CODES.ESCAPE].includes(event.which)) {
if ([KEY_CODES.ESCAPE].includes(event.which)) {
nextActiveItemIndex = 0;
dropdownHide();
}

View File

@ -29,14 +29,16 @@ export default class DropdownListItem extends Component {
render() {
const { label, description, children, injectRef, tabIndex, onClick, onKeyDown,
className, style, separator, intl, } = this.props;
className, style, separator, intl, placeInTabOrder, } = this.props;
let index = (placeInTabOrder) ? 0 : -1;
return (
<li
ref={injectRef}
onClick={onClick}
onKeyDown={onKeyDown}
tabIndex={tabIndex}
tabIndex={index}
aria-labelledby={this.labelID}
aria-describedby={this.descID}
className={cx(styles.item, className)}

View File

@ -1,22 +1,22 @@
import React, { Component, PropTypes } from 'react';
import styles from '../styles';
const propTypes = {
description: PropTypes.string,
};
export default class DropdownListTitle extends Component {
render() {
const { intl, description } = this.props;
return (
<div>
<li className={styles.title} aria-describedby="labelContext">{this.props.children}</li>
<div id="labelContext" aria-label={description}></div>
</div>
);
}
}
DropdownListTitle.propTypes = propTypes;
import React, { Component, PropTypes } from 'react';
import styles from '../styles';
const propTypes = {
description: PropTypes.string,
};
export default class DropdownListTitle extends Component {
render() {
const { intl, description } = this.props;
return (
<div>
<li className={styles.title} aria-describedby="labelContext">{this.props.children}</li>
<div id="labelContext" aria-label={description}></div>
</div>
);
}
}
DropdownListTitle.propTypes = propTypes;

View File

@ -41,14 +41,16 @@ export default class DropdownTrigger extends Component {
}
render() {
const { children, style, className, } = this.props;
const { children, style, className, placeInTabOrder, } = this.props;
const TriggerComponent = React.Children.only(children);
let index = (placeInTabOrder) ? '0' : '-1';
const TriggerComponentBounded = React.cloneElement(children, {
onClick: this.handleClick,
onKeyDown: this.handleKeyDown,
'aria-haspopup': true,
tabIndex: '0',
tabIndex: index,
style: style,
className: cx(children.props.className, className),
});

View File

@ -83,8 +83,8 @@ class SettingsDropdown extends Component {
}
return (
<Dropdown ref="dropdown">
<DropdownTrigger>
<Dropdown autoFocus={true}>
<DropdownTrigger placeInTabOrder={true}>
<Button
label={intl.formatMessage(intlMessages.optionsLabel)}
icon="more"

View File

@ -33,7 +33,7 @@ export default class UserAvatar extends Component {
return (
<div className={user.isOnline ? styles.userAvatar : styles.userLogout}
style={avatarStyles}>
style={avatarStyles} aria-hidden="true">
<div>
{this.renderAvatarContent()}
</div>

View File

@ -43,6 +43,7 @@ class ChatListItem extends Component {
openChat,
compact,
intl,
tabIndex,
} = this.props;
const linkPath = [PRIVATE_CHAT_PATH, chat.id].join('');
@ -57,12 +58,13 @@ class ChatListItem extends Component {
}
return (
<li className={cx(styles.chatListItem, linkClasses)}>
<Link
to={linkPath}
className={styles.chatListItemLink}
className={cx(styles.chatListItem, linkClasses)}
role="button"
aria-expanded={isCurrentChat}>
aria-expanded={isCurrentChat}
tabIndex={tabIndex}>
<div className={styles.chatListItemLink}>
{chat.icon ? this.renderChatIcon() : this.renderChatAvatar()}
<div className={styles.chatName}>
{!compact ? <span className={styles.chatNameMain}>{chat.name}</span> : null }
@ -78,8 +80,8 @@ class ChatListItem extends Component {
</div>
</div>
: null}
</div>
</Link>
</li>
);
}

View File

@ -4,6 +4,7 @@
@extend %list-item;
cursor: pointer;
padding: 0;
text-decoration: none;
}
.chatListItemLink {

View File

@ -6,6 +6,7 @@ import cx from 'classnames';
import { defineMessages, injectIntl } from 'react-intl';
import UserListItem from './user-list-item/component.jsx';
import ChatListItem from './chat-list-item/component.jsx';
import KEY_CODES from '/imports/utils/keyCodes';
const propTypes = {
openChats: PropTypes.array.isRequired,
@ -30,6 +31,128 @@ class UserList extends Component {
this.state = {
compact: this.props.compact,
};
this.rovingIndex = this.rovingIndex.bind(this);
this.focusList = this.focusList.bind(this);
this.focusListItem = this.focusListItem.bind(this);
this.counter = -1;
}
focusList(activeElement, list) {
activeElement.tabIndex = -1;
this.counter = 0;
list.tabIndex = 0;
list.focus();
}
focusListItem(active, direction, element, count) {
function select() {
element.tabIndex = 0;
element.focus();
}
active.tabIndex = -1;
switch (direction) {
case 'down':
element.childNodes[this.counter].tabIndex = 0;
element.childNodes[this.counter].focus();
this.counter++;
break;
case 'up':
this.counter--;
element.childNodes[this.counter].tabIndex = 0;
element.childNodes[this.counter].focus();
break;
case 'upLoopUp':
case 'upLoopDown':
this.counter = count - 1;
select();
break;
case 'downLoopDown':
this.counter = -1;
select();
break;
case 'downLoopUp':
this.counter = 1;
select();
break;
}
}
rovingIndex(...Args) {
const { users, openChats } = this.props;
let active = document.activeElement;
let list;
let items;
let count;
switch (Args[1]) {
case 'users':
list = this._usersList;
items = this._userItems;
count = users.length;
break;
case 'messages':
list = this._msgsList;
items = this._msgItems;
count = openChats.length;
break;
}
if (Args[0].keyCode === KEY_CODES.ESCAPE
|| this.counter === -1
|| this.counter > count) {
this.focusList(active, list);
}
if (Args[0].keyCode === KEY_CODES.ENTER
|| Args[0].keyCode === KEY_CODES.ARROW_RIGHT
|| Args[0].keyCode === KEY_CODES.ARROW_LEFT) {
active.firstChild.click();
}
if (Args[0].keyCode === KEY_CODES.ARROW_DOWN) {
if (this.counter < count) {
this.focusListItem(active, 'down', items);
}else if (this.counter === count) {
this.focusListItem(active, 'downLoopDown', list);
}else if (this.counter === 0) {
this.focusListItem(active, 'downLoopUp', list);
}
}
if (Args[0].keyCode === KEY_CODES.ARROW_UP) {
if (this.counter < count && this.counter !== 0) {
this.focusListItem(active, 'up', items);
}else if (this.counter === 0) {
this.focusListItem(active, 'upLoopUp', list, count);
}else if (this.counter === count) {
this.focusListItem(active, 'upLoopDown', list, count);
}
}
}
componentDidMount() {
let _this = this;
if (!this.state.compact) {
this._msgsList.addEventListener('keypress', function (event) {
_this.rovingIndex.call(this, event, 'messages');
});
this._usersList.addEventListener('keypress', function (event) {
_this.rovingIndex.call(this, event, 'users');
});
}
}
componentWillUnmount() {
this._msgsList.removeEventListener('keypress', function (event) {}, false);
this._usersList.removeEventListener('keypress', function (event) {}, false);
}
render() {
@ -48,9 +171,9 @@ class UserList extends Component {
<div className={styles.header}>
{
!this.state.compact ?
<h2 className={styles.headerTitle}>
<div className={styles.headerTitle} role="banner">
{intl.formatMessage(intlMessages.participantsTitle)}
</h2> : null
</div> : null
}
</div>
);
@ -76,11 +199,14 @@ class UserList extends Component {
<div className={styles.messages}>
{
!this.state.compact ?
<h3 className={styles.smallTitle}>
<div className={styles.smallTitle} role="banner">
{intl.formatMessage(intlMessages.messagesTitle)}
</h3> : <hr className={styles.separator}></hr>
</div> : <hr className={styles.separator}></hr>
}
<div className={styles.scrollableList}>
<div
tabIndex={0}
className={styles.scrollableList}
ref={(r) => this._msgsList = r}>
<ReactCSSTransitionGroup
transitionName={listTransition}
transitionAppear={true}
@ -89,15 +215,18 @@ class UserList extends Component {
transitionAppearTimeout={0}
transitionEnterTimeout={0}
transitionLeaveTimeout={0}
component="ul"
component="div"
className={cx(styles.chatsList, styles.scrollableList)}>
<div ref={(r) => this._msgItems = r}>
{openChats.map(chat => (
<ChatListItem
compact={this.state.compact}
key={chat.id}
openChat={openChat}
chat={chat} />
chat={chat}
tabIndex={-1} />
))}
</div>
</ReactCSSTransitionGroup>
</div>
</div>
@ -151,34 +280,40 @@ class UserList extends Component {
<div className={styles.participants}>
{
!this.state.compact ?
<h3 className={styles.smallTitle}>
<div className={styles.smallTitle} role="banner">
{intl.formatMessage(intlMessages.usersTitle)}
&nbsp;({users.length})
</h3> : <hr className={styles.separator}></hr>
</div> : <hr className={styles.separator}></hr>
}
<ReactCSSTransitionGroup
transitionName={listTransition}
transitionAppear={true}
transitionEnter={true}
transitionLeave={true}
transitionAppearTimeout={0}
transitionEnterTimeout={0}
transitionLeaveTimeout={0}
component="ul"
className={cx(styles.participantsList, styles.scrollableList)}>
{
users.map(user => (
<UserListItem
compact={this.state.compact}
key={user.id}
isBreakoutRoom={isBreakoutRoom}
user={user}
currentUser={currentUser}
userActions={userActions}
meeting={meeting}
/>
))}
</ReactCSSTransitionGroup>
<div
className={styles.scrollableList}
tabIndex={0}
ref={(r) => this._usersList = r}>
<ReactCSSTransitionGroup
transitionName={listTransition}
transitionAppear={true}
transitionEnter={true}
transitionLeave={true}
transitionAppearTimeout={0}
transitionEnterTimeout={0}
transitionLeaveTimeout={0}
component="div"
className={cx(styles.participantsList, styles.scrollableList)}>
<div ref={(r) => this._userItems = r}>
{
users.map(user => (
<UserListItem
compact={this.state.compact}
key={user.id}
isBreakoutRoom={isBreakoutRoom}
user={user}
currentUser={currentUser}
userActions={userActions}
/>))
}
</div>
</ReactCSSTransitionGroup>
</div>
</div>
);
}

View File

@ -54,6 +54,14 @@ const messages = defineMessages({
id: 'app.userlist.menuTitleContext',
description: 'adds context to userListItem menu title',
},
userItemStatusAriaLabel: {
id: 'app.userlist.useritem.status.arialabel',
description: 'adds aria label for user and status',
},
userItemAriaLabel: {
id: 'app.userlist.useritem.nostatus.arialabel',
description: 'aria label for user',
},
});
const userActionsTransition = {
@ -234,15 +242,81 @@ class UserListItem extends Component {
userItemContentsStyle[styles.userItemContentsCompact] = compact;
userItemContentsStyle[styles.active] = this.state.isActionsOpen;
const {
user,
intl,
} = this.props;
let you = (user.isCurrent) ? intl.formatMessage(messages.you) : null;
let presenter = (user.isPresenter)
? intl.formatMessage(messages.presenter)
: null;
let userAriaLabel = (user.emoji.status === 'none')
? intl.formatMessage(messages.userItemAriaLabel,
{ username: user.name, presenter: presenter, you: you, })
: intl.formatMessage(messages.userItemStatusAriaLabel,
{ username: user.name,
presenter: presenter,
you: you,
status: user.emoji.status, });
let actions = this.getAvailableActions();
let contents = (
<div
className={cx(styles.userListItem, userItemContentsStyle)}
aria-label={userAriaLabel}>
<div className={styles.userItemContents} aria-hidden="true">
<UserAvatar user={user} />
{this.renderUserName()}
{this.renderUserIcons()}
</div>
</div>
);
if (!actions.length) {
return contents;
}
const { dropdownOffset, dropdownDirection, dropdownVisible, } = this.state;
return (
<li
role="button"
aria-haspopup="true"
aria-live="assertive"
aria-relevant="additions"
className={cx(styles.userListItem, userItemContentsStyle)}>
{this.renderUserContents()}
</li>
<Dropdown
ref="dropdown"
isOpen={this.state.isActionsOpen}
onShow={this.onActionsShow}
onHide={this.onActionsHide}
className={styles.dropdown}
autoFocus={false}
hasPopup="true"
ariaLive="assertive"
ariaRelevant="additions">
<DropdownTrigger>
{contents}
</DropdownTrigger>
<DropdownContent
style={{
visibility: dropdownVisible ? 'visible' : 'hidden',
[dropdownDirection]: `${dropdownOffset}px`,
}}
className={styles.dropdownContent}
placement={`right ${dropdownDirection}`}>
<DropdownList>
{
[
(<DropdownListTitle
description={intl.formatMessage(messages.menuTitleContext)}
key={_.uniqueId('dropdown-list-title')}>
{user.name}
</DropdownListTitle>),
(<DropdownListSeparator key={_.uniqueId('action-separator')} />),
].concat(actions)
}
</DropdownList>
</DropdownContent>
</Dropdown>
);
}
@ -254,7 +328,7 @@ class UserListItem extends Component {
let actions = this.getAvailableActions();
let contents = (
<div tabIndex={0} className={styles.userItemContents}>
<div className={styles.userItemContents}>
<UserAvatar user={user} />
{this.renderUserName()}
{this.renderUserIcons()}
@ -273,7 +347,8 @@ class UserListItem extends Component {
isOpen={this.state.isActionsOpen}
onShow={this.onActionsShow}
onHide={this.onActionsHide}
className={styles.dropdown}>
className={styles.dropdown}
autoFocus={false}>
<DropdownTrigger>
{contents}
</DropdownTrigger>
@ -335,11 +410,15 @@ class UserListItem extends Component {
</span>
<span className={styles.userNameSub}>
{userNameSub}
{(user.isLocked && (disablePrivateChat || disableCam || disableMic || lockedLayout || disablePublicChat)) ?
<span> {(user.isCurrent? " | " : null)}
{(user.isLocked && (disablePrivateChat
|| disableCam
|| disableMic
|| lockedLayout
|| disablePublicChat)) ?
<span> {(user.isCurrent ? ' | ' : null)}
<Icon iconName='lock' />
{intl.formatMessage(messages.locked)}
</span>: null}
</span> : null}
</span>
</div>
);
@ -407,6 +486,7 @@ class UserListItem extends Component {
label={action.label}
defaultMessage={action.label}
onClick={action.handler.bind(this, ...parameters)}
placeInTabOrder={true}
/>
);

4
bigbluebutton-html5/imports/utils/keyCodes.js Normal file → Executable file
View File

@ -4,6 +4,8 @@ export const TAB = 9;
export const ESCAPE = 27;
export const ARROW_UP = 38;
export const ARROW_DOWN = 40;
export const ARROW_RIGHT = 39;
export const ARROW_LEFT = 37;
export default {
SPACE,
@ -12,4 +14,6 @@ export default {
ESCAPE,
ARROW_UP,
ARROW_DOWN,
ARROW_RIGHT,
ARROW_LEFT,
};

View File

@ -25,6 +25,8 @@
"app.userlist.menu.kickUser.label": "Kick user",
"app.userlist.menu.muteUserAudio.label": "Mute user",
"app.userlist.menu.unmuteUserAudio.label": "Unmute user",
"app.userlist.useritem.nostatus.arialabel": "{username} {presenter} {you}",
"app.userlist.useritem.status.arialabel": "{username} {presenter} {you} current status {status}",
"app.chat.Label": "Chat",
"app.chat.emptyLogLabel": "Chat log empty",
"app.media.Label": "Media",