Skip to content

Instantly share code, notes, and snippets.

@konstantin24121
Last active May 1, 2018 08:58
Show Gist options
  • Save konstantin24121/a4494cf465ce91843bcda7b7df8aefd4 to your computer and use it in GitHub Desktop.
Save konstantin24121/a4494cf465ce91843bcda7b7df8aefd4 to your computer and use it in GitHub Desktop.
Component example from Relap-UI (Bigger, Longer & Uncut)
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
/* subcomponents */
import A from '../A';
import DropdownFiltered from '../DropdownFiltered';
import InlineSVG from '../InlineSVG';
import Tooltip from '../Tooltip';
/* component styles */
import { styles } from './styles.scss';
/**
* Поле для редактирования коллекции тегов
*/
export default class TagsEditable extends PureComponent {
static propTypes = {
/**
* Коллекция тегов
*/
currentTags: PropTypes.arrayOf(
PropTypes.shape({
/**
* Идентификатор
*/
id: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
/**
* Заголовок
*/
title: PropTypes.string.isRequired,
/**
* Опции для компонента Tooltip
*/
tooltip: PropTypes.object,
}),
).isRequired,
/**
* Коллекция возможных тегов, выводится в дропдауне
*/
suggestionTags: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
title: PropTypes.string.isRequired,
tooltip: PropTypes.object,
}),
),
/**
* Разделитель для тегов
*/
delimiter: PropTypes.string,
/**
* Стиль тегов
*/
mod: PropTypes.oneOf(['inline', 'label']),
/**
* Тип компонента. field - поле для ввода. editable - блок с кнопкой редактирования
*/
type: PropTypes.oneOf(['field', 'editable']),
/**
* Плейсхолдер
*/
placeholder: PropTypes.string,
/**
* Скрывать введеные теги из коллекции возможных?
*/
hideUsedTags: PropTypes.bool,
/**
* Размер буфера компонента, для сохранения состояний тегов
*/
bufferSize: PropTypes.number,
/**
* Кнопка редактирования выравнивается по последнему тегу?
*/
withFloatingEditableIcon: PropTypes.bool,
/**
* Событие при сохранении
*/
onSave: PropTypes.func,
/**
* Событие при вводе нового тега
*/
onEnter: PropTypes.func,
/**
* Событие при выборе тега из коллекции возможных
*/
onSuggestionChoose: PropTypes.func,
/**
* Событие при добавлении нового тега
*/
onAdd: PropTypes.func,
/**
* Событие при изменении тегов
*/
onChange: PropTypes.func,
/**
* Событие при удалении тега
*/
onDelete: PropTypes.func,
};
static defaultProps = {
currentTags: [],
suggestionTags: [],
delimiter: ' ',
mod: 'inline',
type: 'editable',
bufferSize: 3,
hideUsedTags: true,
placeholder: 'Добавить тег',
withFloatingEditableIcon: false,
/* eslint-disable no-unused-vars */
onSave: (currentTags) => {},
onEnter: (value) => {},
onSuggestionChoose: ({ id }) => {},
onAdd: ({ value, id }) => {},
onChange: ({ tags }) => {},
onDelete: ({ value, id, index }) => {},
/* eslint-enable no-unused-vars */
};
constructor(props) {
super(props);
this.state = {
isActive: false,
/**
* Тег на котором стоит фокус
*/
currentSelectedTagIdx: null,
/**
* Выбранные теги
*/
selectedTagsIdx: [],
currentTags: props.currentTags,
value: '',
isFocused: false,
/**
* Массив предыдущих состояний. Используется при undo событии
*/
timetravel: [],
};
}
componentWillReceiveProps(nextProps) {
this.setState({ currentTags: nextProps.currentTags });
}
componentDidUpdate(prevProps, prevState) {
{
const prevIsActive = prevState.isActive;
const currIsActive = this.state.isActive;
if (currIsActive && !prevIsActive) {
document.addEventListener('click', this.handleBodyClick);
document.addEventListener('keydown', this.handleBodyKeyDown);
}
if (!currIsActive && prevIsActive) {
document.removeEventListener('click', this.handleBodyClick);
document.removeEventListener('keydown', this.handleBodyKeyDown);
}
}
// Tags collection are changed
// Так как теги могут быть только добавлены и удалены
// есть смысл сравнить только длинны массивов
if (this.state.currentTags.length !== prevState.currentTags.length &&
this.state.timetravel.length === prevState.timetravel.length) {
const { bufferSize } = this.props;
const prevTimetravel = this.state.timetravel.slice();
prevTimetravel.unshift({
currentTags: prevState.currentTags,
});
const timetravel = prevTimetravel.slice(0, bufferSize);
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ timetravel });
this.props.onChange({ tags: this.state.currentTags });
}
}
componentWillUnmount() {
document.removeEventListener('click', this.handleBodyClick);
document.removeEventListener('keydown', this.handleBodyKeyDown);
}
/**
* Добавить тег в коллекцию
* @param {Number|String|null} id
* @param {String} title
* @param {String} newStateValue новое значение для поля ввода
*/
addTag = (id, title, newStateValue) => {
const { currentTags } = this.state;
const trimTitle = title.trim();
this.setState({
currentTags: [...currentTags, { id, title: trimTitle }],
value: newStateValue,
isFocused: true,
}, () => {
this.props.onAdd({ value: trimTitle, id });
this.field.focus();
});
};
/**
* Сохранить теги, вызвать событие сохранения и прекратить редактирование
* @return {void}
*/
saveTagsAndCloseField = () => {
const { type } = this.props;
const isField = type === 'field';
const { value, currentTags } = this.state;
this.setState({
value: '',
isActive: false,
currentSelectedTagIdx: null,
selectedTagsIdx: [],
isFocused: false,
currentTags: !value ? currentTags : [...currentTags, { id: null, title: value.trim() }],
}, () => {
const currentTagsIds = this.props.currentTags.map(tag => tag.id);
if (this.state.currentTags.every(({ id }) => currentTagsIds.includes(id))) return;
if (value) this.props.onAdd({ value: value.trim() });
if (isField) {
this.field.blur();
} else {
this.props.onSave(this.state.currentTags);
}
});
};
/**
* Сбросить выделеные теги и вернуть фокус на поле редактирования
* @return {void}
*/
clearnSelected = () => {
this.setState({
currentSelectedTagIdx: null,
selectedTagsIdx: [],
isFocused: true,
}, () => this.field.focus());
};
/**
* Сгенерить событие onDelete
* @param {Array} removedTags коллекция удаленных тегов
* @param {Array} selectedTags индексы удаленных тегов
* @return {void}
*/
fireOnDelete = (removedTags, selectedTags) => () => {
removedTags.forEach((tag, index) => {
this.props.onDelete({
value: tag.title,
id: tag.id,
index: selectedTags[index],
});
});
};
/**
* Клик на кнопку редактирования
* @return {void}
*/
handlePencilClick = () => {
const { isActive } = this.state;
if (isActive) {
this.saveTagsAndCloseField();
} else {
this.setState({ isActive: true, isFocused: true }, () => this.field.focus());
}
};
/**
* Клик по документу
* @param {ClickEvent} e
* @return {void}
*/
handleBodyClick = (e) => {
if (this.root.contains(e.target)) return;
this.saveTagsAndCloseField();
};
/**
* Нажатие клавиши обрабатываемое на документе
* Срабатывает только если компонент активен
* @param {KeyboardEvent} e
* @return {void}
*/
handleBodyKeyDown = (e) => {
const { keyCode, ctrlKey, metaKey, shiftKey } = e;
const { currentSelectedTagIdx, currentTags, isFocused, selectedTagsIdx, value } = this.state;
const specialKey = ctrlKey || metaKey;
// Если нажата метаклавиша*
if (specialKey) {
switch (keyCode) {
case 65: { // meta-a
// Помещаем все теги в помеченые
const allTagsIdx = currentTags.map((item, idx) => idx);
this.setState({
selectedTagsIdx: allTagsIdx,
isFocused: false,
}, () => this.field.blur());
e.preventDefault();
break;
}
case 86: { // meta-v
this.clearnSelected();
// Событие всплывет на this.field
break;
}
case 88: // meta-x
case 67: { // meta-c
if (!selectedTagsIdx.length) return;
// Передаем управление копиру
this.сopier.select();
break;
}
case 90: { // meta-z
e.preventDefault();
e.stopPropagation();
if (value) {
this.setState({
value: value.substring(0, value.length - 1),
});
break;
}
const timetravel = this.state.timetravel.slice();
if (!timetravel.length) return;
// Возвращаем предыдущее состояние тегов
const prevState = timetravel.shift();
this.setState({
...prevState,
timetravel,
});
break;
}
default:
e.preventDefault();
return;
}
return;
}
// Без мета клавиши
switch (keyCode) {
case 37: { // left arrow
// Двигаем выделение по тегам влево, или выделяем несколько если зажата shift
// В случае если мы были в фокусе на поле ввода, сбрасываем фокус и начинаем выделять теги
if (isFocused) {
if (!currentTags.length) return;
const caretPosition = this.field.selectionStart;
if (caretPosition > 0) return;
const idx = currentTags.length - 1;
let newSelectedTagsIdx = [idx];
// Если зажата клавиша shift выделяем последовательно теги
if (shiftKey) {
newSelectedTagsIdx = selectedTagsIdx.slice();
newSelectedTagsIdx.push(idx);
}
this.setState({
currentSelectedTagIdx: idx,
isFocused: false,
selectedTagsIdx: newSelectedTagsIdx,
}, () => this.field.blur());
} else {
if (currentSelectedTagIdx === null) return;
if (currentSelectedTagIdx === 0) return;
const idx = currentSelectedTagIdx - 1;
let newSelectedTagsIdx = [idx];
// Если зажата клавиша shift выделяем последовательно теги
if (shiftKey) {
newSelectedTagsIdx = selectedTagsIdx.slice();
newSelectedTagsIdx.push(idx);
}
this.setState({ currentSelectedTagIdx: idx, selectedTagsIdx: newSelectedTagsIdx });
}
break;
}
case 39: { // right arrow
// Двигаем выделение по тегам вправо, или выделяем несколько если зажата shift
// Если дошли до края, переводим фокус на поле для ввода тегов
if (currentSelectedTagIdx === null) return;
if (currentSelectedTagIdx === currentTags.length - 1) {
e.preventDefault();
this.clearnSelected();
} else {
const idx = currentSelectedTagIdx + 1;
let newSelectedTagsIdx = [idx];
// Если зажата клавиша shift выделяем последовательно теги
if (shiftKey) {
newSelectedTagsIdx = selectedTagsIdx.slice();
newSelectedTagsIdx.push(idx);
}
this.setState({ currentSelectedTagIdx: idx, selectedTagsIdx: newSelectedTagsIdx });
}
break;
}
case 8: // del
case 46: { // backspace
// Удялаем выбранные теги, если мы находимся вне поля ввода или в поле ввода удалять нечего
if (isFocused) {
if (!currentTags.length) return;
const field = this.field;
const caretPosition = field.selectionStart;
if (caretPosition > 0) return;
const selectionText = field.value.substring(field.selectionStart, field.selectionEnd);
if (selectionText.length || field.value.length) return; // Если стёрли выделенное
e.preventDefault();
const newSelectedTagIdx = currentTags.length - 1;
this.setState({
currentSelectedTagIdx: newSelectedTagIdx,
selectedTagsIdx: [newSelectedTagIdx],
isFocused: false,
}, () => this.field.blur());
} else {
if (!selectedTagsIdx.length) return;
if (currentTags.length === 1 || currentTags.length === selectedTagsIdx.length) {
e.preventDefault();
const removedTags = currentTags.filter((item, idx) =>
selectedTagsIdx.indexOf(idx) !== -1);
this.setState({ currentTags: [] }, this.fireOnDelete(removedTags, selectedTagsIdx));
this.clearnSelected();
} else {
const currentTagsDiff = currentTags.filter((item, idx) =>
selectedTagsIdx.indexOf(idx) === -1);
const removedTags = currentTags.filter((item, idx) =>
selectedTagsIdx.indexOf(idx) !== -1);
const newSelectedTagIdx = currentSelectedTagIdx > 0
? currentSelectedTagIdx - 1
: currentSelectedTagIdx;
e.preventDefault();
this.setState({
currentTags: currentTagsDiff,
selectedTagsIdx: [newSelectedTagIdx],
currentSelectedTagIdx: newSelectedTagIdx,
}, this.fireOnDelete(removedTags, selectedTagsIdx));
}
}
break;
}
case 27: // esc
// Сбрасываем выделение тегов
if (currentSelectedTagIdx === null) return;
this.clearnSelected();
break;
default:
}
};
/**
* Нажатие и удерживание клавиши на области ввода нового тега
* @param {KeyboardEvent} e
* @return {void}
*/
handleTextareaKeyDown = (e) => {
const { keyCode } = e;
const { value } = this.state;
switch (keyCode) {
case 9: // tab
this.saveTagsAndCloseField();
break;
case 13: // enter
e.preventDefault();
// Если есть значение, сохраняем его как новый тег,
// иначе сохрянем теги и прекращаем редактирование
value ? this.addTag(null, value, '') : this.saveTagsAndCloseField();
break;
case 27: // esc
// Сохрянем теги и прекращаем редактирование
this.saveTagsAndCloseField();
break;
default:
}
};
/**
* Нажатие клавиши на области ввода нового тега
* @param {KeuboardEvent} e
* @return {void}
*/
handleTextareaKeyPress = (e) => {
const { keyCode } = e.nativeEvent;
const { delimiter } = this.props;
const { value } = this.state;
const delimiterCode = delimiter.charCodeAt();
switch (keyCode) {
// Если нажата клавиша, определенная как делимитер, сохраняем тег
case delimiterCode:
e.preventDefault();
if (!value) return;
const caretPosition = this.field.selectionStart;
if (caretPosition === 0) {
this.addTag(null, value, '');
} else {
this.addTag(null, value.substring(0, caretPosition), value.substring(caretPosition));
}
break;
default:
}
};
/**
* Клик по тегу
* @param {Number} idx
* @return {Function}
*/
handleTagClick = (idx) => (e) => {
const { isActive } = this.state;
if (!isActive) return;
const { ctrlKey, metaKey } = e;
const specialKey = ctrlKey || metaKey;
let selectedTagsIdx = [idx];
// Если зажата метаклавиша*, то добавляем тег в список выделеных
if (specialKey) {
selectedTagsIdx = this.state.selectedTagsIdx.slice();
selectedTagsIdx.push(idx);
}
this.setState({
currentSelectedTagIdx: idx,
isFocused: false,
selectedTagsIdx,
}, () => this.field.blur());
};
/**
* Событие клика по простыне над полем редактирования
* @param {MouseEvent} e
* @return {void}
*/
handleHolderClick = (e) => {
const { type } = this.props;
if (type === 'field') {
this.setState(
{
isActive: true,
isFocused: true,
},
() => this.field.focus());
}
if (this.state.isActive || e.target !== e.currentTarget) return;
this.clearnSelected();
};
/**
* Разбор всталяемой строки из буффера на теги по делимитеру
* @param {KeyboardEvent} e
* @return {void}
*/
handlePaste = (e) => {
const { delimiter } = this.props;
e.stopPropagation();
e.preventDefault();
// Получаем данные из буфера
const clipboardData = e.clipboardData || window.clipboardData;
const bufferData = clipboardData.getData('Text');
// Получаем массив из строки по делимитеру
let tagsFromBuffer = bufferData.split(delimiter);
tagsFromBuffer = tagsFromBuffer.map(item => item.trim()); // тримим
tagsFromBuffer = tagsFromBuffer.filter(item => item.length); // удаляем пустые
tagsFromBuffer = tagsFromBuffer.map(item => ({ id: null, title: item })); // формируем массив
this.setState((prevState) => ({
currentTags: prevState.currentTags.concat(tagsFromBuffer),
}), () => {
tagsFromBuffer.forEach((tag) => {
this.props.onAdd({ value: tag.title, id: null });
});
});
};
/**
* Вырезает из поля выделеные теги
* @param {ClipboardEvent} e
* @return {void}
*/
handleCut = (e) => {
const { currentTags, currentSelectedTagIdx, selectedTagsIdx } = this.state;
const currentTagsDiff = currentTags.filter((item, idx) =>
selectedTagsIdx.indexOf(idx) === -1);
const removedTags = currentTags.filter((item, idx) =>
selectedTagsIdx.indexOf(idx) !== -1);
const newSelectedTagIdx = currentSelectedTagIdx > 0
? currentSelectedTagIdx - 1
: currentSelectedTagIdx;
// Получаем данные из буфера
const clipboardData = e.clipboardData || window.clipboardData;
clipboardData.setData('text/plain', e.target.value);
this.setState({
currentTags: currentTagsDiff,
selectedTagsIdx: [newSelectedTagIdx],
currentSelectedTagIdx: newSelectedTagIdx,
}, this.fireOnDelete(removedTags, selectedTagsIdx));
e.stopPropagation();
e.preventDefault();
}
/**
* Всплывает при вводе нового тега в textarea
*/
handleEnter = ({ target }) => {
const { value } = target;
this.setState({ value }, () => {
this.props.onEnter(value);
});
};
/**
* Всплывает при выборе тега из коллекции возможных
* @param {string} value
* @param {string} label
* @return {void}
*/
handleSuggestionChoose = ({ value, label }) => {
this.addTag(value, label, '');
this.props.onSuggestionChoose({ id: value });
};
/**
* Событие клика по полю ввода
* @return {void}
*/
handleTextareaClick = () => {
this.setState({
currentSelectedTagIdx: null,
isActive: true,
isFocused: true,
});
}
/**
* Обработчик фокуса на поле ввода
* @return {void}
*/
handleTextareaFocus = () => {
this.setState({
isActive: true,
isFocused: true,
}, () => this.field.focus());
}
/*
* Рендер плейсхолдера
*/
renderPlaceholder = () => {
const { currentTags, isActive } = this.state;
const placeholderClass = classNames(
`${styles}__placeholder`,
{
[`${styles}__placeholder--displaynone`]: currentTags.length > 0 || isActive,
},
);
return (
<span
className={placeholderClass}
onClick={this.handleTextareaFocus}
>
{this.props.placeholder}
</span>
);
};
/**
* Рендер коллекции тегов
* @return {React$Element}
*/
renderTags() {
const { currentTags, currentSelectedTagIdx, selectedTagsIdx } = this.state;
return currentTags.map(({ title, tooltip }, idx) => {
const isSelected = idx === currentSelectedTagIdx || selectedTagsIdx.includes(idx);
const tagClass = classNames(
`${styles}__tag`,
{
[`${styles}__tag--selected`]: isSelected,
},
);
const tag = (
<span
className={tagClass}
key={idx} // eslint-disable-line react/no-array-index-key
onClick={this.handleTagClick(idx)}
onKeyPress={this.handleKeyPress}
>
{title}
</span>
);
if (tooltip) {
return (
<Tooltip
key={idx} // eslint-disable-line react/no-array-index-key
{...tooltip}
>
{tag}
</Tooltip>
);
}
return tag;
});
}
/**
* Рендер дропдауна
* @return {React$Element|null}
*/
renderDropdown() {
const { currentTags, value } = this.state;
if (!value || value.length < 0) return null;
let suggestionTags = this.props.suggestionTags;
// Удалим введенные теги из возможных если установлен props
if (this.props.hideUsedTags) {
suggestionTags = suggestionTags.filter(
tag => {
const index = currentTags.findIndex(
currentTag => currentTag.title === tag.title,
);
return index === -1;
},
);
}
return (
<div className={`${styles}__dropdown`}>
<DropdownFiltered
options={[{
items: suggestionTags.map(({ id, title }) => ({ value: id, label: title })),
}]}
filterValue={value}
onItemClick={this.handleSuggestionChoose}
/>
</div>
);
}
/**
* Рендер поля для ввода тега
* @return {React$Element}
*/
renderField = () => {
const { isActive } = this.state;
const fieldMatClass = classNames(
`${styles}__field-mat`,
{
[`${styles}__field-mat--displaynone`]: !isActive,
},
);
return (
<div className={fieldMatClass}>
<plaintext className={`${styles}__field-mat__ghost`}>
{this.state.value || '|'}
</plaintext>
<input
ref={el => (this.field = el)}
onClick={this.handleTextareaClick}
onKeyDown={this.handleTextareaKeyDown}
onKeyPress={this.handleTextareaKeyPress}
onChange={this.handleEnter}
onPaste={this.handlePaste}
onCopy={this.handleCopy}
onFocus={this.handleTextareaFocus}
className={`${styles}__field`}
value={this.state.value}
/>
</div>
);
};
/**
* Рендер поля для копирования текста в буфер
* Это поле скрыто от пользователя, в нем записана переменная, состоящая из тегов
* соединенных делимитером. Когда происзодит событие связанное с записью в буфер (copy/cut)
* это поле фокус и перехватывая событие исполняет его,
* тем самым копируя в буфер нужную строку.
* Браузер, или даже js, запрещает напрямую в буфер пользователю записать всякое,
* в целях безопа-сности. Можно начать копать отсюда:
* https://github.com/zenorocha/clipboard.js/issues/258
* @return {React$Element}
*/
renderСopier() {
const { currentTags, selectedTagsIdx } = this.state;
const { delimiter } = this.props;
let tagsArray = currentTags.filter((item, idx) => selectedTagsIdx.indexOf(idx) !== -1);
tagsArray = tagsArray.map(item => item.title);
return (
<input
className={`${styles}__copier`}
ref={el => (this.сopier = el)}
value={tagsArray.join(delimiter)}
onCut={this.handleCut}
/>
);
}
render() {
const { mod, suggestionTags, type } = this.props;
const { currentTags, isActive } = this.state;
const isField = type === 'field';
const fieldIsVisible = isActive || (mod === 'label' && !currentTags.length);
const rootClass = classNames(
styles,
`${styles}--is-${mod}`,
{
[`${styles}--active`]: isActive,
[`${styles}--is-visible`]: fieldIsVisible || isField,
},
);
const holderClass = classNames(
`${styles}__holder`,
{
[`${styles}__holder--active`]: fieldIsVisible,
[`${styles}__holder--is-inline`]: isField,
},
);
const contentClass = classNames(
`${styles}__content`,
{
[`${styles}__content--floating-editable-icon`]: this.props.withFloatingEditableIcon,
[`${styles}__content--with-icon`]: !isField,
},
);
return (
<div
className={rootClass}
ref={el => (this.root = el)}
>
{this.renderСopier()}
<div className={contentClass}>
<div className={holderClass} onClick={this.handleHolderClick}>
<span className={`${styles}__placeholder-and-pencil`}>
{this.renderPlaceholder()}
{!isField &&
<span className={`${styles}__pencil`}>
<A onClick={this.handlePencilClick}>
<InlineSVG name="edit" colorInherit />
</A>
</span>
}
</span>
{this.renderTags()}
{this.renderField()}
</div>
</div>
{suggestionTags.length !== 0 && this.renderDropdown()}
</div>
);
}
}
/**
* NOTE: *Метаклавиши - CTRL на win и unix, CMD на macOs (http://learn.javascript.ru/mouse-clicks)
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment