mirror of
https://github.com/iconoir-icons/iconoir
synced 2024-06-26 17:30:16 +02:00
201 lines
5.5 KiB
TypeScript
201 lines
5.5 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) {
|
|
return allIcons.filter((icon) => {
|
|
const normalSearch = normalizeString(filters.search!);
|
|
return (
|
|
normalizeString(icon.filename).includes(normalSearch) ||
|
|
normalizeString(icon.category).includes(normalSearch) ||
|
|
icon.tags.some((tag) => normalizeString(tag).includes(normalSearch))
|
|
);
|
|
});
|
|
} 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;
|
|
> :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';
|