From ede4f4662c1aa625b82ca73b7e11758915cbb460 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Sat, 31 Oct 2020 18:09:15 +0700 Subject: [PATCH 1/3] refactored tag component --- .../containers/TagField/styles.scss | 4 + src/components/node/NodeTags/index.tsx | 2 +- src/components/node/NodeTags/placeholder.tsx | 17 --- .../node/NodeTagsPlaceholder/index.tsx | 15 +++ src/components/node/Tag/index.tsx | 69 ---------- src/components/node/Tags/index.tsx | 118 ----------------- src/components/tags/Tag/index.tsx | 43 +++++++ src/components/tags/Tag/styles.scss | 4 + src/components/tags/TagAutocomplete/index.tsx | 9 ++ .../tags/TagAutocomplete/styles.module.scss | 11 ++ src/components/tags/TagInput/index.tsx | 121 ++++++++++++++++++ src/components/tags/TagWrapper/index.tsx | 40 ++++++ .../TagWrapper/styles.module.scss} | 5 +- src/components/tags/Tags/index.tsx | 90 +++++++++++++ src/containers/sidebars/TagSidebar/index.tsx | 2 +- webpack.config.js | 1 + 16 files changed, 344 insertions(+), 207 deletions(-) delete mode 100644 src/components/node/NodeTags/placeholder.tsx create mode 100644 src/components/node/NodeTagsPlaceholder/index.tsx delete mode 100644 src/components/node/Tag/index.tsx delete mode 100644 src/components/node/Tags/index.tsx create mode 100644 src/components/tags/Tag/index.tsx create mode 100644 src/components/tags/Tag/styles.scss create mode 100644 src/components/tags/TagAutocomplete/index.tsx create mode 100644 src/components/tags/TagAutocomplete/styles.module.scss create mode 100644 src/components/tags/TagInput/index.tsx create mode 100644 src/components/tags/TagWrapper/index.tsx rename src/components/{node/Tag/styles.scss => tags/TagWrapper/styles.module.scss} (97%) create mode 100644 src/components/tags/Tags/index.tsx diff --git a/src/components/containers/TagField/styles.scss b/src/components/containers/TagField/styles.scss index f65e12d0..18edac3e 100644 --- a/src/components/containers/TagField/styles.scss +++ b/src/components/containers/TagField/styles.scss @@ -3,4 +3,8 @@ align-items: flex-start; justify-content: flex-start; flex-wrap: wrap; + + &> * { + margin: 0 $gap $gap 0; + } } diff --git a/src/components/node/NodeTags/index.tsx b/src/components/node/NodeTags/index.tsx index 910d2590..d5c48923 100644 --- a/src/components/node/NodeTags/index.tsx +++ b/src/components/node/NodeTags/index.tsx @@ -1,6 +1,6 @@ import React, { FC, memo } from 'react'; -import { Tags } from '../Tags'; import { ITag } from '~/redux/types'; +import { Tags } from '~/components/tags/Tags'; interface IProps { is_editable?: boolean; diff --git a/src/components/node/NodeTags/placeholder.tsx b/src/components/node/NodeTags/placeholder.tsx deleted file mode 100644 index f5b015b4..00000000 --- a/src/components/node/NodeTags/placeholder.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React, { FC, memo } from "react"; -import { Tags } from "../Tags"; -import { ITag } from "~/redux/types"; - -interface IProps { - is_editable?: boolean; - tags: ITag[]; - onChange?: (tags: string[]) => void; -} - -const NodeTagsPlaceholder: FC = memo( - ({ is_editable, tags, onChange }) => ( - - ) -); - -export { NodeTagsPlaceholder }; diff --git a/src/components/node/NodeTagsPlaceholder/index.tsx b/src/components/node/NodeTagsPlaceholder/index.tsx new file mode 100644 index 00000000..4fb8e75d --- /dev/null +++ b/src/components/node/NodeTagsPlaceholder/index.tsx @@ -0,0 +1,15 @@ +import React, { FC, memo } from 'react'; +import { ITag } from '~/redux/types'; +import { Tags } from '~/components/tags/Tags'; + +interface IProps { + is_editable?: boolean; + tags: ITag[]; + onChange?: (tags: string[]) => void; +} + +const NodeTagsPlaceholder: FC = memo(({ is_editable, tags, onChange }) => ( + +)); + +export { NodeTagsPlaceholder }; diff --git a/src/components/node/Tag/index.tsx b/src/components/node/Tag/index.tsx deleted file mode 100644 index f9bb1c8a..00000000 --- a/src/components/node/Tag/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { ChangeEventHandler, FC, FocusEventHandler, KeyboardEventHandler, useCallback, } from 'react'; -import * as styles from './styles.scss'; -import { ITag } from '~/redux/types'; -import classNames = require('classnames'); - -const getTagFeature = (tag: Partial) => { - if (tag.title.substr(0, 1) === '/') return 'green'; - - return ''; -}; - -interface IProps { - tag: Partial; - size?: 'normal' | 'big'; - - is_hoverable?: boolean; - is_editing?: boolean; - - onInput?: ChangeEventHandler; - onKeyUp?: KeyboardEventHandler; - onBlur?: FocusEventHandler; - onClick?: (tag: Partial) => void; -} - -const Tag: FC = ({ - tag, - is_hoverable, - is_editing, - size = 'normal', - onInput, - onKeyUp, - onBlur, - onClick, -}) => { - const onClickHandler = useCallback(() => { - if (!onClick) return; - onClick(tag); - }, [tag, onClick]); - - return ( -
-
-
{tag.title}
- - {onInput && ( - - )} -
- ); -}; - -export { Tag }; diff --git a/src/components/node/Tags/index.tsx b/src/components/node/Tags/index.tsx deleted file mode 100644 index 894dde37..00000000 --- a/src/components/node/Tags/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { - ChangeEvent, - FC, - HTMLAttributes, - KeyboardEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { TagField } from '~/components/containers/TagField'; -import { ITag } from '~/redux/types'; -import { Tag } from '~/components/node/Tag'; -import uniq from 'ramda/es/uniq'; - -type IProps = HTMLAttributes & { - tags: Partial[]; - is_editable?: boolean; - onTagsChange?: (tags: string[]) => void; - onTagClick?: (tag: Partial) => void; -}; - -export const Tags: FC = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => { - const [input, setInput] = useState(''); - const [data, setData] = useState([]); - const timer = useRef(null); - - 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 onInput = useCallback( - ({ target: { value } }: ChangeEvent) => { - clearTimeout(timer.current); - setInput(value); - }, - [setInput, timer] - ); - - const onKeyUp = useCallback( - ({ key }: KeyboardEvent) => { - if (key === 'Backspace' && input === '' && data.length) { - setData(data.slice(0, data.length - 1)); - setInput(data[data.length - 1].title); - } - - if (key === 'Enter' || key === ',' || key === 'Comma') { - setData( - uniq([ - ...data, - ...input - .split(',') - .map((title: string) => - title - .trim() - .substr(0, 32) - .toLowerCase() - ) - .filter(el => el.length > 0) - .filter(el => !tags.some(tag => tag.title.trim() === el.trim())) - .map(title => ({ - title, - })), - ]) - ); - setInput(''); - } - }, - [input, setInput, data, setData] - ); - - 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]); - - return ( - - {catTags.map(tag => ( - - ))} - - {ordinaryTags.map(tag => ( - - ))} - - {data.map(tag => ( - - ))} - - {is_editable && ( - - )} - - ); -}; diff --git a/src/components/tags/Tag/index.tsx b/src/components/tags/Tag/index.tsx new file mode 100644 index 00000000..2dcbc077 --- /dev/null +++ b/src/components/tags/Tag/index.tsx @@ -0,0 +1,43 @@ +import React, { FC, FocusEventHandler, useCallback, } from 'react'; +import * as styles from './styles.scss'; +import { ITag } from '~/redux/types'; +import { TagWrapper } from '~/components/tags/TagWrapper'; + +const getTagFeature = (tag: Partial) => { + if (tag.title.substr(0, 1) === '/') return 'green'; + + return ''; +}; + +interface IProps { + tag: Partial; + size?: 'normal' | 'big'; + + is_hoverable?: boolean; + is_editing?: boolean; + + onBlur?: FocusEventHandler; + onClick?: (tag: Partial) => void; +} + +const Tag: FC = ({ tag, is_hoverable, is_editing, size = 'normal', onBlur, onClick }) => { + const onClickHandler = useCallback(() => { + if (!onClick) return; + onClick(tag); + }, [tag, onClick]); + + return ( +
+ +
+ ); +}; + +export { Tag }; diff --git a/src/components/tags/Tag/styles.scss b/src/components/tags/Tag/styles.scss new file mode 100644 index 00000000..a834b87e --- /dev/null +++ b/src/components/tags/Tag/styles.scss @@ -0,0 +1,4 @@ +.wrap { + background-color: blue; + position: relative; +} diff --git a/src/components/tags/TagAutocomplete/index.tsx b/src/components/tags/TagAutocomplete/index.tsx new file mode 100644 index 00000000..69ce26fa --- /dev/null +++ b/src/components/tags/TagAutocomplete/index.tsx @@ -0,0 +1,9 @@ +import React, { FC } from 'react'; +import styles from './styles.module.scss'; +import classNames from 'classnames'; + +interface IProps {} + +const TagAutocomplete: FC = () =>
auto
; + +export { TagAutocomplete }; diff --git a/src/components/tags/TagAutocomplete/styles.module.scss b/src/components/tags/TagAutocomplete/styles.module.scss new file mode 100644 index 00000000..740c4a96 --- /dev/null +++ b/src/components/tags/TagAutocomplete/styles.module.scss @@ -0,0 +1,11 @@ +.window { + display: none; + position: absolute; + top: 0; + right: 0; + width: calc(90vw - 20px); + max-width: 300px; + background: red; + height: 100px; + z-index: -1; +} diff --git a/src/components/tags/TagInput/index.tsx b/src/components/tags/TagInput/index.tsx new file mode 100644 index 00000000..cae76eb1 --- /dev/null +++ b/src/components/tags/TagInput/index.tsx @@ -0,0 +1,121 @@ +import React, { + ChangeEvent, + FC, + FocusEventHandler, + KeyboardEvent, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { TagAutocomplete } from '~/components/tags/TagAutocomplete'; +import { TagWrapper } from '~/components/tags/TagWrapper'; + +const placeholder = 'Добавить'; + +const prepareInput = (input: string): string[] => { + return input + .split(',') + .map((title: string) => + title + .trim() + .substr(0, 32) + .toLowerCase() + ) + .filter(el => el.length > 0); +}; + +interface IProps { + onAppend: (tags: string[]) => void; + onClearTag: () => string | undefined; + onSubmit: (last: string[]) => void; +} + +const TagInput: FC = ({ onAppend, onClearTag, onSubmit }) => { + const [focused, setFocused] = useState(false); + const [input, setInput] = useState(''); + const ref = useRef(null); + + const onInput = useCallback( + ({ target: { value } }: ChangeEvent) => { + if (!value.trim()) { + setInput(value); + return; + } + + const items = prepareInput(value); + + if (items.length > 1) { + onAppend(items.slice(0, items.length - 1)); + } + + setInput(items[items.length - 1]); + }, + [setInput] + ); + + const onKeyUp = useCallback( + ({ key }: KeyboardEvent) => { + if (key === 'Escape' && ref.current) { + setInput(''); + ref.current.blur(); + return; + } + + if (key === 'Backspace' && input === '') { + setInput(onClearTag() || ''); + return; + } + + if (key === 'Enter' || key === ',' || key === 'Comma') { + const created = prepareInput(input); + + if (created.length) { + onAppend(created); + } + + setInput(''); + } + + if (key === 'Enter' && ref.current) { + ref.current.blur(); + } + }, + [input, setInput, onClearTag, onAppend, onSubmit, ref.current] + ); + + const onFocus = useCallback(() => setFocused(true), []); + const onBlur = useCallback>(() => { + setFocused(false); + + if (input.trim()) { + const created = prepareInput(input); + onAppend(created); + setInput(''); + onSubmit(created); + } + }, [input, onAppend, setInput, onSubmit]); + + const feature = useMemo(() => (input.substr(0, 1) === '/' ? 'green' : ''), [input]); + + return ( + + {onInput && } + + + + ); +}; + +export { TagInput }; diff --git a/src/components/tags/TagWrapper/index.tsx b/src/components/tags/TagWrapper/index.tsx new file mode 100644 index 00000000..2eb0f28e --- /dev/null +++ b/src/components/tags/TagWrapper/index.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; +import classNames from 'classnames'; +import styles from './styles.module.scss'; + +interface IProps { + feature?: string; + size?: string; + is_hoverable?: boolean; + is_editing?: boolean; + has_input?: boolean; + onClick?: () => void; + title?: string; +} + +const TagWrapper: FC = ({ + children, + feature, + size, + is_hoverable, + is_editing, + has_input, + onClick, + title = '', +}) => ( +
+
+
{title}
+ {children} +
+); + +export { TagWrapper }; diff --git a/src/components/node/Tag/styles.scss b/src/components/tags/TagWrapper/styles.module.scss similarity index 97% rename from src/components/node/Tag/styles.scss rename to src/components/tags/TagWrapper/styles.module.scss index 1fb4d6df..87e71e1b 100644 --- a/src/components/node/Tag/styles.scss +++ b/src/components/tags/TagWrapper/styles.module.scss @@ -14,8 +14,9 @@ $big: 1.2; font: $font_14_semibold; align-self: flex-start; padding: 0 8px 0 0; - margin: 0 $gap $gap 0; + //margin: 0 $gap $gap 0; position: relative; + z-index: 4; &:global(.big) { height: $tag_height * $big; @@ -88,6 +89,7 @@ $big: 1.2; top: 0; bottom: 0; width: 100%; + min-width: 100px; padding-left: $tag_height; padding-right: 5px; box-sizing: border-box; @@ -118,3 +120,4 @@ $big: 1.2; overflow: hidden; text-overflow: ellipsis; } + diff --git a/src/components/tags/Tags/index.tsx b/src/components/tags/Tags/index.tsx new file mode 100644 index 00000000..2cf064fb --- /dev/null +++ b/src/components/tags/Tags/index.tsx @@ -0,0 +1,90 @@ +import React, { FC, HTMLAttributes, useCallback, useEffect, useMemo, useState, } from 'react'; +import { TagField } from '~/components/containers/TagField'; +import { ITag } from '~/redux/types'; +import uniq from 'ramda/es/uniq'; +import { Tag } from '~/components/tags/Tag'; +import { TagInput } from '~/components/tags/TagInput'; + +type IProps = HTMLAttributes & { + tags: Partial[]; + is_editable?: boolean; + onTagsChange?: (tags: string[]) => void; + onTagClick?: (tag: Partial) => void; +}; + +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 onSubmit = useCallback( + (last: string[]) => { + const exist = tags.map(tag => tag.title); + onTagsChange(uniq([...exist, ...data, ...last])); + }, + [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]); + + const onAppendTag = useCallback( + (created: string[]) => { + setData(uniq([...data, ...created]).filter(title => !tags.some(it => it.title === title))); + }, + [data, setData, tags] + ); + + const onClearTag = useCallback((): string | undefined => { + if (!data.length) return; + const last = data[data.length - 1]; + setData(data.slice(0, data.length - 1)); + return last; + }, [data, setData]); + + return ( + + {catTags.map(tag => ( + + ))} + + {ordinaryTags.map(tag => ( + + ))} + + {data.map(title => ( + + ))} + + {is_editable && ( + + )} + + ); +}; diff --git a/src/containers/sidebars/TagSidebar/index.tsx b/src/containers/sidebars/TagSidebar/index.tsx index bcef7151..edeca2d0 100644 --- a/src/containers/sidebars/TagSidebar/index.tsx +++ b/src/containers/sidebars/TagSidebar/index.tsx @@ -2,7 +2,6 @@ import React, { FC, useCallback, useEffect, useMemo } from 'react'; import { SidebarWrapper } from '~/containers/sidebars/SidebarWrapper'; import styles from './styles.module.scss'; import { useHistory, useRouteMatch } from 'react-router'; -import { Tag } from '~/components/node/Tag'; import { Icon } from '~/components/input/Icon'; import { Link } from 'react-router-dom'; import { TagSidebarList } from '~/components/sidebar/TagSidebarList'; @@ -11,6 +10,7 @@ import { selectTagNodes } from '~/redux/tag/selectors'; import * as ACTIONS from '~/redux/tag/actions'; import { LoaderCircle } from '~/components/input/LoaderCircle'; import { InfiniteScroll } from '~/components/containers/InfiniteScroll'; +import { Tag } from '~/components/tags/Tag'; const mapStateToProps = state => ({ nodes: selectTagNodes(state), diff --git a/webpack.config.js b/webpack.config.js index 6cb6b064..f6bc4dd1 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -176,6 +176,7 @@ module.exports = () => { contentBase: 'dist', publicPath: '/', hot: true, + open: false, }, }; }; From 1414245a1a2b678e46220a6d49cb1667552d19b1 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Sat, 31 Oct 2020 18:17:57 +0700 Subject: [PATCH 2/3] made autocomplete popup --- src/components/tags/Tag/index.tsx | 21 +++++------- src/components/tags/Tag/styles.scss | 4 --- .../tags/TagAutocomplete/styles.module.scss | 15 ++++++-- src/components/tags/TagInput/index.tsx | 34 ++++++++++--------- .../tags/TagInput/styles.module.scss | 4 +++ .../tags/TagWrapper/styles.module.scss | 2 +- src/components/tags/Tags/index.tsx | 2 +- 7 files changed, 45 insertions(+), 37 deletions(-) create mode 100644 src/components/tags/TagInput/styles.module.scss diff --git a/src/components/tags/Tag/index.tsx b/src/components/tags/Tag/index.tsx index 2dcbc077..8207e65a 100644 --- a/src/components/tags/Tag/index.tsx +++ b/src/components/tags/Tag/index.tsx @@ -1,5 +1,4 @@ -import React, { FC, FocusEventHandler, useCallback, } from 'react'; -import * as styles from './styles.scss'; +import React, { FC, FocusEventHandler, useCallback } from 'react'; import { ITag } from '~/redux/types'; import { TagWrapper } from '~/components/tags/TagWrapper'; @@ -27,16 +26,14 @@ const Tag: FC = ({ tag, is_hoverable, is_editing, size = 'normal', onBlu }, [tag, onClick]); return ( -
- -
+ ); }; diff --git a/src/components/tags/Tag/styles.scss b/src/components/tags/Tag/styles.scss index a834b87e..e69de29b 100644 --- a/src/components/tags/Tag/styles.scss +++ b/src/components/tags/Tag/styles.scss @@ -1,4 +0,0 @@ -.wrap { - background-color: blue; - position: relative; -} diff --git a/src/components/tags/TagAutocomplete/styles.module.scss b/src/components/tags/TagAutocomplete/styles.module.scss index 740c4a96..7d620f93 100644 --- a/src/components/tags/TagAutocomplete/styles.module.scss +++ b/src/components/tags/TagAutocomplete/styles.module.scss @@ -1,11 +1,20 @@ +@keyframes appear { + 0% { opacity: 0 } + 100% { opacity: 100 } +} + .window { - display: none; + box-shadow: transparentize(black, 0.5) 4px 4px 4px, inset transparentize(white, 0.95) 1px 1px; + position: absolute; top: 0; right: 0; width: calc(90vw - 20px); max-width: 300px; - background: red; + background: lighten($content_bg, 4%); height: 100px; - z-index: -1; + z-index: 10; + border-radius: 3px; + padding-top: $tag_height; + animation: appear 0.25s forwards; } diff --git a/src/components/tags/TagInput/index.tsx b/src/components/tags/TagInput/index.tsx index cae76eb1..4211faff 100644 --- a/src/components/tags/TagInput/index.tsx +++ b/src/components/tags/TagInput/index.tsx @@ -10,6 +10,7 @@ import React, { } from 'react'; import { TagAutocomplete } from '~/components/tags/TagAutocomplete'; import { TagWrapper } from '~/components/tags/TagWrapper'; +import styles from './styles.module.scss'; const placeholder = 'Добавить'; @@ -99,22 +100,23 @@ const TagInput: FC = ({ onAppend, onClearTag, onSubmit }) => { const feature = useMemo(() => (input.substr(0, 1) === '/' ? 'green' : ''), [input]); return ( - - {onInput && } - - - +
+ {onInput && focused && } + + + +
); }; diff --git a/src/components/tags/TagInput/styles.module.scss b/src/components/tags/TagInput/styles.module.scss new file mode 100644 index 00000000..57fac5fd --- /dev/null +++ b/src/components/tags/TagInput/styles.module.scss @@ -0,0 +1,4 @@ +.wrap { + position: relative; + z-index: 13; +} diff --git a/src/components/tags/TagWrapper/styles.module.scss b/src/components/tags/TagWrapper/styles.module.scss index 87e71e1b..a16a6358 100644 --- a/src/components/tags/TagWrapper/styles.module.scss +++ b/src/components/tags/TagWrapper/styles.module.scss @@ -16,7 +16,7 @@ $big: 1.2; padding: 0 8px 0 0; //margin: 0 $gap $gap 0; position: relative; - z-index: 4; + z-index: 12; &:global(.big) { height: $tag_height * $big; diff --git a/src/components/tags/Tags/index.tsx b/src/components/tags/Tags/index.tsx index 2cf064fb..2b4b8f3a 100644 --- a/src/components/tags/Tags/index.tsx +++ b/src/components/tags/Tags/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, HTMLAttributes, useCallback, useEffect, useMemo, useState, } from 'react'; +import React, { FC, HTMLAttributes, useCallback, useEffect, useMemo, useState } from 'react'; import { TagField } from '~/components/containers/TagField'; import { ITag } from '~/redux/types'; import uniq from 'ramda/es/uniq'; From 359cfaee7a633e0f9001439fea8c21cc827a6f23 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Sat, 31 Oct 2020 20:49:28 +0700 Subject: [PATCH 3/3] added tag autocomplete --- src/components/tags/TagAutocomplete/index.tsx | 133 +++++++++++++++++- .../tags/TagAutocomplete/styles.module.scss | 33 +++-- .../tags/TagAutocompleteRow/index.tsx | 19 +++ .../TagAutocompleteRow/styles.module.scss | 33 +++++ src/components/tags/TagInput/index.tsx | 81 ++++++----- .../tags/TagInput/styles.module.scss | 2 +- src/components/tags/Tags/index.tsx | 40 ++---- src/constants/api.ts | 1 + src/containers/node/NodeLayout/styles.scss | 4 +- src/redux/tag/actions.ts | 13 +- src/redux/tag/api.ts | 14 ++ src/redux/tag/constants.ts | 4 +- src/redux/tag/handlers.ts | 14 +- src/redux/tag/index.ts | 8 ++ src/redux/tag/sagas.ts | 32 ++++- src/redux/tag/selectors.ts | 1 + src/sprites/Sprites.tsx | 10 ++ src/utils/tag.ts | 13 ++ 18 files changed, 375 insertions(+), 80 deletions(-) create mode 100644 src/components/tags/TagAutocompleteRow/index.tsx create mode 100644 src/components/tags/TagAutocompleteRow/styles.module.scss create mode 100644 src/utils/tag.ts 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) + );