239 lines
8.0 KiB
TypeScript
239 lines
8.0 KiB
TypeScript
import { Fragment, useCallback, useEffect, useState } from "react";
|
|
import { useHotkeys } from "react-hotkeys-hook";
|
|
import naturalCompare from "string-natural-compare";
|
|
import { Chip } from "./Chip";
|
|
import { PageSelector } from "./Inputs/PageSelector";
|
|
import { Ico, Icon } from "./Ico";
|
|
import { cJoin } from "helpers/className";
|
|
import { isDefined, isDefinedAndNotEmpty } from "helpers/others";
|
|
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
|
|
import { Ids } from "types/ids";
|
|
import { atoms } from "contexts/atoms";
|
|
import { useAtomGetter } from "helpers/atoms";
|
|
|
|
interface Group<T> {
|
|
name: string;
|
|
items: T[];
|
|
totalCount: number;
|
|
}
|
|
|
|
const defaultGroupSortingFunction = <T,>(a: Group<T>, b: Group<T>) =>
|
|
naturalCompare(a.name, b.name);
|
|
const defaultGroupCountingFunction = () => 1;
|
|
const defaultFilteringFunction = () => true;
|
|
const defaultSortingFunction = () => 0;
|
|
const defaultGroupingFunction = () => [""];
|
|
|
|
interface Props<T> {
|
|
// Items
|
|
items: T[];
|
|
getItemId: (item: T) => string;
|
|
renderItem: (props: { item: T }) => JSX.Element;
|
|
renderWhenEmpty?: () => JSX.Element;
|
|
// Pagination
|
|
paginationItemPerPage?: number;
|
|
paginationSelectorTop?: boolean;
|
|
paginationSelectorBottom?: boolean;
|
|
paginationScroolTop?: boolean;
|
|
// Searching
|
|
searchingTerm?: string;
|
|
searchingBy?: (item: T) => string;
|
|
searchingCaseInsensitive?: boolean;
|
|
// Grouping
|
|
groupingFunction?: (item: T) => string[];
|
|
groupSortingFunction?: (a: Group<T>, b: Group<T>) => number;
|
|
groupCountingFunction?: (item: T) => number;
|
|
// Filtering
|
|
filteringFunction?: (item: T) => boolean;
|
|
// Sorting
|
|
sortingFunction?: (a: T, b: T) => number;
|
|
// Other
|
|
className?: string;
|
|
}
|
|
|
|
export const SmartList = <T,>({
|
|
items,
|
|
getItemId,
|
|
renderItem: RenderItem,
|
|
renderWhenEmpty: RenderWhenEmpty,
|
|
paginationItemPerPage = Infinity,
|
|
paginationSelectorTop = true,
|
|
paginationSelectorBottom = true,
|
|
paginationScroolTop = true,
|
|
searchingTerm,
|
|
searchingBy,
|
|
searchingCaseInsensitive = true,
|
|
groupingFunction = defaultGroupingFunction,
|
|
groupSortingFunction = defaultGroupSortingFunction,
|
|
groupCountingFunction = defaultGroupCountingFunction,
|
|
filteringFunction = defaultFilteringFunction,
|
|
sortingFunction = defaultSortingFunction,
|
|
className,
|
|
}: Props<T>): JSX.Element => {
|
|
const [page, setPage] = useState(0);
|
|
const langui = useAtomGetter(atoms.localData.langui);
|
|
useScrollTopOnChange(Ids.ContentPanel, [page], paginationScroolTop);
|
|
useEffect(() => setPage(0), [searchingTerm, groupingFunction, groupSortingFunction]);
|
|
|
|
const searchFilter = useCallback(() => {
|
|
if (isDefinedAndNotEmpty(searchingTerm) && isDefined(searchingBy)) {
|
|
if (searchingCaseInsensitive) {
|
|
return items.filter((item) =>
|
|
searchingBy(item).toLowerCase().includes(searchingTerm.toLowerCase())
|
|
);
|
|
}
|
|
return items.filter((item) => searchingBy(item).includes(searchingTerm));
|
|
}
|
|
return items;
|
|
}, [items, searchingBy, searchingCaseInsensitive, searchingTerm]);
|
|
|
|
const filteredItems = searchFilter().filter(filteringFunction);
|
|
|
|
const sortedItem = filteredItems.sort(sortingFunction);
|
|
|
|
const groups = (() => {
|
|
const memo: Group<T>[] = [];
|
|
|
|
sortedItem.forEach((item) => {
|
|
groupingFunction(item).forEach((category) => {
|
|
const index = memo.findIndex((group) => group.name === category);
|
|
if (index === -1) {
|
|
memo.push({
|
|
name: category,
|
|
items: [item],
|
|
totalCount: groupCountingFunction(item),
|
|
});
|
|
} else {
|
|
memo[index].items.push(item);
|
|
memo[index].totalCount += groupCountingFunction(item);
|
|
}
|
|
});
|
|
});
|
|
return memo.sort(groupSortingFunction);
|
|
})();
|
|
|
|
const pages = (() => {
|
|
const memo: Group<T>[][] = [];
|
|
let currentPage: Group<T>[] = [];
|
|
let remainingSlots = paginationItemPerPage;
|
|
let loopSafeguard = 1000;
|
|
|
|
const newPage = () => {
|
|
memo.push(currentPage);
|
|
currentPage = [];
|
|
remainingSlots = paginationItemPerPage;
|
|
};
|
|
|
|
for (const group of groups) {
|
|
let remainingItems = group.items.length;
|
|
while (remainingItems > 0 && loopSafeguard >= 0) {
|
|
loopSafeguard--;
|
|
const currentIndex = group.items.length - remainingItems;
|
|
|
|
if (
|
|
remainingSlots <= 0 ||
|
|
(currentIndex === 0 &&
|
|
remainingItems > remainingSlots &&
|
|
remainingItems <= paginationItemPerPage)
|
|
) {
|
|
newPage();
|
|
}
|
|
|
|
const slicedGroup: Group<T> = {
|
|
name: group.name,
|
|
items: group.items.slice(currentIndex, currentIndex + remainingSlots),
|
|
totalCount: group.totalCount,
|
|
};
|
|
|
|
remainingItems -= slicedGroup.items.length;
|
|
remainingSlots -= slicedGroup.items.length;
|
|
currentPage.push(slicedGroup);
|
|
}
|
|
}
|
|
|
|
if (currentPage.length > 0) {
|
|
newPage();
|
|
}
|
|
|
|
return memo;
|
|
})();
|
|
|
|
useHotkeys("left", () => setPage((current) => current - 1), { enabled: page > 0 });
|
|
useHotkeys("right", () => setPage((current) => current + 1), {
|
|
enabled: page < pages.length - 1,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{pages.length > 1 && paginationSelectorTop && (
|
|
<PageSelector className="mb-12" page={page} pagesCount={pages.length} onChange={setPage} />
|
|
)}
|
|
|
|
<div className="mb-8">
|
|
{pages[page]?.length > 0 ? (
|
|
pages[page]?.map(
|
|
(group) =>
|
|
group.items.length > 0 && (
|
|
<Fragment key={group.name}>
|
|
{group.name.length > 0 && (
|
|
<h2
|
|
className="flex flex-row place-items-center gap-2 pb-2 pt-10 text-2xl
|
|
first-of-type:pt-0">
|
|
{group.name}
|
|
<Chip
|
|
text={`${group.totalCount} ${
|
|
group.items.length <= 1
|
|
? langui.result?.toLowerCase() ?? ""
|
|
: langui.results?.toLowerCase() ?? ""
|
|
}`}
|
|
/>
|
|
</h2>
|
|
)}
|
|
<div
|
|
className={cJoin(
|
|
`grid items-start gap-8 border-b-2 border-dotted pb-12
|
|
last-of-type:border-0`,
|
|
className
|
|
)}>
|
|
{group.items.map((item) => (
|
|
<RenderItem item={item} key={getItemId(item)} />
|
|
))}
|
|
</div>
|
|
</Fragment>
|
|
)
|
|
)
|
|
) : isDefined(RenderWhenEmpty) ? (
|
|
<RenderWhenEmpty />
|
|
) : (
|
|
<DefaultRenderWhenEmpty />
|
|
)}
|
|
</div>
|
|
|
|
{pages.length > 1 && paginationSelectorBottom && (
|
|
<PageSelector className="mb-12" page={page} pagesCount={pages.length} onChange={setPage} />
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
/*
|
|
* ╭──────────────────────╮
|
|
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
|
|
*/
|
|
|
|
const DefaultRenderWhenEmpty = () => {
|
|
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
|
|
const langui = useAtomGetter(atoms.localData.langui);
|
|
return (
|
|
<div className="grid h-full place-content-center">
|
|
<div
|
|
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
|
|
border-dark p-8 text-dark opacity-40">
|
|
{is3ColumnsLayout && <Ico icon={Icon.ChevronLeft} className="!text-[300%]" />}
|
|
<p className="max-w-xs text-2xl">{langui.no_results_message}</p>
|
|
{!is3ColumnsLayout && <Ico icon={Icon.ChevronRight} className="!text-[300%]" />}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|