ar-design
Version:
AR Design is a (react | nextjs) ui library.
310 lines (309 loc) • 14 kB
JavaScript
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import "../../../assets/css/components/data-display/kanban-board/styles.css";
import DnD from "../dnd";
import { ARIcon } from "../../icons";
import Filter from "./filter";
const KanbanBoard = function ({ trackBy, columns, onChange, onLazyLoad, config, }) {
// refs
const _kanbanWrapper = useRef(null);
const _kanbanItems = useRef({});
const _hoverItemIndex = useRef(null);
const _isProgrammaticScroll = useRef(false);
const _scrollInterval = useRef(null);
const _scrollAnimationFrame = useRef(null);
const _scrollSpeedRef = useRef(0);
const _lastScrollTop = useRef(0);
const _lastRequest = useRef("");
// states
const [data, setData] = useState([]);
// states -> Lazy Load
const [query, setQuery] = useState(null);
const [perPage] = useState(config?.perPage ?? 10);
const [currentPage, setCurrentPage] = useState(1);
// states -> Filters
const [search, setSearch] = useState(null);
const [selectFilters, setSelectFilters] = useState({});
const [selectedFilters, setSelectedFilters] = useState({});
const [dateFilters, setDateFilters] = useState({});
// methods
const handleBoardDragOver = (event) => {
event.preventDefault();
const kanbanWrapper = _kanbanWrapper.current;
if (!kanbanWrapper)
return;
const rect = kanbanWrapper.getBoundingClientRect();
const mouseX = event.clientX;
const edgeThreshold = 100; // px olarak kenar hassasiyeti.
if (mouseX - rect.left < edgeThreshold) {
// sol kenara yaklaştı.
_scrollSpeedRef.current = -Math.max(1, (edgeThreshold - (mouseX - rect.left)) / 2);
}
else if (rect.right - mouseX < edgeThreshold) {
// sağ kenara yaklaştı.
_scrollSpeedRef.current = Math.max(1, (edgeThreshold - (rect.right - mouseX)) / 2);
}
else {
_scrollSpeedRef.current = 0;
}
if (!_scrollAnimationFrame.current) {
const scrollLoop = () => {
if (kanbanWrapper && _scrollSpeedRef.current !== 0) {
kanbanWrapper.scrollLeft += _scrollSpeedRef.current;
}
_scrollAnimationFrame.current = requestAnimationFrame(scrollLoop);
};
scrollLoop();
}
};
const handleDrop = (toColumn) => (event) => {
event.preventDefault();
const item = JSON.parse(event.dataTransfer.getData("item"));
const fromColumn = event.dataTransfer.getData("fromColumn");
const nodes = document.querySelectorAll("[data-id='placeholder']");
nodes.forEach((node) => node.remove());
if (!item || fromColumn === toColumn)
return;
const updatedColumns = data.map((board) => {
if (board.key === fromColumn) {
return {
...board,
items: board.items.filter((_item) => trackBy(_item) !== trackBy(item)),
};
}
if (board.key === toColumn) {
const boardItems = [...board.items];
const safeIndex = Math.min(_hoverItemIndex.current ?? Infinity, boardItems.length); // son elemandan fazla olmasın.
boardItems.splice(safeIndex, 0, item);
onChange?.(item, board.key, board.columnProperties, safeIndex);
return {
...board,
items: boardItems,
};
}
return board;
});
setData(updatedColumns);
// Temizlik
event.dataTransfer.clearData("item");
event.dataTransfer.clearData("fromColumn");
_hoverItemIndex.current = null;
};
const handleItemsDragOver = (event) => {
event.preventDefault();
const item = event.currentTarget;
if (!item.classList.contains("dragging"))
item.classList.add("dragging");
};
const handleItemsDragLeave = (event) => {
event.preventDefault();
const item = event.currentTarget;
const rect = item.getBoundingClientRect();
const { clientX, clientY } = event;
const isInside = clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
if (!isInside && item.classList.contains("dragging")) {
item.classList.remove("dragging");
}
};
const handleItemsDrop = (event) => {
event.preventDefault();
const item = event.currentTarget;
if (item.classList.contains("dragging"))
item.classList.remove("dragging");
};
const handleLazyLoadScroll = useCallback((event) => {
const target = event.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = target;
if (_isProgrammaticScroll.current) {
if (scrollTop <= 5)
_isProgrammaticScroll.current = false;
return;
}
const isScrollingDown = scrollTop > _lastScrollTop.current;
_lastScrollTop.current = scrollTop;
if (!isScrollingDown)
return;
const isBottom = scrollHeight - scrollTop <= clientHeight;
if (isBottom)
setCurrentPage((prev) => prev + 1);
}, []);
const handleStartScroll = (direction) => {
const el = _kanbanWrapper.current;
if (!el)
return;
handleStopScroll();
_scrollInterval.current = window.setInterval(() => {
el.scrollLeft += direction === "left" ? -10 : 10;
}, 16); // ~60fps
};
const handleStopScroll = () => {
if (_scrollInterval.current) {
clearInterval(_scrollInterval.current);
_scrollInterval.current = null;
}
};
const stopScrolling = () => {
if (_scrollAnimationFrame.current) {
cancelAnimationFrame(_scrollAnimationFrame.current);
_scrollAnimationFrame.current = null;
}
_scrollSpeedRef.current = 0;
};
const darkenColor = (hex, percent) => {
let num = parseInt(hex.slice(1), 16), amt = Math.round(2.55 * percent), R = (num >> 16) - amt, G = ((num >> 8) & 0x00ff) - amt, B = (num & 0x0000ff) - amt;
return ("#" +
(0x1000000 +
(R < 255 ? (R < 0 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 0 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 0 ? 0 : B) : 255))
.toString(16)
.slice(1));
};
// useEffects
useEffect(() => {
let _data = columns.map((col) => ({
...col,
items: [...col.items],
}));
setData(_data);
const selectMap = new Map();
const dateMap = new Map();
columns.forEach((col) => {
col.items.forEach((item) => {
const keys = config?.filter?.keys(item);
keys?.forEach((k) => {
if (k.type === "select") {
if (!selectMap.has(k.name))
selectMap.set(k.name, new Set());
selectMap.get(k.name).add(k.value ?? null);
}
if (k.type === "date")
dateMap.set(k.name, true);
});
});
});
setSelectFilters(Object.fromEntries(Array.from(selectMap.entries()).map(([name, set]) => [name, Array.from(set)])));
setDateFilters((prev) => {
const next = { ...prev };
Array.from(dateMap.keys()).forEach((name) => {
if (!next[name]) {
next[name] = { from: null, to: null };
}
});
return next;
});
}, [columns]);
const _prevFilters = useRef(JSON.stringify({ search, selectedFilters, dateFilters }));
useEffect(() => {
const normalizedFilters = JSON.stringify({
search,
selectedFilters: Object.fromEntries(Object.entries(selectedFilters).map(([k, v]) => [k, Array.from(v).sort()])),
dateFilters,
});
const hasSelectedFilters = Object.values(selectedFilters).some((set) => set.size > 0);
const hasDateFilters = Object.values(dateFilters).some((r) => r.from || r.to);
// Page reset + Scroll logic.
if (_prevFilters.current !== normalizedFilters) {
setCurrentPage(1);
_prevFilters.current = normalizedFilters;
if (_kanbanWrapper.current) {
_isProgrammaticScroll.current = true;
_kanbanWrapper.current.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}
}
// Query Build Logic.
if (!search && !hasSelectedFilters && !hasDateFilters) {
setQuery(null);
return;
}
const sampleItem = columns?.[0]?.items?.[0];
const keys = config?.filter?.keys(sampleItem) ?? [];
const keyMap = Object.fromEntries(keys.map((k) => [k.name, k.key]));
const dateQuery = Object.entries(dateFilters).reduce((acc, [name, range]) => {
if (range.from || range.to) {
const technicalKey = keyMap[name] || name;
acc[technicalKey] = {
from: range.from,
to: range.to,
};
}
return acc;
}, {});
const selectQuery = Object.entries(selectedFilters).reduce((acc, [name, set]) => {
if (set.size > 0) {
const technicalKey = keyMap[name] || name;
acc[technicalKey] = Array.from(set);
}
return acc;
}, {});
setQuery({
keyword: search ?? "",
...dateQuery,
...selectQuery,
});
}, [search, selectedFilters, dateFilters, columns, config]);
useEffect(() => {
if (!onLazyLoad)
return;
const key = JSON.stringify({ query, currentPage, perPage });
if (_lastRequest.current === key)
return;
_lastRequest.current = key;
onLazyLoad(query, perPage, currentPage);
}, [query, currentPage, perPage, onLazyLoad]);
return (React.createElement(React.Fragment, null,
config?.filter && (React.createElement(Filter, { states: {
search: {
get: search,
set: setSearch,
},
dateFilters: {
get: dateFilters,
set: setDateFilters,
},
selectFilters: {
get: selectFilters,
set: setSelectFilters,
},
selectedFilters: {
get: selectedFilters,
set: setSelectedFilters,
},
}, config: config })),
React.createElement("div", { ref: _kanbanWrapper, className: "ar-kanban-board", style: {
height: `calc(100dvh - (${_kanbanWrapper.current?.getBoundingClientRect().top}px + ${config?.safeAreaOffset?.bottom ?? 0}px))`,
}, onScroll: handleLazyLoadScroll, onDragOver: handleBoardDragOver, onDragEnd: stopScrolling, onDrop: stopScrolling },
React.createElement("div", { className: "buttons" },
React.createElement("div", { className: "button left", onMouseDown: () => handleStartScroll("left"), onMouseUp: handleStopScroll, onMouseLeave: handleStopScroll },
React.createElement(ARIcon, { icon: "ArrowLeft" })),
React.createElement("div", { className: "button right", onMouseDown: () => handleStartScroll("right"), onMouseUp: handleStopScroll, onMouseLeave: handleStopScroll },
React.createElement(ARIcon, { icon: "ArrowRight" }))),
React.createElement("div", { className: "titles" }, data.map((board, index) => (React.createElement("div", { key: index, className: "title" },
React.createElement("h4", null,
React.createElement("span", { style: {
backgroundColor: darkenColor(board.titleColor ?? "", 1),
borderColor: darkenColor(board.titleColor ?? "", 1),
} }),
board.title.toLocaleUpperCase("tr")),
board.description && React.createElement("span", null, board.description))))),
React.createElement("div", { className: "columns" }, data.map((board, index) => (React.createElement("div", { key: index, className: "column", onDrop: handleDrop(board.key) },
React.createElement("div", { className: "items", onDragOver: handleItemsDragOver, onDragLeave: handleItemsDragLeave, onDrop: handleItemsDrop },
React.createElement(DnD, { key: board.key, data: board.items, renderItem: (item, dndIndex) => {
return (React.createElement("div", { key: dndIndex, ref: (el) => {
if (!el)
return;
_kanbanItems.current[trackBy(item)] = el;
}, className: "item", onDragOver: (event) => {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
const mouseY = event.clientY;
const isBelow = mouseY > rect.top + rect.height / 2;
_hoverItemIndex.current = isBelow ? dndIndex + 1 : dndIndex;
} }, board.renderItem(item, index)));
}, columnKey: board.key, confing: { isMoveIcon: false } })))))))));
};
export default KanbanBoard;