Changeset View
Changeset View
Standalone View
Standalone View
web/calendar/thread-picker.react.js
// @flow | // @flow | ||||
import invariant from 'invariant'; | import invariant from 'invariant'; | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { createSelector } from 'reselect'; | import { createSelector } from 'reselect'; | ||||
import { threadSearchIndex } from 'lib/selectors/nav-selectors'; | import { threadSearchIndex } from 'lib/selectors/nav-selectors'; | ||||
import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors'; | import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors'; | ||||
import SearchIndex from 'lib/shared/search-index'; | |||||
import type { ThreadInfo } from 'lib/types/thread-types'; | import type { ThreadInfo } from 'lib/types/thread-types'; | ||||
import { useSelector } from '../redux/redux-utils'; | import { useSelector } from '../redux/redux-utils'; | ||||
import { htmlTargetFromEvent } from '../vector-utils'; | import { htmlTargetFromEvent } from '../vector-utils'; | ||||
import css from './thread-picker.css'; | import css from './thread-picker.css'; | ||||
type OptionProps = { | type OptionProps = { | ||||
+threadInfo: ThreadInfo, | +threadInfo: ThreadInfo, | ||||
Show All 12 Lines | <div className={css.option} onClick={onClick}> | ||||
<span className={css.thread}> | <span className={css.thread}> | ||||
<div className={css.colorPreview} style={colorStyle} /> | <div className={css.colorPreview} style={colorStyle} /> | ||||
<span className={css.threadName}>{props.threadInfo.uiName}</span> | <span className={css.threadName}>{props.threadInfo.uiName}</span> | ||||
</span> | </span> | ||||
</div> | </div> | ||||
); | ); | ||||
} | } | ||||
type BaseProps = { | type Props = { | ||||
+createNewEntry: (threadID: string) => void, | +createNewEntry: (threadID: string) => void, | ||||
+closePicker: () => void, | +closePicker: () => void, | ||||
}; | }; | ||||
type Props = { | |||||
...BaseProps, | |||||
+onScreenThreadInfos: $ReadOnlyArray<ThreadInfo>, | |||||
+searchIndex: SearchIndex, | |||||
}; | |||||
type State = { | |||||
+searchText: string, | |||||
+searchResults: Set<string>, | |||||
}; | |||||
type PropsAndState = { ...Props, ...State }; | |||||
class ThreadPicker extends React.PureComponent<Props, State> { | function ThreadPicker(props: Props): React.Node { | ||||
pickerDiv: ?HTMLDivElement; | const { closePicker, createNewEntry } = props; | ||||
const onScreenThreadInfos = useSelector(onScreenEntryEditableThreadInfos); | |||||
const searchIndex = useSelector(state => threadSearchIndex(state)); | |||||
constructor(props: Props) { | |||||
super(props); | |||||
this.state = { | |||||
searchText: '', | |||||
searchResults: new Set(), | |||||
}; | |||||
invariant( | invariant( | ||||
props.onScreenThreadInfos.length > 0, | onScreenThreadInfos.length > 0, | ||||
"ThreadPicker can't be open when onScreenThreadInfos is empty", | "ThreadPicker can't be open when onScreenThreadInfos is empty", | ||||
); | ); | ||||
const pickerDivRef = React.useRef<?HTMLDivElement>(null); | |||||
React.useLayoutEffect(() => { | |||||
invariant(pickerDivRef, 'pickerDivRef must be set'); | |||||
const { current } = pickerDivRef; | |||||
current?.focus(); | |||||
}, []); | |||||
const [searchText, setSearchText] = React.useState<string>(''); | |||||
const [searchResults, setSearchResults] = React.useState<Set<string>>( | |||||
new Set(), | |||||
); | |||||
const onPickerKeyDown = React.useCallback( | |||||
(event: SyntheticKeyboardEvent<HTMLDivElement>) => { | |||||
if (event.keyCode === 27) { | |||||
// esc | |||||
closePicker(); | |||||
} | } | ||||
}, | |||||
[closePicker], | |||||
); | |||||
componentDidMount() { | const onMouseDown = React.useCallback( | ||||
invariant(this.pickerDiv, 'pickerDiv ref unset'); | (event: SyntheticEvent<HTMLDivElement>) => { | ||||
this.pickerDiv.focus(); | const target = htmlTargetFromEvent(event); | ||||
invariant(pickerDivRef, 'pickerDivRef must be set'); | |||||
if (pickerDivRef.current?.contains(target)) { | |||||
// This prevents onBlur from firing | |||||
event.preventDefault(); | |||||
} | } | ||||
}, | |||||
[], | |||||
); | |||||
// eslint-disable-next-line no-unused-vars | |||||
const onChangeSearchText = React.useCallback( | |||||
(text: string) => { | |||||
const results = searchIndex.getSearchResults(text); | |||||
setSearchText(text); | |||||
setSearchResults(new Set(results)); | |||||
}, | |||||
[searchIndex], | |||||
); | |||||
listDataSelector = createSelector( | const listDataSelector = createSelector( | ||||
(propsAndState: PropsAndState) => propsAndState.onScreenThreadInfos, | state => state.onScreenThreadInfos, | ||||
(propsAndState: PropsAndState) => propsAndState.searchText, | state => state.searchText, | ||||
(propsAndState: PropsAndState) => propsAndState.searchResults, | state => state.searchResults, | ||||
( | ( | ||||
threadInfos: $ReadOnlyArray<ThreadInfo>, | threadInfos: $ReadOnlyArray<ThreadInfo>, | ||||
text: string, | text: string, | ||||
searchResults: Set<string>, | results: Set<string>, | ||||
) => | ) => | ||||
text | text | ||||
? threadInfos.filter(threadInfo => searchResults.has(threadInfo.id)) | ? threadInfos.filter(threadInfo => results.has(threadInfo.id)) | ||||
: [...threadInfos], | : [...threadInfos], | ||||
); | ); | ||||
get getListData() { | const threads = useSelector(() => | ||||
return this.listDataSelector({ ...this.props, ...this.state }); | listDataSelector({ | ||||
} | onScreenThreadInfos, | ||||
searchText, | |||||
render() { | searchResults, | ||||
const length = this.props.onScreenThreadInfos.length; | }), | ||||
invariant( | |||||
length > 0, | |||||
"ThreadPicker can't be open when onScreenThreadInfos is empty", | |||||
); | ); | ||||
const options = this.getListData.map(threadInfo => ( | const options = React.useMemo(() => { | ||||
return threads.map(threadInfo => ( | |||||
<ThreadPickerOption | <ThreadPickerOption | ||||
threadInfo={threadInfo} | threadInfo={threadInfo} | ||||
createNewEntry={this.props.createNewEntry} | createNewEntry={createNewEntry} | ||||
key={threadInfo.id} | key={threadInfo.id} | ||||
/> | /> | ||||
)); | )); | ||||
}, [threads, createNewEntry]); | |||||
return ( | return ( | ||||
<div | <div | ||||
className={css.container} | className={css.container} | ||||
tabIndex="0" | tabIndex="0" | ||||
onBlur={this.props.closePicker} | ref={pickerDivRef} | ||||
onKeyDown={this.onPickerKeyDown} | onBlur={closePicker} | ||||
onMouseDown={this.onMouseDown} | onKeyDown={onPickerKeyDown} | ||||
ref={this.pickerDivRef} | onMouseDown={onMouseDown} | ||||
> | > | ||||
{options} | {options} | ||||
</div> | </div> | ||||
); | ); | ||||
} | } | ||||
pickerDivRef = (pickerDiv: ?HTMLDivElement) => { | export default ThreadPicker; | ||||
this.pickerDiv = pickerDiv; | |||||
}; | |||||
onPickerKeyDown = (event: SyntheticKeyboardEvent<HTMLDivElement>) => { | |||||
if (event.keyCode === 27) { | |||||
// Esc | |||||
this.props.closePicker(); | |||||
} | |||||
}; | |||||
onMouseDown = (event: SyntheticEvent<HTMLDivElement>) => { | |||||
const target = htmlTargetFromEvent(event); | |||||
invariant(this.pickerDiv, 'pickerDiv ref not set'); | |||||
if (this.pickerDiv.contains(target)) { | |||||
// This prevents onBlur from firing | |||||
event.preventDefault(); | |||||
} | |||||
}; | |||||
onChangeSearchText = (searchText: string) => { | |||||
const results = this.props.searchIndex.getSearchResults(searchText); | |||||
this.setState({ searchText, searchResults: new Set(results) }); | |||||
}; | |||||
} | |||||
const ConnectedThreadPicker: React.ComponentType<BaseProps> = React.memo<BaseProps>( | |||||
function ConnectedThreadPicker(props) { | |||||
const onScreenThreadInfos = useSelector(onScreenEntryEditableThreadInfos); | |||||
const index = useSelector(state => threadSearchIndex(state)); | |||||
return ( | |||||
<ThreadPicker | |||||
{...props} | |||||
onScreenThreadInfos={onScreenThreadInfos} | |||||
searchIndex={index} | |||||
/> | |||||
); | |||||
}, | |||||
); | |||||
export default ConnectedThreadPicker; |