diff --git a/src/components/Popups/Popper.ts b/src/components/Popups/Popper.ts index c43a8584b..539a81009 100644 --- a/src/components/Popups/Popper.ts +++ b/src/components/Popups/Popper.ts @@ -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; diff --git a/src/components/Popups/ReplyField/DefaultItemRow.scss b/src/components/Popups/ReplyField/DefaultItemRow.scss new file mode 100644 index 000000000..09300b52b --- /dev/null +++ b/src/components/Popups/ReplyField/DefaultItemRow.scss @@ -0,0 +1,7 @@ +@import '~box-ui-elements/es/styles/variables'; + +.ba-DefaultItemRow-email { + color: $bdl-gray-62; + font-size: 11px; + line-height: 13px; +} diff --git a/src/components/Popups/ReplyField/DefaultItemRow.tsx b/src/components/Popups/ReplyField/DefaultItemRow.tsx new file mode 100644 index 000000000..82d49062c --- /dev/null +++ b/src/components/Popups/ReplyField/DefaultItemRow.tsx @@ -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 ( + +
{item.name}
+ {'email' in item &&
{item.email}
} +
+ ); +}; + +export default DefaultItemRow; diff --git a/src/components/Popups/ReplyField/ItemList.scss b/src/components/Popups/ReplyField/ItemList.scss new file mode 100644 index 000000000..7e9bf7445 --- /dev/null +++ b/src/components/Popups/ReplyField/ItemList.scss @@ -0,0 +1,5 @@ +.ba { + .ba-ItemList-row { + padding: 5px 30px 5px 15px; + } +} diff --git a/src/components/Popups/ReplyField/ItemList.tsx b/src/components/Popups/ReplyField/ItemList.tsx index 2d3ff8516..1c29d874e 100644 --- a/src/components/Popups/ReplyField/ItemList.tsx +++ b/src/components/Popups/ReplyField/ItemList.tsx @@ -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; @@ -12,17 +13,9 @@ export type Props = { setActiveItemIndex?: (index: number) => void; }; -const DefaultItemRow = ({ item, ...rest }: Collaborator): JSX.Element | null => - item?.name ? ( - -
{item.name}
- {'email' in item &&
{item.email}
} -
- ) : null; - const ItemList = ({ activeItemIndex = 0, - itemRow = , + itemRow = , items, onSelect, setActiveItemIndex = noop, @@ -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); @@ -50,9 +43,7 @@ const ItemList = ({ onMouseEnter: () => { setActiveItemIndex(index); }, - }; - - return React.cloneElement(itemRow as JSX.Element, itemProps); + }); })} ); diff --git a/src/components/Popups/ReplyField/MentionItem.scss b/src/components/Popups/ReplyField/MentionItem.scss new file mode 100644 index 000000000..58a3b2926 --- /dev/null +++ b/src/components/Popups/ReplyField/MentionItem.scss @@ -0,0 +1,5 @@ +@import '~box-ui-elements/es/styles/variables'; + +.ba-MentionItem-link { + color: $bdl-box-blue; +} diff --git a/src/components/Popups/ReplyField/MentionItem.tsx b/src/components/Popups/ReplyField/MentionItem.tsx index c137022bc..f42c9e6d5 100644 --- a/src/components/Popups/ReplyField/MentionItem.tsx +++ b/src/components/Popups/ReplyField/MentionItem.tsx @@ -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; @@ -13,7 +13,7 @@ const MentionItem = ({ contentState, entityKey, children }: Props): JSX.Element const id = entityKey ? contentState.getEntity(entityKey).getData().id : ''; return ( - + {children} ); diff --git a/src/components/Popups/ReplyField/PopupList.scss b/src/components/Popups/ReplyField/PopupList.scss index f40626da4..2dec1048b 100644 --- a/src/components/Popups/ReplyField/PopupList.scss +++ b/src/components/Popups/ReplyField/PopupList.scss @@ -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; - } } diff --git a/src/components/Popups/ReplyField/PopupList.tsx b/src/components/Popups/ReplyField/PopupList.tsx index 9edf90fcf..40a1409fd 100644 --- a/src/components/Popups/ReplyField/PopupList.tsx +++ b/src/components/Popups/ReplyField/PopupList.tsx @@ -13,11 +13,12 @@ export type Props = { itemRow?: JSX.Element; items: Collaborator[]; onSelect: (index: number, event: React.SyntheticEvent) => void; + options?: Partial; reference: PopupReference; setActiveItemIndex?: (index: number) => void; }; -const options: Partial = { +const defaultOptions: Partial = { modifiers: [ { name: 'offset', @@ -35,9 +36,9 @@ const options: Partial = { placement: 'bottom-start', }; -const PopupList = ({ items, reference, ...rest }: Props): JSX.Element => { +const PopupList = ({ items, reference, options, ...rest }: Props): JSX.Element => { return ( - + {isEmpty(items) ? (
diff --git a/src/components/Popups/ReplyField/ReplyField.tsx b/src/components/Popups/ReplyField/ReplyField.tsx index 48a4f33dc..72c6a1c79 100644 --- a/src/components/Popups/ReplyField/ReplyField.tsx +++ b/src/components/Popups/ReplyField/ReplyField.tsx @@ -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 = { @@ -36,7 +36,6 @@ export type Props = { export type State = { activeItemIndex: number; editorState: EditorState; - popupItems: Collaborator[]; popupReference: VirtualElement | null; }; @@ -51,7 +50,7 @@ export default class ReplyField extends React.Component { 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(); @@ -65,23 +64,28 @@ export default class ReplyField extends React.Component { 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) { @@ -128,19 +132,34 @@ export default class ReplyField extends React.Component { }; }; + updatePopupReference = (): void => { + const { editorState } = this.state; + + const activeMention = getActiveMentionForEditorState(editorState); + + this.setState({ popupReference: activeMention ? this.getVirtualElement(activeMention) : null }); + }; + + handleFirstUpdate = (state: Partial): 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 => { @@ -167,63 +186,62 @@ export default class ReplyField extends React.Component { 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): void => { - const { activeItemIndex, popupItems, popupReference } = this.state; + handleKeyDown = (event: React.KeyboardEvent): 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 ( -
+
{