2022-12-05 22:01:46 +01:00

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