Reimplement keyboard navigation

This commit is contained in:
Chi Vinh Le
2017-12-12 17:19:11 +01:00
parent f32e4523e8
commit b12d5f6bd7
8 changed files with 102 additions and 87 deletions
@@ -34,3 +34,8 @@ export const storySearchChange = (value) => ({
export const clearState = () => ({
type: actions.MODERATION_CLEAR_STATE
});
export const selectCommentId = (id) => ({
type: actions.MODERATION_SELECT_COMMENT,
id,
});
@@ -6,3 +6,4 @@ export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH';
export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH';
export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE';
export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE';
export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT';
@@ -7,6 +7,7 @@ const initialState = {
storySearchString: '',
shortcutsNoteVisible: 'show',
sortOrder: 'DESC',
selectedCommentId: '',
};
export default function moderation (state = initialState, action) {
@@ -51,6 +52,11 @@ export default function moderation (state = initialState, action) {
...state,
sortOrder: action.order,
};
case actions.MODERATION_SELECT_COMMENT:
return {
...state,
selectedCommentId: action.id,
};
default:
return state;
}
@@ -12,6 +12,7 @@
padding: 10px 0;
margin-top: 13px;
min-height: 0;
outline: 0;
/*
Fix rendering issues in Safari by promoting this
@@ -20,6 +20,16 @@ import t, {timeago} from 'coral-framework/services/i18n';
class Comment extends React.Component {
ref = null;
handleRef = (ref) => this.ref = ref;
handleFocusOrClick = () => {
if (!this.props.selected) {
this.props.selectComment();
}
};
showSuspendUserDialog = () => {
const {comment, showSuspendUserDialog} = this.props;
return showSuspendUserDialog({
@@ -55,6 +65,12 @@ class Comment extends React.Component {
: this.props.rejectComment({commentId: this.props.comment.id})
);
componentDidUpdate(prev) {
if (!prev.selected && this.props.selected) {
this.ref.focus();
}
}
render() {
const {
comment,
@@ -76,6 +92,9 @@ class Comment extends React.Component {
tabIndex={0}
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected}, 'talk-admin-moderate-comment')}
id={`comment_${comment.id}`}
onClick={this.handleFocusOrClick}
ref={this.handleRef}
onFocus={this.handleFocusOrClick}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
@@ -190,7 +209,9 @@ class Comment extends React.Component {
Comment.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
selectComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
onClick: PropTypes.func,
className: PropTypes.string,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
@@ -12,15 +12,8 @@ import Slot from 'coral-framework/components/Slot';
import ViewOptions from './ViewOptions';
class Moderation extends Component {
constructor(props) {
super(props);
const comments = this.getComments(props);
this.state = {
selectedCommentId: comments[0] ? comments[0].id : null,
};
}
state = {};
componentWillMount() {
const {toggleModal, singleView} = this.props;
@@ -30,8 +23,6 @@ class Moderation extends Component {
key('esc', () => toggleModal(false));
key('ctrl+f', () => this.openSearch());
key('t', () => this.nextQueue());
key('j', () => this.select(true));
key('k', () => this.select(false));
key('f', () => this.moderate(false));
key('d', () => this.moderate(true));
this.getMenuItems()
@@ -96,64 +87,6 @@ class Moderation extends Component {
return root[activeTab].nodes;
}
scrollTo = (toId, smooth = true) =>
document.querySelector(`#comment_${toId}`).scrollIntoView(smooth ? {behavior: 'smooth'} : {});
select = async (next, props = this.props, selectedCommentId = this.state.selectedCommentId) => {
const comments = this.getComments(props);
// No comments to be selected.
if (comments.length === 0){
return;
}
// Find current index if we have a selected comment.
const index = selectedCommentId
? comments.findIndex((comment) => comment.id === selectedCommentId)
: null;
if (next) {
// Grab first one if we don't have a selected comment yet.
if (!selectedCommentId) {
this.setState({selectedCommentId: comments[0].id}, () => this.scrollTo(comments[0].id));
return;
}
// Select next one when we still have more comments left.
if (index < comments.length - 1) {
this.setState({selectedCommentId: comments[index + 1].id}, () => this.scrollTo(comments[index + 1].id));
return;
} else {
// We hit the end of the list, load more comments if we have.
if (comments.length < this.getActiveTabCount()) {
const res = await this.loadMore();
// If `loadMore` was already in progress, res would be false.
if (res) {
// Select next comment after loading has completed.
this.select(true);
}
}
return;
}
} else {
// We have no selected comment, so just skip it.
if (!selectedCommentId) {
return;
}
// If we still have previous comments take the one before.
if (index > 0) {
this.setState({selectedCommentId: comments[index - 1].id}, () => this.scrollTo(comments[index - 1].id));
return;
}
}
}
loadMore = async () => {
if (!this.isLoadingMore) {
this.isLoadingMore = true;
@@ -176,8 +109,6 @@ class Moderation extends Component {
key.unbind('esc');
key.unbind('ctrl+f');
key.unbind('t');
key.unbind('j');
key.unbind('k');
key.unbind('f');
key.unbind('d');
this.getMenuItems()
@@ -186,11 +117,8 @@ class Moderation extends Component {
componentWillReceiveProps(nextProps) {
if (this.props.activeTab !== nextProps.activeTab) {
// Reset selection when changing tabs.
this.select(true, nextProps, null);
} else {
// TODO: Adapt to react virtualized.
if (this.props.activeTab === nextProps.activeTab) {
// Detect if comment has left the queue and find next or prev selected comment to set it
// as the new selectedCommentId.
@@ -218,14 +146,6 @@ class Moderation extends Component {
}
}
componentDidUpdate(prevProps) {
// Scroll to comment when changing from single wiew to normal view.
if (prevProps.moderation.singleView !== this.props.moderation.singleView && this.state.selectedCommentId) {
this.scrollTo(this.state.selectedCommentId, false);
}
}
render () {
const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
const {asset} = root;
@@ -268,7 +188,7 @@ class Moderation extends Component {
comments={comments.nodes}
activeTab={activeTab}
singleView={moderation.singleView}
selectedCommentId={this.state.selectedCommentId}
selectedCommentId={moderation.selectedCommentId}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
@@ -277,6 +197,7 @@ class Moderation extends Component {
commentCount={activeTabCount}
currentUserId={this.props.auth.user.id}
viewUserDetail={viewUserDetail}
selectCommentId={props.selectCommentId}
/>
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
@@ -307,6 +228,7 @@ class Moderation extends Component {
Moderation.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
toggleModal: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
toggleStorySearch: PropTypes.func.isRequired,
getModPath: PropTypes.func.isRequired,
storySearchChange: PropTypes.func.isRequired,
@@ -9,6 +9,7 @@ import ViewMore from './ViewMore';
import t from 'coral-framework/services/i18n';
import {WindowScroller, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized';
import throttle from 'lodash/throttle';
import key from 'keymaster';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
@@ -75,6 +76,46 @@ class ModerationQueue extends React.Component {
}
throw new Error(`unknown index ${index}`);
};
const view = this.getVisibleComments();
if (view.length) {
props.selectCommentId(view[0].id);
}
}
componentDidMount() {
key('j', () => this.selectDown());
key('k', () => this.selectUp());
}
componentWillUnmount() {
key.unbind('j');
key.unbind('k');
}
async selectDown() {
const view = this.getVisibleComments();
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
if (index === view.length - 1 && this.props.comments.length !== this.props.commentCount) {
await this.props.loadMore();
this.selectDown();
return;
}
if (index < view.length - 1) {
this.props.selectCommentId(view[index + 1].id);
}
}
selectUp() {
const view = this.getVisibleComments();
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
if (index === 0 && view.length < this.props.comments.length) {
this.viewNewComments(() => this.selectUp());
return;
}
if (index > 0) {
this.props.selectCommentId(view[index - 1].id);
}
}
componentDidUpdate (prev) {
@@ -86,6 +127,15 @@ class ModerationQueue extends React.Component {
if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) {
this.props.loadMore();
}
// Scroll to selected comment.
if (prev.selectedCommentId !== this.props.selectedCommentId) {
const view = this.getVisibleComments();
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
this.listRef.scrollToRow(index);
}
}
handleListRef = (list) => {
@@ -118,8 +168,11 @@ class ModerationQueue extends React.Component {
}
}
viewNewComments = () => {
this.setState(resetCursors, () => this.reflowList());
viewNewComments = (callback) => {
this.setState(resetCursors, () => {
this.reflowList();
callback && callback();
});
};
reflowList = throttle(() => {
@@ -203,6 +256,7 @@ class ModerationQueue extends React.Component {
currentAsset={this.props.currentAsset}
currentUserId={this.props.currentUserId}
clearHeightCache={() => {this.cache.clear(index); this.listRef.recomputeRowHeights(index); }}
selectComment={() => this.props.selectCommentId(comment.id)}
/>
</div>
</CellMeasurer>
@@ -244,6 +298,7 @@ class ModerationQueue extends React.Component {
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
selectComment={() => this.props.selectCommentId(comment.id)}
/>;
</div>
);
@@ -287,6 +342,7 @@ ModerationQueue.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
selectCommentId: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
@@ -23,7 +23,8 @@ import {
toggleStorySearch,
setSortOrder,
storySearchChange,
clearState
clearState,
selectCommentId,
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import {notify} from 'coral-framework/actions/notification';
@@ -246,6 +247,7 @@ class ModerationContainer extends Component {
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
/>;
}
}
@@ -423,6 +425,7 @@ const mapDispatchToProps = (dispatch) => ({
storySearchChange,
clearState,
notify,
selectCommentId,
}, dispatch),
});