Skip to content

Commit

Permalink
feat(reply): Address comments
Browse files Browse the repository at this point in the history
  • Loading branch information
Mingze Xiao committed May 13, 2020
1 parent 5f51bf3 commit adce7c8
Show file tree
Hide file tree
Showing 10 changed files with 110 additions and 66 deletions.
1 change: 1 addition & 0 deletions src/components/Popups/Popper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import unionBy from 'lodash/unionBy';

export type Instance = popper.Instance;
export type Options = popper.Options;
export type State = popper.State;
export type VirtualElement = popper.VirtualElement;

export type PopupReference = Element | VirtualElement;
Expand Down
7 changes: 7 additions & 0 deletions src/components/Popups/ReplyField/DefaultItemRow.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@import '~box-ui-elements/es/styles/variables';

.ba-DefaultItemRow-email {
color: $bdl-gray-62;
font-size: 11px;
line-height: 13px;
}
25 changes: 25 additions & 0 deletions src/components/Popups/ReplyField/DefaultItemRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import DatalistItem from 'box-ui-elements/es/components/datalist-item';
import { UserMini, GroupMini } from '../../../@types';
import './DefaultItemRow.scss';

export type Props = {
id?: string;
item?: UserMini | GroupMini;
name?: string;
};

const DefaultItemRow = ({ item, ...rest }: Props): JSX.Element | null => {
if (!item || !item.name) {
return null;
}

return (
<DatalistItem {...rest}>
<div className="ba-DefaultItemRow-name">{item.name}</div>
{'email' in item && <div className="ba-DefaultItemRow-email">{item.email}</div>}
</DatalistItem>
);
};

export default DefaultItemRow;
5 changes: 5 additions & 0 deletions src/components/Popups/ReplyField/ItemList.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.ba {
.ba-ItemList-row {
padding: 5px 30px 5px 15px;
}
}
21 changes: 6 additions & 15 deletions src/components/Popups/ReplyField/ItemList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { SyntheticEvent } from 'react';
import DatalistItem from 'box-ui-elements/es/components/datalist-item';
import noop from 'lodash/noop';
import DefaultItemRow from './DefaultItemRow';
import { Collaborator } from '../../../@types';
import './ItemList.scss';

export type Props = {
activeItemIndex?: number;
Expand All @@ -12,17 +13,9 @@ export type Props = {
setActiveItemIndex?: (index: number) => void;
};

const DefaultItemRow = ({ item, ...rest }: Collaborator): JSX.Element | null =>
item?.name ? (
<DatalistItem {...rest}>
<div className="ba-PopupList-name">{item.name}</div>
{'email' in item && <div className="ba-PopupList-email">{item.email}</div>}
</DatalistItem>
) : null;

const ItemList = ({
activeItemIndex = 0,
itemRow = <DefaultItemRow {...({} as Collaborator)} />,
itemRow = <DefaultItemRow />,
items,
onSelect,
setActiveItemIndex = noop,
Expand All @@ -35,10 +28,10 @@ const ItemList = ({
return null;
}

const itemProps = {
return React.cloneElement(itemRow, {
...item,
key: item.id,
className: 'ba-PopupList-item',
className: 'ba-ItemList-row',
isActive: index === activeItemIndex,
onClick: (event: SyntheticEvent) => {
onSelect(index, event);
Expand All @@ -50,9 +43,7 @@ const ItemList = ({
onMouseEnter: () => {
setActiveItemIndex(index);
},
};

return React.cloneElement(itemRow as JSX.Element, itemProps);
});
})}
</ul>
);
Expand Down
5 changes: 5 additions & 0 deletions src/components/Popups/ReplyField/MentionItem.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import '~box-ui-elements/es/styles/variables';

.ba-MentionItem-link {
color: $bdl-box-blue;
}
4 changes: 2 additions & 2 deletions src/components/Popups/ReplyField/MentionItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { bdlBoxBlue } from 'box-ui-elements/es/styles/variables';
import { ContentState } from 'draft-js';
import './MentionItem.scss';

type Props = {
children: React.ReactNode;
Expand All @@ -13,7 +13,7 @@ const MentionItem = ({ contentState, entityKey, children }: Props): JSX.Element
const id = entityKey ? contentState.getEntity(entityKey).getData().id : '';

return (
<a href={`/profile/${id}`} style={{ color: bdlBoxBlue }}>
<a className="ba-MentionItem-link" href={`/profile/${id}`}>
{children}
</a>
);
Expand Down
10 changes: 0 additions & 10 deletions src/components/Popups/ReplyField/PopupList.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,7 @@
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, .05);
}

.ba-PopupList-item {
padding: 5px 30px 5px 15px;
}

.ba-PopupList-prompt {
padding: 0 10px 10px;
}

.ba-PopupList-email {
color: $bdl-gray-62;
font-size: 11px;
line-height: 13px;
}
}
7 changes: 4 additions & 3 deletions src/components/Popups/ReplyField/PopupList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ export type Props = {
itemRow?: JSX.Element;
items: Collaborator[];
onSelect: (index: number, event: React.SyntheticEvent) => void;
options?: Partial<Options>;
reference: PopupReference;
setActiveItemIndex?: (index: number) => void;
};

const options: Partial<Options> = {
const defaultOptions: Partial<Options> = {
modifiers: [
{
name: 'offset',
Expand All @@ -35,9 +36,9 @@ const options: Partial<Options> = {
placement: 'bottom-start',
};

const PopupList = ({ items, reference, ...rest }: Props): JSX.Element => {
const PopupList = ({ items, reference, options, ...rest }: Props): JSX.Element => {
return (
<PopupBase className="ba-PopupList" options={options} reference={reference}>
<PopupBase className="ba-PopupList" options={{ ...defaultOptions, ...options }} reference={reference}>
{isEmpty(items) ? (
<div className="ba-PopupList-prompt">
<FormattedMessage {...messages.popupListPrompt} />
Expand Down
91 changes: 55 additions & 36 deletions src/components/Popups/ReplyField/ReplyField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {
getActiveMentionForEditorState,
} from 'box-ui-elements/es/components/form-elements/draft-js-mention-selector';
import fuzzySearch from 'box-ui-elements/es/utils/fuzzySearch';
import { ContentState, Editor, EditorState, SelectionState } from 'draft-js';
import { ContentState, DraftHandleValue, Editor, EditorState, SelectionState } from 'draft-js';
import PopupList from './PopupList';
import withMentionDecorator from './withMentionDecorator';
import { Collaborator } from '../../../@types';
import { VirtualElement } from '../Popper';
import { State as PopperState, VirtualElement } from '../Popper';
import './ReplyField.scss';

export type Mention = {
Expand All @@ -36,7 +36,6 @@ export type Props = {
export type State = {
activeItemIndex: number;
editorState: EditorState;
popupItems: Collaborator[];
popupReference: VirtualElement | null;
};

Expand All @@ -51,7 +50,7 @@ export default class ReplyField extends React.Component<Props, State> {
constructor(props: Props) {
super(props);

const { value, collaborators, cursorPosition } = props;
const { value, cursorPosition } = props;
const contentState = ContentState.createFromText(value as string);
let prevEditorState = withMentionDecorator(EditorState.createWithContent(contentState));
let selectionState = prevEditorState.getSelection();
Expand All @@ -65,23 +64,28 @@ export default class ReplyField extends React.Component<Props, State> {
this.state = {
activeItemIndex: 0,
editorState: prevEditorState,
popupItems: collaborators,
popupReference: null,
};
}

componentDidMount(): void {
this.focusEditor();
// restore PopupList if it was active
this.updatePopupReference();
}

componentWillUnmount(): void {
this.saveCursorPosition();
}

getItemList = (mentionString: string): Collaborator[] => {
getCollaborators = (activeMention: Mention | null): Collaborator[] => {
const { collaborators } = this.props;
const trimmedQuery = mentionString.trim();

if (!activeMention) {
return [];
}

const trimmedQuery = activeMention.mentionString.trim();
// fuzzySearch doesn't match anything if query length is less than 2
// Compared to empty list, full list has a better user experience
if (trimmedQuery.length < 2) {
Expand Down Expand Up @@ -128,19 +132,34 @@ export default class ReplyField extends React.Component<Props, State> {
};
};

updatePopupReference = (): void => {
const { editorState } = this.state;

const activeMention = getActiveMentionForEditorState(editorState);

this.setState({ popupReference: activeMention ? this.getVirtualElement(activeMention) : null });
};

handleFirstUpdate = (state: Partial<PopperState>): void => {
const { x, y } = state.rects?.reference || {};

// sometimes inner popper position is wrong due to parent popper position change, like zoom in/out
// the inner popper position should always be positive (bottom-right)
// so if position is negative after first positioning,
// we re-calculate virtualElement to re-position inner popper
if ((x && x < 0) || (y && y < 0)) {
this.updatePopupReference();
}
};

handleChange = (nextEditorState: EditorState): void => {
const { onChange } = this.props;

onChange(nextEditorState.getCurrentContent().getPlainText());

const activeMention = getActiveMentionForEditorState(nextEditorState);
const popupItems = this.getItemList(activeMention ? activeMention.mentionString : '');

this.setState({ editorState: nextEditorState, popupItems }, () => {
// In order to get correct selection, getVirtualElement has to be called after new texts are rendered
const popupReference = activeMention ? this.getVirtualElement(activeMention) : null;
this.setState({ popupReference });
});
// In order to get correct selection, getVirtualElement in this.updatePopupReference
// has to be called after new texts are rendered in editor
this.setState({ editorState: nextEditorState }, this.updatePopupReference);
};

handleSelect = (index: number): void => {
Expand All @@ -167,63 +186,62 @@ export default class ReplyField extends React.Component<Props, State> {
setCursorPosition(editorState.getSelection().getFocusOffset());
};

stopDefaultEvent = (e: React.KeyboardEvent): void => {
e.preventDefault();
e.stopPropagation();
stopDefaultEvent = (event: React.SyntheticEvent): void => {
event.preventDefault();
event.stopPropagation();
};

setPopupListActiveItem = (index: number): void => this.setState({ activeItemIndex: index });

handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
const { activeItemIndex, popupItems, popupReference } = this.state;
handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): DraftHandleValue => {
const { activeItemIndex, editorState, popupReference } = this.state;
const collaborators = this.getCollaborators(getActiveMentionForEditorState(editorState));

if (!popupReference) {
return;
return 'not-handled';
}

const itemsCount = popupItems.length;

switch (event.key) {
case 'ArrowDown':
if (!itemsCount) {
if (!collaborators.length) {
break;
}
this.stopDefaultEvent(event);
this.setPopupListActiveItem(activeItemIndex === itemsCount - 1 ? 0 : activeItemIndex + 1);
this.setPopupListActiveItem(activeItemIndex === collaborators.length - 1 ? 0 : activeItemIndex + 1);
break;
case 'ArrowUp':
if (!itemsCount) {
if (!collaborators.length) {
break;
}
this.stopDefaultEvent(event);
this.setPopupListActiveItem(activeItemIndex === 0 ? itemsCount - 1 : activeItemIndex - 1);
this.setPopupListActiveItem(activeItemIndex === 0 ? collaborators.length - 1 : activeItemIndex - 1);
break;
case 'Enter':
this.stopDefaultEvent(event);
this.handleSelect(activeItemIndex);
this.setPopupListActiveItem(0);
break;
return 'handled';
default:
// don't stop default event if not above keys
}

return 'not-handled';
};

render(): JSX.Element {
const { className, isDisabled, itemRow, placeholder, ...rest } = this.props;
const { activeItemIndex, editorState, popupItems, popupReference } = this.state;
const { activeItemIndex, editorState, popupReference } = this.state;

return (
<div
className={classnames(className, 'ba-ReplyField')}
onKeyDown={this.handleKeyDown}
role="textbox"
tabIndex={0}
>
<div className={classnames(className, 'ba-ReplyField')}>
<Editor
{...rest}
ref={this.editorRef}
editorState={editorState}
handleReturn={this.handleKeyDown}
onChange={this.handleChange}
onDownArrow={this.handleKeyDown}
onUpArrow={this.handleKeyDown}
placeholder={placeholder}
readOnly={isDisabled}
stripPastedStyles
Expand All @@ -234,8 +252,9 @@ export default class ReplyField extends React.Component<Props, State> {
<PopupList
activeItemIndex={activeItemIndex}
itemRow={itemRow}
items={popupItems}
items={this.getCollaborators(getActiveMentionForEditorState(editorState))}
onSelect={this.handleSelect}
options={{ onFirstUpdate: this.handleFirstUpdate }}
reference={popupReference}
setActiveItemIndex={this.setPopupListActiveItem}
/>
Expand Down

0 comments on commit adce7c8

Please sign in to comment.