5180 fichiers 38.5 Mo 0 téléchargements 157 vues mis à jour il y a 4 heures ui
nitro-v36-with-renderer-v36 / src / layout / InfiniteGrid.tsx
InfiniteGrid.tsx 12.3 Ko Télécharger le fichier
import { useVirtualizer } from '@tanstack/react-virtual';
import { ComponentPropsWithoutRef, CSSProperties, Fragment, ReactElement, ReactNode, Ref, RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { ToggleButton } from '../common/ToggleButton';
import { ClassicScrollAreaView } from '../common/scroll-area/ClassicScrollAreaView';
import { classNames } from './classNames';
import { OctaneLimitedEditionStyledNumberView } from './limited-edition';
import { styleNames } from './styleNames';

type Props<T> = {
    items: T[];
    columnCount: number;
    overscan?: number;
    estimateSize?: number;
    squareItems?: boolean;
    itemMinWidth?: number;
    rowGap?: number;
    columnGap?: number;
    classicScrollbar?: boolean;
    airColumnAdmission?: boolean;
    onColumnCountChange?: (columnCount: number) => void;
    itemRender?: (item: T, index?: number) => ReactElement;
    // Optional stable React key per item. Defaults to the grid position (index), which is fine
    // for static lists but lets a cell's local state bleed to a different item when the list
    // reorders - pass this for lists whose items are added/removed/reordered.
    itemKey?: (item: T, index: number) => string | number;
};

const GRID_GAP_PX = 4;

const useColumnMeasure = (
    itemMinWidth: number | null,
    columnCountProp: number,
    columnGap: number = GRID_GAP_PX,
    airColumnAdmission: boolean = false,
    onColumnCountChange?: (columnCount: number) => void
): { parentRef: RefObject<HTMLDivElement | null>; columnCount: number } => {
    const parentRef = useRef<HTMLDivElement>(null);
    const [measuredColumnCount, setMeasuredColumnCount] = useState<number>(columnCountProp);

    useLayoutEffect(() => {
        if (!itemMinWidth || itemMinWidth <= 0) return;

        const element = parentRef.current;
        if (!element) return;

        const recompute = () => {
            const computedStyle = window.getComputedStyle(element);
            const parsedLeft = Number.parseFloat(computedStyle.paddingLeft);
            const parsedRight = Number.parseFloat(computedStyle.paddingRight);
            const horizontalPadding = (Number.isFinite(parsedLeft) ? parsedLeft : 0) + (Number.isFinite(parsedRight) ? parsedRight : 0);
            const width = element.clientWidth - horizontalPadding;
            const admissionAllowance = airColumnAdmission ? columnGap * 2 : columnGap;
            const cols = Math.max(1, Math.floor((width + admissionAllowance) / (itemMinWidth + columnGap)));
            setMeasuredColumnCount((prev) => (prev === cols ? prev : cols));
        };

        recompute();

        const observer = new ResizeObserver(recompute);
        observer.observe(element);

        return () => observer.disconnect();
    }, [airColumnAdmission, columnGap, itemMinWidth]);

    const columnCount = itemMinWidth && itemMinWidth > 0 ? measuredColumnCount : columnCountProp;

    useLayoutEffect(() => onColumnCountChange?.(columnCount), [columnCount, onColumnCountChange]);

    return { parentRef, columnCount };
};

const InfiniteGridSquare = <T,>(props: Props<T>) => {
    const {
        items = [],
        columnCount: columnCountProp = 4,
        itemMinWidth = null,
        columnGap = GRID_GAP_PX,
        airColumnAdmission = false,
        onColumnCountChange,
        itemRender = null,
        itemKey = null,
        classicScrollbar = false
    } = props;
    const { parentRef } = useColumnMeasure(itemMinWidth, columnCountProp, columnGap, airColumnAdmission, onColumnCountChange);

    const autoFillStyle =
        itemMinWidth && itemMinWidth > 0 ? { columnGap, gridTemplateColumns: `repeat(auto-fill, minmax(${itemMinWidth}px, 1fr))` } : { columnGap };
    const fixedColsClass = itemMinWidth && itemMinWidth > 0 ? '' : `grid-cols-${columnCountProp}`;

    const content = (
        <div className={`grid ${fixedColsClass} gap-1 w-full`} style={autoFillStyle ?? undefined}>
            {items.map((item, index) => {
                if (!item) return <Fragment key={`${index}-empty`} />;

                return <Fragment key={itemKey ? itemKey(item, index) : `${index}-item`}>{itemRender(item, index)}</Fragment>;
            })}
        </div>
    );

    if (classicScrollbar) {
        return (
            <ClassicScrollAreaView className="size-full" viewportRef={parentRef}>
                {content}
            </ClassicScrollAreaView>
        );
    }

    return (
        <div ref={parentRef} className="overflow-y-auto size-full">
            {content}
        </div>
    );
};

const InfiniteGridVirtualized = <T,>(props: Props<T>) => {
    const {
        items = [],
        columnCount: columnCountProp = 4,
        overscan = 5,
        estimateSize = 45,
        itemMinWidth = null,
        rowGap = null,
        columnGap = GRID_GAP_PX,
        airColumnAdmission = false,
        onColumnCountChange,
        itemRender = null,
        itemKey = null,
        classicScrollbar = false
    } = props;
    const { parentRef, columnCount } = useColumnMeasure(itemMinWidth, columnCountProp, columnGap, airColumnAdmission, onColumnCountChange);

    const rowsContainerClassName = rowGap !== null ? 'flex flex-col w-full relative' : 'flex flex-col w-full *:pb-1 relative';

    const autoFillStyle = itemMinWidth && itemMinWidth > 0 ? { gridTemplateColumns: `repeat(auto-fill, minmax(${itemMinWidth}px, 1fr))` } : null;
    const fixedColsClass = itemMinWidth && itemMinWidth > 0 ? '' : `grid-cols-${columnCountProp}`;

    const virtualizer = useVirtualizer({
        count: Math.ceil(items.length / columnCount),
        overscan,
        getScrollElement: () => parentRef.current,
        estimateSize: () => estimateSize
    });

    useEffect(() => {
        const element = parentRef.current;

        if (!element || !items) return;

        if (classicScrollbar) {
            element.style.removeProperty('padding-right');
            return;
        }

        const checkAndApplyPadding = () => {
            if (!element) return;

            element.style.paddingRight = element.scrollHeight > element.clientHeight ? '0.25rem' : '0';
        };

        checkAndApplyPadding();

        window.addEventListener('resize', checkAndApplyPadding);

        return () => {
            window.removeEventListener('resize', checkAndApplyPadding);
        };
    }, [items, parentRef, classicScrollbar]);

    useEffect(() => {
        if (!items || !items.length) return;

        if (parentRef.current) parentRef.current.scrollLeft = 0;
        virtualizer.scrollToIndex(0);
    }, [items, virtualizer]);

    const virtualItems = virtualizer.getVirtualItems();

    const content = virtualItems.map((virtualRow) => (
        <div
            key={virtualRow.key + 'a'}
            ref={virtualizer.measureElement}
            className={`grid ${fixedColsClass} gap-1 absolute top-0 left-0 last:pb-0 w-full`}
            data-index={virtualRow.index}
            style={{
                ...(rowGap === null && { height: virtualRow.size }),
                ...(autoFillStyle ?? {}),
                ...(rowGap !== null && { paddingBottom: `${rowGap}px` }),
                columnGap,
                transform: `translateY(${virtualRow.start}px)`
            }}
        >
            {Array.from(Array(columnCount)).map((e, i) => {
                const index = i + virtualRow.index * columnCount;
                const item = items[index];

                if (!item) return <Fragment key={virtualRow.index + i + 'b'} />;

                return <Fragment key={itemKey ? itemKey(item, index) : i}>{itemRender(item, index)}</Fragment>;
            })}
        </div>
    ));

    if (classicScrollbar) {
        return (
            <ClassicScrollAreaView
                className="size-full"
                contentClassName={rowsContainerClassName}
                contentStyle={{ height: virtualizer.getTotalSize() }}
                viewportRef={parentRef}
            >
                {content}
            </ClassicScrollAreaView>
        );
    }

    return (
        <div ref={parentRef} className="overflow-y-auto size-full">
            <div
                className={rowsContainerClassName}
                style={{
                    height: virtualizer.getTotalSize()
                }}
            >
                {content}
            </div>
        </div>
    );
};

const InfiniteGridRoot = <T,>(props: Props<T>) => {
    return props.squareItems ? <InfiniteGridSquare<T> {...props} /> : <InfiniteGridVirtualized<T> {...props} />;
};

type InfiniteGridItemProps = {
    itemImage?: string;
    itemColor?: string;
    itemActive?: boolean;
    itemCount?: number;
    itemCountMinimum?: number;
    itemUniqueSoldout?: boolean;
    itemUniqueNumber?: number;
    itemUnseen?: boolean;
    itemHighlight?: boolean;
    disabled?: boolean;
    ref?: Ref<HTMLButtonElement>;
    className?: string;
    style?: CSSProperties;
    children?: ReactNode;
} & Omit<ComponentPropsWithoutRef<typeof ToggleButton>, 'className' | 'style' | 'children' | 'disabled' | 'pressed' | 'defaultPressed'>;

const InfiniteGridItem = ({
    ref,
    itemImage = undefined,
    itemColor = undefined,
    itemActive = false,
    itemCount = 1,
    itemCountMinimum = 1,
    itemUniqueSoldout = false,
    itemUniqueNumber = -2,
    itemUnseen = false,
    itemHighlight = false,
    disabled = false,
    className = null,
    style = {},
    children = null,
    ...rest
}: InfiniteGridItemProps) => {
    const [backgroundImageUrl, setBackgroundImageUrl] = useState<string>(null);
    const disposed = useRef<boolean>(false);

    useEffect(() => {
        if (!itemImage || !itemImage.length) {
            setBackgroundImageUrl(null);

            return;
        }

        const image = new Image();

        image.onload = () => {
            if (disposed.current) return;

            setBackgroundImageUrl(image.src);
        };

        image.src = itemImage;
    }, [itemImage]);

    useEffect(() => {
        disposed.current = false;

        return () => {
            disposed.current = true;
        };
    }, []);

    return (
        <ToggleButton
            ref={ref}
            pressed={itemActive}
            className={classNames(
                'flex flex-col items-center justify-center cursor-pointer overflow-hidden relative bg-center bg-no-repeat w-full rounded-md border-2',
                itemImage && (!backgroundImageUrl || !backgroundImageUrl.length) && 'octane-icon icon-loading',
                itemActive
                    ? itemColor
                        ? 'border-card-grid-item-active'
                        : 'border-card-grid-item-active bg-card-grid-item-active'
                    : itemColor
                      ? 'border-card-grid-item-border'
                      : 'border-card-grid-item-border bg-card-grid-item',
                (itemUniqueSoldout || itemUniqueNumber > 0) && 'unique-item',
                itemUniqueSoldout && 'sold-out',
                itemUnseen && ' animate-pulse-glow-gold border-yellow-400/60',
                className
            )}
            style={styleNames(
                backgroundImageUrl &&
                    backgroundImageUrl.length &&
                    !(itemUniqueSoldout || itemUniqueNumber > 0) && {
                        backgroundImage: `url(${backgroundImageUrl})`
                    },
                itemColor && {
                    backgroundColor: itemColor
                },
                style
            )}
            {...rest}
        >
            {itemCount > itemCountMinimum && (
                <div className="absolute align-middle rounded bg-red-700 bg-opacity-80 text-white border-black border top-[2px] right-[2px] text-[9.5px] p-[2px] z-1 leading-[8px]">
                    {itemCount}
                </div>
            )}
            {itemUniqueNumber > 0 && (
                <>
                    <div
                        className="size-full unique-bg-override"
                        style={{
                            backgroundImage: `url(${backgroundImageUrl})`
                        }}
                    />
                    <div className="absolute bottom-0 unique-item-counter">
                        <OctaneLimitedEditionStyledNumberView value={itemUniqueNumber} />
                    </div>
                </>
            )}
            {children}
        </ToggleButton>
    );
};

export const InfiniteGrid = Object.assign(InfiniteGridRoot, {
    Item: InfiniteGridItem
});
15Sujets
1Dépôts
20Membres
7En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser