- Демонстрация (TLDR)
- HTML Drag and Drop API
- Управление состояниями
- Действие: СОЗДАТЬ
- Действие: ОБНОВИТЬ_КАТЕГОРИЮ
- Действие: UPDATE_DRAG_OVER
- Действие: УДАЛИТЬ
- Состояние формы добавления элемента
- Пользовательский интерфейс (UI)
- Форма добавления элемента
- Колонки доски Канбан
- Decoders
- Элементы в доске Канбан
- Перетаскиваемый
- OnDragStart
- onDragOver и onDragLeave
- onDrop
- Элемент доски Канбан
- Заключение
Демонстрация (TLDR)
Это исходный код и доска Канбан, которую мы будем создавать.
HTML Drag and Drop API
HTML Drag and Drop API необходим для реализации функции перетаскивания на любой элемент DOM.
Управление состояниями
Для интерактивного веб-приложения важно выбрать правильный шаблон проектирования для управления состояниями.
Я использую useReducer, поскольку состояние является сложным.
Вот начальное состояние. isDragOver
необходим для обновления стиля элемента, который перетаскивается. Для простоты Date.now()
используется в качестве уникального id
элемента.
type Category = "todo" | "doing" | "done";
type Item = { id: number; content: string; isDragOver: boolean };
type State = { [key in Category]: Item[] };
const initialState: State = {
todo: [{ id: Date.now(), content: "Task 4", isDragOver: false }],
doing: [{ id: Date.now() + 1, content: "Task 3", isDragOver: false }],
done: [
{ id: Date.now() + 2, content: "Task 2", isDragOver: false },
{ id: Date.now() + 3, content: "Task 1", isDragOver: false },
],
};
Вот действия, которые выполняет редуктор.
type Action =
| { type: "CREATE"; content: string }
| {
type: "UPDATE_CATEGORY";
newCategory: Category;
oldCategory: Category;
position: number;
id: number;
}
| {
type: "UPDATE_DRAG_OVER";
id: number;
category: Category;
isDragOver: boolean;
}
| { type: "DELETE"; id: number; category: Category };
Действие: СОЗДАТЬ
Действие create создает элемент в колонке todo
доски Kanban.
case "CREATE": {
if (action.content.trim().length === 0) return state;
return {
...state,
todo: [
{ id: Date.now(), content: action.content, isDragOver: false },
...state.todo
]
};
}
Действие: ОБНОВИТЬ_КАТЕГОРИЮ
Действие UPDATE_CATEGORY
обновляет позицию и категорию элемента.
Сначала мы найдем старую позицию и элемент, используя id
, указанный в объекте action
. Чтобы избежать использования мутации, для возврата обоих значений в этой функции используется Immediately Invoked Function Expression (IIFE).
const { oldPosition, found } = (() => {
const oldPosition = state[oldCategory].findIndex(
(item) => item.id === action.id
);
return { oldPosition, found: state[oldCategory][oldPosition] };
})();
Возвращается исходное состояние, если элемент не найден или если категория и позиция не изменились.
if (oldPosition === -1) return state;
if (newCategory === oldCategory && position === oldPosition) return state;
Элемент удаляется из старого списка категорий. Новый список категорий определяется тем, была ли изменена исходная категория.
const filtered = state[oldCategory].filter((item) => item.id !== action.id);
const newCategoryList = newCategory === oldCategory ? filtered : [...state[newCategory]];
Списки обновляются в соответствии с положением нового элемента.
if (position === 0) {
return {
...state,
[oldCategory]: filtered,
[newCategory]: [found, ...newCategoryList],
};
}
return {
...state,
[oldCategory]: filtered,
[newCategory]: [
...newCategoryList.slice(0, position),
found,
...newCategoryList.slice(position),
],
};
Полный код.
case "UPDATE_CATEGORY": {
const { position, newCategory, oldCategory } = action;
const { oldPosition, found } = (() => {
const oldPosition = state[oldCategory].findIndex(
(item) => item.id === action.id
);
return { oldPosition, found: state[oldCategory][oldPosition] };
})();
if (oldPosition === -1) return state;
if (newCategory === oldCategory && position === oldPosition) return state;
const filtered = state[oldCategory].filter(
(item) => item.id !== action.id
);
const newCategoryList =
newCategory === oldCategory ? filtered : [...state[newCategory]];
if (position === 0) {
return {
...state,
[oldCategory]: filtered,
[newCategory]: [found, ...newCategoryList]
};
}
return {
...state,
[oldCategory]: filtered,
[newCategory]: [
...newCategoryList.slice(0, position),
found,
...newCategoryList.slice(position)
]
};
}
Действие: UPDATE_DRAG_OVER
Это действие обновит элемент, который перетаскивается на него или из него.
case "UPDATE_DRAG_OVER": {
const updated = state[action.category].map((item) => {
if (item.id === action.id) {
return { ...item, isDragOver: action.isDragOver };
}
return item;
});
return {
...state,
[action.category]: updated
};
}
Действие: УДАЛИТЬ
Наконец, это действие удалит элемент на доске Канбан.
case "DELETE": {
const filtered = state[action.category].filter(
(item) => item.id !== action.id
);
return {
...state,
[action.category]: filtered
};
}
Состояние формы добавления элемента
Есть еще два состояния, которые используются для управления колонкой добавления элемента в список todo на доске Kanban.
Состояние add
определяет, скрывать или показывать форму добавления элемента, а состояние addInput
будет хранить заголовок нового элемента.
const [state, dispatch] = useReducer(reducer, initialState); // our reducer
const [add, setAdd] = useState(false);
const [addInput, setAddInput] = useState("");
Пользовательский интерфейс (UI)
Теперь мы рассмотрели все, что касается управления состояниями доски Kanban. Я рассмотрю некоторые из основных компонентов пользовательского интерфейса доски Kanban.
Форма добавления элемента
TSX формы добавления элемента.
{
add && (
<div className="addItem">
<input
type="text"
onKeyUp={(e) => {
if (e.code === "Enter") {
e.preventDefault();
e.stopPropagation();
dispatch({ type: "CREATE", content: addInput });
setAddInput("");
setAdd(false);
}
}}
onChange={onAddInputChange}
value={addInput}
/>
<div>
<button
onClick={() => {
dispatch({ type: "CREATE", content: addInput });
setAddInput("");
setAdd(false);
}}
>
Add
</button>
<button onClick={() => setAdd(false)}>Cancel</button>
</div>
</div>
);
}
Функция слушателя события изменения ввода.
const onAddInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.value;
setAddInput(value);
};
Колонки доски Канбан
TSX столбцов в доске Канбан.
<div
className="items"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onItemsDrop(e, "doing")} // "todo" | "doing" | "done"
>
{Items(state.doing, "doing")} {/* "todo" | "doing" | "done" */}
</div>
Функция слушателя onDrop
для столбцов предназначена для определения того, был ли перетаскиваемый элемент опущен на столбец. Функция e.dataTransfer
может получать, хранить или очищать данные из перетаскиваемого элемента. Данные должны быть разобраны в формате JSON, так как dataTransfer
принимает только string
.
const onItemsDrop = (
e: React.DragEvent<HTMLDivElement>,
newCategory: Category
) => {
const item = e.dataTransfer.getData("text/plain");
const parsedItem = JSON.parse(item);
const decodedItem = ItemDecoder.verify(parsedItem);
dispatch({
type: "UPDATE_CATEGORY",
id: decodedItem.id,
newCategory,
oldCategory: decodedItem.category,
position: state[newCategory].length,
});
};
Decoders
Decoders — это моя лучшая библиотека проверки данных для JavaScript и NodeJS. Она легкая, имеет хорошую поддержку TypeScript и расширяема. Разобранный элемент проверяется этой библиотекой.
const decodedItem = ItemDecoder.verify(parsedItem);
Действие отправляется на редуктор для обновления столбцов на доске Kanban.
Элементы в доске Канбан
Функция TSX для отображения элементов на доске Kanban.
const Items = (items: Item[], category: Category) => {
return items.map(({ id, content, isDragOver }) => (
<div
key={id}
draggable={true}
onDragStart={(e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData(
"text/plain",
JSON.stringify({ id, content, category, isDragOver })
);
}}
onDragOver={(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
dispatch({
type: "UPDATE_DRAG_OVER",
category,
id,
isDragOver: true,
});
}}
onDragLeave={(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
dispatch({
type: "UPDATE_DRAG_OVER",
category,
id,
isDragOver: false,
});
}}
onDrop={(e: React.DragEvent<HTMLDivElement>) => {
e.stopPropagation();
const item = e.dataTransfer.getData("text/plain");
const parsedItem = JSON.parse(item);
const decodedItem = ItemDecoder.verify(parsedItem);
const position = state[category].findIndex((i) => i.id === id);
dispatch({
type: "UPDATE_CATEGORY",
id: decodedItem.id,
newCategory: category,
oldCategory: decodedItem.category,
position,
});
dispatch({
type: "UPDATE_DRAG_OVER",
category,
id,
isDragOver: false,
});
}}
>
<div className={"itemContent" + (isDragOver ? " dashed" : "")}>
<h2>{content}</h2>
<button onClick={() => dispatch({ type: "DELETE", category, id })}>
<DeleteIcon height={13} width={13} />
</button>
</div>
</div>
));
};
Перетаскиваемый
Чтобы сделать div
перетаскиваемым. draggable={true}
добавляется в свойства div
DOM.
OnDragStart
Слушатель OnDragStart
срабатывает при перетаскивании элемента. Необходимые данные сохраняются как string
в dataTransfer
Drag and Drop API.
onDragStart={(e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData(
"text/plain",
JSON.stringify({ id, content, category, isDragOver })
);
}}
onDragOver и onDragLeave
Эти два слушателя срабатывают, когда элемент перетаскивается на другой элемент на доске Kanban или покидает ее.
onDragOver={(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
dispatch({
type: "UPDATE_DRAG_OVER",
category,
id,
isDragOver: true
});
}}
onDragLeave={(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
dispatch({
type: "UPDATE_DRAG_OVER",
category,
id,
isDragOver: false
});
}}
onDrop
Наконец, у нас есть слушатель onDrop
. Он аналогичен слушателю onItemsDrop
для столбцов доски Kanban. e.stopPropagation()
предотвращает распространение этого слушателя на родительские элементы и повторное срабатывание того же слушателя. Ознакомьтесь с этой статьей, чтобы узнать, как это работает.
onDrop={(e: React.DragEvent<HTMLDivElement>) => {
e.stopPropagation();
const item = e.dataTransfer.getData("text/plain");
const parsedItem = JSON.parse(item);
const decodedItem = ItemDecoder.verify(parsedItem);
const position = state[category].findIndex((i) => i.id === id);
dispatch({
type: "UPDATE_CATEGORY",
id: decodedItem.id,
newCategory: category,
oldCategory: decodedItem.category,
position
});
dispatch({
type: "UPDATE_DRAG_OVER",
category,
id,
isDragOver: false
});
}}
Элемент доски Канбан
Переменная isDragOver
каждого элемента используется для обновления стиля элемента при перетаскивании его другим элементом. Элемент также может быть удален с доски Канбан.
<div className={"itemContent" + (isDragOver ? " dashed" : "")}>
<h2>{content}</h2>
<button onClick={() => dispatch({ type: "DELETE", category, id })}>
<DeleteIcon height={13} width={13} />
</button>
</div>;
Заключение
Мы подошли к концу этой статьи. Есть еще функции, которые можно улучшить или добавить к нашей доске Kanban. Вот их неполный список.
- Обновление заголовка элемента
- Содержание тела элемента Канбан
- Сохранение данных элемента Kanban в базу данных/хранилище.
- Назначение персоны для элемента Канбан.
Цель этой статьи — дать толчок к тому, как создать Kanban Board без каких-либо внешних библиотек, и я надеюсь, что мне это удалось. Спасибо за прочтение!