1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 12:56:41 +07:00

added tag autocomplete

This commit is contained in:
Fedor Katurov 2020-10-31 20:49:28 +07:00
parent 1414245a1a
commit 359cfaee7a
18 changed files with 375 additions and 80 deletions

View file

@ -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<IProps> = () => <div className={classNames(styles.window)}>auto</div>;
type Props = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
exclude: string[];
input: HTMLInputElement;
onSelect: (val: string) => void;
search: string;
};
const TagAutocompleteUnconnected: FC<Props> = ({
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<HTMLDivElement>(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 (
<div className={classNames(styles.window, { [styles.top]: top, [styles.left]: left })}>
<div className={styles.scroll} ref={scroll}>
<TagAutocompleteRow selected={selected === -1} title={search} type="enter" />
{categories.map((item, i) => (
<TagAutocompleteRow selected={selected === i} title={item} type="right" key={item} />
))}
{tags.map((item, i) => (
<TagAutocompleteRow
selected={selected === categories.length + i}
title={item}
type="tag"
key={item}
/>
))}
</div>
</div>
);
};
const TagAutocomplete = connect(mapStateToProps, mapDispatchToProps)(TagAutocompleteUnconnected);
export { TagAutocomplete };

View file

@ -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;
}

View file

@ -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<IProps> = ({ selected, type, title }) => (
<div className={classNames(styles.row, styles[type], { [styles.selected]: selected })}>
<Icon icon={type} size={16} />
<span>{title}</span>
</div>
);
export { TagAutocompleteRow };

View file

@ -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;
}
}

View file

@ -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<IProps> = ({ onAppend, onClearTag, onSubmit }) => {
const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
const [focused, setFocused] = useState(false);
const [input, setInput] = useState('');
const ref = useRef<HTMLInputElement>(null);
const wrapper = useRef<HTMLDivElement>(null);
const onInput = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
if (!value.trim()) {
setInput(value);
setInput(value || '');
return;
}
@ -50,7 +43,7 @@ const TagInput: FC<IProps> = ({ 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<IProps> = ({ 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<IProps> = ({ 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<FocusEventHandler<HTMLInputElement>>(() => {
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 (
<div className={styles.wrap}>
{onInput && focused && <TagAutocomplete />}
<div className={styles.wrap} ref={wrapper}>
<TagWrapper title={input || placeholder} has_input={true} feature={feature}>
<input
type="text"
@ -110,12 +119,20 @@ const TagInput: FC<IProps> = ({ onAppend, onClearTag, onSubmit }) => {
placeholder={placeholder}
maxLength={24}
onChange={onInput}
onKeyUp={onKeyUp}
onBlur={onBlur}
onKeyDown={onKeyUp}
onFocus={onFocus}
ref={ref}
/>
</TagWrapper>
{onInput && focused && input?.length > 0 && (
<TagAutocomplete
exclude={exclude}
input={ref.current}
onSelect={onAutocompleteSelect}
search={input}
/>
)}
</div>
);
};

View file

@ -1,4 +1,4 @@
.wrap {
position: relative;
z-index: 13;
z-index: 20;
}

View file

@ -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<HTMLDivElement> & {
tags: Partial<ITag>[];
@ -15,15 +16,7 @@ type IProps = HTMLAttributes<HTMLDivElement> & {
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => {
const [data, setData] = useState<string[]>([]);
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<IProps> = ({ 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<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
return last;
}, [data, setData]);
const exclude = useMemo(() => [...(data || []), ...(tags || []).map(({ title }) => title)], [
data,
tags,
]);
return (
<TagField {...props}>
{catTags.map(tag => (
@ -83,7 +64,12 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
))}
{is_editable && (
<TagInput onAppend={onAppendTag} onClearTag={onClearTag} onSubmit={onSubmit} />
<TagInput
onAppend={onAppendTag}
onClearTag={onClearTag}
onSubmit={onSubmit}
exclude={exclude}
/>
)}
</TagField>
);