diff --git a/src/components/tags/TagAutocomplete/index.tsx b/src/components/tags/TagAutocomplete/index.tsx index 69ce26fa..87a71389 100644 --- a/src/components/tags/TagAutocomplete/index.tsx +++ b/src/components/tags/TagAutocomplete/index.tsx @@ -1,9 +1,136 @@ -import React, { FC } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styles from './styles.module.scss'; import classNames from 'classnames'; +import { connect } from 'react-redux'; +import * as TAG_ACTIONS from '~/redux/tag/actions'; +import { selectTagAutocomplete } from '~/redux/tag/selectors'; +import { separateTagOptions } from '~/utils/tag'; +import { TagAutocompleteRow } from '~/components/tags/TagAutocompleteRow'; -interface IProps {} +const mapStateToProps = selectTagAutocomplete; +const mapDispatchToProps = { + tagSetAutocomplete: TAG_ACTIONS.tagSetAutocomplete, + tagLoadAutocomplete: TAG_ACTIONS.tagLoadAutocomplete, +}; -const TagAutocomplete: FC = () =>
auto
; +type Props = ReturnType & + typeof mapDispatchToProps & { + exclude: string[]; + input: HTMLInputElement; + onSelect: (val: string) => void; + search: string; + }; + +const TagAutocompleteUnconnected: FC = ({ + exclude, + input, + onSelect, + search, + tagSetAutocomplete, + tagLoadAutocomplete, + options, +}) => { + const [top, setTop] = useState(false); + const [left, setLeft] = useState(false); + + const [selected, setSelected] = useState(-1); + const [categories, tags] = useMemo( + () => + separateTagOptions(options.filter(option => option !== search && !exclude.includes(option))), + [options, search, exclude] + ); + const scroll = useRef(null); + + const onKeyDown = useCallback( + event => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelected(selected < options.length - 1 ? selected + 1 : -1); + return; + case 'ArrowUp': + event.preventDefault(); + setSelected(selected > -1 ? selected - 1 : options.length - 1); + return; + case 'Enter': + event.preventDefault(); + onSelect(selected >= 0 ? [...categories, ...tags][selected] : search); + } + }, + [setSelected, selected, categories, tags, onSelect, search] + ); + + const onScroll = useCallback(() => { + if (!scroll.current) return; + const { y, height, x, width } = scroll.current.getBoundingClientRect(); + + const newTop = window.innerHeight - y - height <= (top ? 120 : 10); + if (top !== newTop) setTop(newTop); + + const newLeft = x <= 0; + if (newLeft !== left) setLeft(newLeft); + }, [scroll.current, top, left]); + + useEffect(() => { + input.addEventListener('keydown', onKeyDown, false); + return () => input.removeEventListener('keydown', onKeyDown); + }, [input, onKeyDown]); + + useEffect(() => { + setSelected(-1); + tagLoadAutocomplete(search, exclude); + }, [search]); + + useEffect(() => { + tagSetAutocomplete({ options: [] }); + return () => tagSetAutocomplete({ options: [] }); + }, [tagSetAutocomplete]); + + useEffect(() => { + if (!scroll.current || !scroll.current?.children[selected + 1]) return; + const el = scroll.current?.children[selected + 1] as HTMLDivElement; + const { scrollTop, clientHeight } = scroll.current; + const { offsetTop } = el; + + if (clientHeight - scrollTop + el.clientHeight < offsetTop || offsetTop < scrollTop) { + scroll.current.scrollTo(0, el.offsetTop - el.clientHeight); + } + }, [selected, scroll.current]); + + useEffect(() => { + onScroll(); + + window.addEventListener('resize', onScroll); + window.addEventListener('scroll', onScroll); + + return () => { + window.removeEventListener('resize', onScroll); + window.removeEventListener('scroll', onScroll); + }; + }, []); + + return ( +
+
+ + + {categories.map((item, i) => ( + + ))} + + {tags.map((item, i) => ( + + ))} +
+
+ ); +}; + +const TagAutocomplete = connect(mapStateToProps, mapDispatchToProps)(TagAutocompleteUnconnected); export { TagAutocomplete }; diff --git a/src/components/tags/TagAutocomplete/styles.module.scss b/src/components/tags/TagAutocomplete/styles.module.scss index 7d620f93..18591e03 100644 --- a/src/components/tags/TagAutocomplete/styles.module.scss +++ b/src/components/tags/TagAutocomplete/styles.module.scss @@ -3,18 +3,35 @@ 100% { opacity: 100 } } -.window { - box-shadow: transparentize(black, 0.5) 4px 4px 4px, inset transparentize(white, 0.95) 1px 1px; +$row_height: 24px; +.window { + box-shadow: transparentize(white, 0.8) 0 0 0 1px; position: absolute; - top: 0; - right: 0; - width: calc(90vw - 20px); + top: -2px; + right: -2px; + width: calc(100vw - 15px); max-width: 300px; - background: lighten($content_bg, 4%); - height: 100px; + background: darken($content_bg, 1%); z-index: 10; border-radius: 3px; - padding-top: $tag_height; + padding: $tag_height + 2px 0 0; animation: appear 0.25s forwards; + + &.top { + bottom: -2px; + top: auto; + padding: 0 0 $tag_height; + } + + &.left { + right: auto; + left: -2px; + } +} + +.scroll { + overflow: auto; + max-height: 7 * $row_height + $tag_height; + padding: 0 0 $gap / 2; } diff --git a/src/components/tags/TagAutocompleteRow/index.tsx b/src/components/tags/TagAutocompleteRow/index.tsx new file mode 100644 index 00000000..0340bd3d --- /dev/null +++ b/src/components/tags/TagAutocompleteRow/index.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; +import styles from './styles.module.scss'; +import classNames from 'classnames'; +import { Icon } from '~/components/input/Icon'; + +interface IProps { + selected: boolean; + title: string; + type: string; +} + +const TagAutocompleteRow: FC = ({ selected, type, title }) => ( +
+ + {title} +
+); + +export { TagAutocompleteRow }; diff --git a/src/components/tags/TagAutocompleteRow/styles.module.scss b/src/components/tags/TagAutocompleteRow/styles.module.scss new file mode 100644 index 00000000..ba9d63a9 --- /dev/null +++ b/src/components/tags/TagAutocompleteRow/styles.module.scss @@ -0,0 +1,33 @@ +$row_height: 24px; + +.row { + height: $row_height; + padding: 0 $gap; + display: flex; + align-items: center; + justify-content: flex-start; + font: $font_16_semibold; + border-top: lighten($content_bg, 2%) solid 1px; + opacity: 0.5; + cursor: pointer; + transition: all 0.1s; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + &:hover, &.selected { + opacity: 1; + background: lighten($content_bg, 4%); + } + + &.right { + color: $wisegreen; + opacity: 0.7; + } + + svg { + margin-right: 5px; + fill: currentColor; + flex: 0 0 16px; + } +} diff --git a/src/components/tags/TagInput/index.tsx b/src/components/tags/TagInput/index.tsx index 4211faff..3d8c5b37 100644 --- a/src/components/tags/TagInput/index.tsx +++ b/src/components/tags/TagInput/index.tsx @@ -1,13 +1,4 @@ -import React, { - ChangeEvent, - FC, - FocusEventHandler, - KeyboardEvent, - useCallback, - useMemo, - useRef, - useState, -} from 'react'; +import React, { ChangeEvent, FC, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { TagAutocomplete } from '~/components/tags/TagAutocomplete'; import { TagWrapper } from '~/components/tags/TagWrapper'; import styles from './styles.module.scss'; @@ -30,17 +21,19 @@ interface IProps { onAppend: (tags: string[]) => void; onClearTag: () => string | undefined; onSubmit: (last: string[]) => void; + exclude: string[]; } -const TagInput: FC = ({ onAppend, onClearTag, onSubmit }) => { +const TagInput: FC = ({ exclude, onAppend, onClearTag, onSubmit }) => { const [focused, setFocused] = useState(false); const [input, setInput] = useState(''); const ref = useRef(null); + const wrapper = useRef(null); const onInput = useCallback( ({ target: { value } }: ChangeEvent) => { if (!value.trim()) { - setInput(value); + setInput(value || ''); return; } @@ -50,7 +43,7 @@ const TagInput: FC = ({ onAppend, onClearTag, onSubmit }) => { onAppend(items.slice(0, items.length - 1)); } - setInput(items[items.length - 1]); + setInput(items[items.length - 1] || ''); }, [setInput] ); @@ -68,7 +61,7 @@ const TagInput: FC = ({ onAppend, onClearTag, onSubmit }) => { return; } - if (key === 'Enter' || key === ',' || key === 'Comma') { + if (key === ',' || key === 'Comma') { const created = prepareInput(input); if (created.length) { @@ -77,31 +70,47 @@ const TagInput: FC = ({ onAppend, onClearTag, onSubmit }) => { setInput(''); } - - if (key === 'Enter' && ref.current) { - ref.current.blur(); - } }, - [input, setInput, onClearTag, onAppend, onSubmit, ref.current] + [input, setInput, onClearTag, onAppend, onSubmit, ref.current, wrapper.current] ); const onFocus = useCallback(() => setFocused(true), []); - const onBlur = useCallback>(() => { - setFocused(false); + const onBlur = useCallback( + event => { + if (wrapper.current.contains(event.target)) { + ref.current.focus(); + return; + } - if (input.trim()) { - const created = prepareInput(input); - onAppend(created); + setFocused(false); + + if (input.trim()) { + setInput(''); + } + + onSubmit([]); + }, + [input, onAppend, setInput, onSubmit] + ); + + const onAutocompleteSelect = useCallback( + (val: string) => { + onAppend([val]); setInput(''); - onSubmit(created); - } - }, [input, onAppend, setInput, onSubmit]); + }, + [onAppend, setInput] + ); - const feature = useMemo(() => (input.substr(0, 1) === '/' ? 'green' : ''), [input]); + const feature = useMemo(() => (input?.substr(0, 1) === '/' ? 'green' : ''), [input]); + + useEffect(() => { + document.addEventListener('click', onBlur); + + return () => document.removeEventListener('click', onBlur); + }, [onBlur]); return ( -
- {onInput && focused && } +
= ({ onAppend, onClearTag, onSubmit }) => { placeholder={placeholder} maxLength={24} onChange={onInput} - onKeyUp={onKeyUp} - onBlur={onBlur} + onKeyDown={onKeyUp} onFocus={onFocus} ref={ref} /> + + {onInput && focused && input?.length > 0 && ( + + )}
); }; diff --git a/src/components/tags/TagInput/styles.module.scss b/src/components/tags/TagInput/styles.module.scss index 57fac5fd..c5ef4d75 100644 --- a/src/components/tags/TagInput/styles.module.scss +++ b/src/components/tags/TagInput/styles.module.scss @@ -1,4 +1,4 @@ .wrap { position: relative; - z-index: 13; + z-index: 20; } diff --git a/src/components/tags/Tags/index.tsx b/src/components/tags/Tags/index.tsx index 2b4b8f3a..8feec917 100644 --- a/src/components/tags/Tags/index.tsx +++ b/src/components/tags/Tags/index.tsx @@ -4,6 +4,7 @@ import { ITag } from '~/redux/types'; import uniq from 'ramda/es/uniq'; import { Tag } from '~/components/tags/Tag'; import { TagInput } from '~/components/tags/TagInput'; +import { separateTags } from '~/utils/tag'; type IProps = HTMLAttributes & { tags: Partial[]; @@ -15,15 +16,7 @@ type IProps = HTMLAttributes & { export const Tags: FC = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => { const [data, setData] = useState([]); - const [catTags, ordinaryTags] = useMemo( - () => - (tags || []).reduce( - (obj, tag) => - tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]], - [[], []] - ), - [tags] - ); + const [catTags, ordinaryTags] = useMemo(() => separateTags(tags), [tags]); const onSubmit = useCallback( (last: string[]) => { @@ -33,23 +26,6 @@ export const Tags: FC = ({ tags, is_editable, onTagsChange, onTagClick, [data] ); - // - // const onSubmit = useCallback(() => { - // const title = input && input.trim(); - // const items = (title ? [...data, { title }] : data) - // .filter(tag => tag.title.length > 0) - // .map(tag => ({ - // ...tag, - // title: tag.title.toLowerCase(), - // })); - // - // if (!items.length) return; - // - // setData(items); - // setInput(''); - // onTagsChange(uniq([...tags, ...items]).map(tag => tag.title)); - // }, [tags, data, onTagsChange, input, setInput]); - useEffect(() => { setData(data.filter(title => !tags.some(tag => tag.title.trim() === title.trim()))); }, [tags]); @@ -68,6 +44,11 @@ export const Tags: FC = ({ tags, is_editable, onTagsChange, onTagClick, return last; }, [data, setData]); + const exclude = useMemo(() => [...(data || []), ...(tags || []).map(({ title }) => title)], [ + data, + tags, + ]); + return ( {catTags.map(tag => ( @@ -83,7 +64,12 @@ export const Tags: FC = ({ tags, is_editable, onTagsChange, onTagClick, ))} {is_editable && ( - + )} ); diff --git a/src/constants/api.ts b/src/constants/api.ts index d942c61c..4c23cf31 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -48,5 +48,6 @@ export const API = { }, TAG: { NODES: `/tag/nodes`, + AUTOCOMPLETE: `/tag/autocomplete`, }, }; diff --git a/src/containers/node/NodeLayout/styles.scss b/src/containers/node/NodeLayout/styles.scss index a45d4b27..6549945c 100644 --- a/src/containers/node/NodeLayout/styles.scss +++ b/src/containers/node/NodeLayout/styles.scss @@ -23,7 +23,9 @@ justify-content: flex-start; padding-left: $gap / 2; min-width: 0; - + position: relative; + z-index: 10; + @media (max-width: 1024px) { padding-left: 0; padding-top: $comment_height / 2; diff --git a/src/redux/tag/actions.ts b/src/redux/tag/actions.ts index 835d3ca3..078f47a4 100644 --- a/src/redux/tag/actions.ts +++ b/src/redux/tag/actions.ts @@ -7,6 +7,17 @@ export const tagSetNodes = (nodes: Partial) => ({ }); export const tagLoadNodes = (tag: string) => ({ - type: TAG_ACTIONS.LOAD_TAG_NODES, + type: TAG_ACTIONS.LOAD_NODES, tag, }); + +export const tagSetAutocomplete = (autocomplete: Partial) => ({ + type: TAG_ACTIONS.SET_TAG_AUTOCOMPLETE, + autocomplete, +}); + +export const tagLoadAutocomplete = (search: string, exclude: string[]) => ({ + type: TAG_ACTIONS.LOAD_AUTOCOMPLETE, + search, + exclude, +}); diff --git a/src/redux/tag/api.ts b/src/redux/tag/api.ts index 92d87081..d657b9b7 100644 --- a/src/redux/tag/api.ts +++ b/src/redux/tag/api.ts @@ -17,3 +17,17 @@ export const getTagNodes = ({ .get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } })) .then(resultMiddleware) .catch(errorMiddleware); + +export const getTagAutocomplete = ({ + search, + exclude, + access, +}: { + access: string; + search: string; + exclude: string[]; +}): Promise> => + api + .get(API.TAG.AUTOCOMPLETE, configWithToken(access, { params: { search, exclude } })) + .then(resultMiddleware) + .catch(errorMiddleware); diff --git a/src/redux/tag/constants.ts b/src/redux/tag/constants.ts index 5c6b04f4..8c74e7ee 100644 --- a/src/redux/tag/constants.ts +++ b/src/redux/tag/constants.ts @@ -1,6 +1,8 @@ const prefix = 'TAG.'; export const TAG_ACTIONS = { - LOAD_TAG_NODES: `${prefix}LOAD_TAG_NODES`, + LOAD_NODES: `${prefix}LOAD_TAG_NODES`, SET_TAG_NODES: `${prefix}SET_TAG_NODES`, + SET_TAG_AUTOCOMPLETE: `${prefix}SET_TAG_NODES`, + LOAD_AUTOCOMPLETE: `${prefix}LOAD_TAG_AUTOCOMPLETE`, }; diff --git a/src/redux/tag/handlers.ts b/src/redux/tag/handlers.ts index b0bc92b1..d9c4ffe9 100644 --- a/src/redux/tag/handlers.ts +++ b/src/redux/tag/handlers.ts @@ -1,6 +1,6 @@ import { TAG_ACTIONS } from '~/redux/tag/constants'; import { ITagState } from '~/redux/tag/index'; -import { tagSetNodes } from '~/redux/tag/actions'; +import { tagSetAutocomplete, tagSetNodes } from '~/redux/tag/actions'; const setNodes = (state: ITagState, { nodes }: ReturnType) => ({ ...state, @@ -10,6 +10,18 @@ const setNodes = (state: ITagState, { nodes }: ReturnType) = }, }); +const setAutocomplete = ( + state: ITagState, + { autocomplete }: ReturnType +) => ({ + ...state, + autocomplete: { + ...state.autocomplete, + ...autocomplete, + }, +}); + export const TAG_HANDLERS = { [TAG_ACTIONS.SET_TAG_NODES]: setNodes, + [TAG_ACTIONS.SET_TAG_AUTOCOMPLETE]: setAutocomplete, }; diff --git a/src/redux/tag/index.ts b/src/redux/tag/index.ts index fddaff42..b55487f1 100644 --- a/src/redux/tag/index.ts +++ b/src/redux/tag/index.ts @@ -8,6 +8,10 @@ export interface ITagState { count: number; isLoading: boolean; }; + autocomplete: { + isLoading: boolean; + options: string[]; + }; } const INITIAL_STATE: ITagState = { @@ -16,6 +20,10 @@ const INITIAL_STATE: ITagState = { count: 0, isLoading: true, }, + autocomplete: { + isLoading: true, + options: [], + }, }; export default createReducer(INITIAL_STATE, TAG_HANDLERS); diff --git a/src/redux/tag/sagas.ts b/src/redux/tag/sagas.ts index 1b11f38d..ade289bd 100644 --- a/src/redux/tag/sagas.ts +++ b/src/redux/tag/sagas.ts @@ -1,9 +1,9 @@ import { TAG_ACTIONS } from '~/redux/tag/constants'; -import { call, put, select, takeLatest } from 'redux-saga/effects'; -import { tagLoadNodes, tagSetNodes } from '~/redux/tag/actions'; +import { call, delay, put, select, takeLatest } from 'redux-saga/effects'; +import { tagLoadAutocomplete, tagLoadNodes, tagSetAutocomplete, tagSetNodes, } from '~/redux/tag/actions'; import { reqWrapper } from '~/redux/auth/sagas'; import { selectTagNodes } from '~/redux/tag/selectors'; -import { getTagNodes } from '~/redux/tag/api'; +import { getTagAutocomplete, getTagNodes } from '~/redux/tag/api'; import { Unwrap } from '~/redux/types'; function* loadTagNodes({ tag }: ReturnType) { @@ -26,6 +26,28 @@ function* loadTagNodes({ tag }: ReturnType) { } } -export default function* tagSaga() { - yield takeLatest(TAG_ACTIONS.LOAD_TAG_NODES, loadTagNodes); +function* loadAutocomplete({ search, exclude }: ReturnType) { + if (search.length < 3) return; + + try { + yield put(tagSetAutocomplete({ isLoading: true })); + yield delay(100); + + const { data, error }: Unwrap> = yield call( + reqWrapper, + getTagAutocomplete, + { search, exclude } + ); + + if (error) throw new Error(error); + + yield put(tagSetAutocomplete({ options: data.tags, isLoading: false })); + } catch (e) { + yield put(tagSetAutocomplete({ isLoading: false })); + } +} + +export default function* tagSaga() { + yield takeLatest(TAG_ACTIONS.LOAD_NODES, loadTagNodes); + yield takeLatest(TAG_ACTIONS.LOAD_AUTOCOMPLETE, loadAutocomplete); } diff --git a/src/redux/tag/selectors.ts b/src/redux/tag/selectors.ts index 338bfda5..74ffdc3e 100644 --- a/src/redux/tag/selectors.ts +++ b/src/redux/tag/selectors.ts @@ -2,3 +2,4 @@ import { IState } from '~/redux/store'; export const selectTag = (state: IState) => state.tag; export const selectTagNodes = (state: IState) => state.tag.nodes; +export const selectTagAutocomplete = (state: IState) => state.tag.autocomplete; diff --git a/src/sprites/Sprites.tsx b/src/sprites/Sprites.tsx index a8efa18e..d3c8a38e 100644 --- a/src/sprites/Sprites.tsx +++ b/src/sprites/Sprites.tsx @@ -160,6 +160,11 @@ const Sprites: FC<{}> = () => ( + + + + + @@ -220,6 +225,11 @@ const Sprites: FC<{}> = () => ( + + + + + diff --git a/src/utils/tag.ts b/src/utils/tag.ts new file mode 100644 index 00000000..2913ba95 --- /dev/null +++ b/src/utils/tag.ts @@ -0,0 +1,13 @@ +import { ITag } from '~/redux/types'; + +export const separateTags = (tags: Partial[]): Partial[][] => + (tags || []).reduce( + (obj, tag) => + tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]], + [[], []] + ); + +export const separateTagOptions = (options: string[]): string[][] => + separateTags(options.map((title): Partial => ({ title }))).map(item => + item.map(({ title }) => title) + );