iconoir/iconoir.com/components/IconList.tsx
2023-10-29 00:33:17 +02:00

217 lines
5.6 KiB
TypeScript

import { chunk } from 'lodash';
import React from 'react';
import {
areEqual,
ListChildComponentProps,
VariableSizeList as List,
} from 'react-window';
import styled from 'styled-components';
import useResizeObserver from 'use-resize-observer';
import { CategoryRow } from './CategoryRow';
import { ICON_SPACE, ICON_WIDTH } from '../lib/constants';
import { IconListEmpty } from './IconListEmpty';
import { IconsRow } from './IconsRow';
import { ReactWindowScroller } from './ReactWindowScroller';
export interface IconListFilters {
search?: string;
}
export interface IconListCustomizations {
size: number;
strokeWidth: number;
hexColor: string;
}
export interface Icon {
filename: string;
category: string;
tags: string[];
iconComponentName: string;
}
export const DEFAULT_CUSTOMIZATIONS: IconListCustomizations = {
size: 24,
strokeWidth: 1.5,
hexColor: '#000000',
};
function normalizeString(s: string) {
return s.toLowerCase().replace(/[!@#$%^&*(),.\][-]/g, '');
}
function filterIcons(allIcons: Icon[], filters: IconListFilters): Icon[] {
if (filters.search) {
const normalSearch = normalizeString(filters.search!);
let result = allIcons;
for (const term of normalSearch.split(' ')) {
result = result.filter((icon) => {
return (
normalizeString(icon.filename).includes(term) ||
normalizeString(icon.category).includes(term) ||
icon.tags.some((tag) => normalizeString(tag).includes(term))
);
});
}
return result;
} else return allIcons;
}
interface IconCategoryRow {
category: string;
numIcons: number;
}
interface IconIconsRow {
icons: Icon[];
}
type IconRow = IconCategoryRow | IconIconsRow;
function isCategoryRow(iconRow: IconRow): iconRow is IconCategoryRow {
return !!(iconRow as IconCategoryRow).category;
}
function getRowsFromIcons(
filteredIcons: Icon[],
iconsPerRow: number,
): IconRow[] {
const categoryGroups: Record<string, Icon[]> = {};
for (const icon of filteredIcons) {
if (!categoryGroups[icon.category]) categoryGroups[icon.category] = [];
categoryGroups[icon.category].push(icon);
}
const result: IconRow[] = [];
const sortedCategories = Object.keys(categoryGroups).sort();
for (const sortedCategory of sortedCategories) {
result.push({
category: sortedCategory,
numIcons: categoryGroups[sortedCategory].length,
});
const iconRows = chunk(categoryGroups[sortedCategory], iconsPerRow);
for (const iconRow of iconRows) {
result.push({ icons: iconRow });
}
}
return result;
}
const ICON_BOTTOM_PADDING = 65;
const HEADER_HEIGHT = 150;
const HEADER_INNER_HEIGHT = 15 + 40;
const HEADER_TOP_PADDING = HEADER_HEIGHT - HEADER_INNER_HEIGHT;
function getItemSize(row: IconRow, iconWidth: number): number {
if (isCategoryRow(row)) {
return HEADER_HEIGHT;
} else {
return iconWidth + ICON_BOTTOM_PADDING;
}
}
interface IconListContextValue {
iconWidth: number;
iconsPerRow: number;
}
export const IconListContext = React.createContext<
IconListContextValue | undefined
>(undefined);
export interface IconListProps {
filters: IconListFilters;
allIcons: Icon[];
}
export function IconList({ filters, allIcons }: IconListProps) {
const filteredIcons = filterIcons(allIcons, filters);
const { ref, width = 400 } = useResizeObserver();
const iconsPerRow = width
? Math.floor((width + ICON_SPACE) / (ICON_WIDTH + ICON_SPACE))
: null;
let children = null;
const listRef = React.useRef<List<IconRow[]> | null>();
const [height, setHeight] = React.useState(400);
const iconWidth = iconsPerRow
? Math.floor((width + ICON_SPACE) / iconsPerRow) - ICON_SPACE
: null;
React.useEffect(() => {
setHeight(window.innerHeight);
}, []);
React.useEffect(() => {
if (listRef.current) {
listRef.current.resetAfterIndex(0, true);
}
}, [iconWidth, height]);
if (filteredIcons.length && iconsPerRow && width && iconWidth) {
const iconRows = getRowsFromIcons(filteredIcons, iconsPerRow);
children = (
<IconListContext.Provider value={{ iconsPerRow, iconWidth }}>
<ReactWindowScroller>
{({ ref, outerRef, style, onScroll }: any) => (
<List<IconRow[]>
ref={(c) => {
if (typeof ref === 'function') ref(c);
else ref.current = c;
listRef.current = c;
}}
itemData={iconRows}
width={width}
outerRef={outerRef}
style={style}
height={height}
itemCount={iconRows.length}
onScroll={onScroll}
itemSize={(index) => getItemSize(iconRows[index], iconWidth)}
>
{Row}
</List>
)}
</ReactWindowScroller>
</IconListContext.Provider>
);
} else if (width && filters.search) {
return <IconListEmpty searchTerm={filters.search} />;
}
return <Container ref={ref}>{children}</Container>;
}
const Container = styled.div`
width: 100%;
margin-top: -${HEADER_TOP_PADDING}px;
> :first-child {
overflow: visible !important;
> :first-child {
-webkit-overflow-scrolling: touch;
}
}
`;
const Row = React.memo(
({ data, index, style }: ListChildComponentProps<IconRow[]>) => {
const { iconWidth } = React.useContext(IconListContext)!;
const row = data[index];
if (isCategoryRow(row)) {
return (
<CategoryRow
category={row.category}
numIcons={row.numIcons}
style={style}
/>
);
} else {
return <IconsRow icons={row.icons} style={style} iconWidth={iconWidth} />;
}
},
areEqual,
);
Row.displayName = 'Row';