From 30b183280feca1675f95c0dfa029b9b90d1fa1a5 Mon Sep 17 00:00:00 2001 From: Vincentius Roger Kuswara Date: Mon, 30 Dec 2024 19:01:32 +0800 Subject: [PATCH 1/4] fix: add custom function to calculate rowHeight for dataset with sections --- .../listbox/src/virtualized-listbox.tsx | 62 +++++++++++---- packages/components/select/src/use-select.ts | 4 +- .../select/stories/select.stories.tsx | 79 +++++++++++++++++++ 3 files changed, 127 insertions(+), 18 deletions(-) diff --git a/packages/components/listbox/src/virtualized-listbox.tsx b/packages/components/listbox/src/virtualized-listbox.tsx index 0a36766204..48c9499356 100644 --- a/packages/components/listbox/src/virtualized-listbox.tsx +++ b/packages/components/listbox/src/virtualized-listbox.tsx @@ -1,7 +1,8 @@ -import {useRef} from "react"; +import {useMemo, useRef} from "react"; import {mergeProps} from "@react-aria/utils"; import {useVirtualizer} from "@tanstack/react-virtual"; import {isEmpty} from "@nextui-org/shared-utils"; +import {Node} from "@react-types/shared"; import ListboxItem from "./listbox-item"; import ListboxSection from "./listbox-section"; @@ -13,6 +14,21 @@ interface Props extends UseListboxReturn { virtualization?: VirtualizationProps; } +const getItemSizesForCollection = (collection: Node[], itemHeight: number) => { + const sizes: number[] = []; + + for (const item of collection) { + if (item.type === "section") { + /* +1 for the section header */ + sizes.push(([...item.childNodes].length + 1) * itemHeight); + } else { + sizes.push(itemHeight); + } + } + + return sizes; +}; + const VirtualizedListbox = (props: Props) => { const { Component, @@ -46,16 +62,20 @@ const VirtualizedListbox = (props: Props) => { const listHeight = Math.min(maxListboxHeight, itemHeight * state.collection.size); const parentRef = useRef(null); + const itemSizes = useMemo( + () => getItemSizesForCollection([...state.collection], itemHeight), + [state.collection, itemHeight], + ); const rowVirtualizer = useVirtualizer({ - count: state.collection.size, + count: [...state.collection].length, getScrollElement: () => parentRef.current, - estimateSize: () => itemHeight, + estimateSize: (i) => itemSizes[i], }); const virtualItems = rowVirtualizer.getVirtualItems(); - const renderRow = ({ + const ListBoxRow = ({ index, style: virtualizerStyle, }: { @@ -64,6 +84,10 @@ const VirtualizedListbox = (props: Props) => { }) => { const item = [...state.collection][index]; + if (!item) { + return null; + } + const itemProps = { color, item, @@ -111,6 +135,7 @@ const VirtualizedListbox = (props: Props) => { )}
{ position: "relative", }} > - {virtualItems.map((virtualItem) => - renderRow({ - index: virtualItem.index, - style: { - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: `${virtualItem.size}px`, - transform: `translateY(${virtualItem.start}px)`, - }, - }), - )} + {virtualItems.map((virtualItem) => { + return ( + + ); + })}
)} diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index e563cd1f7d..09aa8fd993 100644 --- a/packages/components/select/src/use-select.ts +++ b/packages/components/select/src/use-select.ts @@ -161,7 +161,9 @@ export type UseSelectProps = Omit< SelectVariantProps & { /** * The height of each item in the listbox. + * For dataset with sections, the itemHeight must be the height of each item (including padding, border, margin). * This is required for virtualized listboxes to calculate the height of each item. + * @default 36 */ itemHeight?: number; /** @@ -208,7 +210,7 @@ export function useSelect(originalProps: UseSelectProps) { onSelectionChange, placeholder, isVirtualized, - itemHeight = 32, + itemHeight = 36, maxListboxHeight = 256, children, disallowEmptySelection = false, diff --git a/packages/components/select/stories/select.stories.tsx b/packages/components/select/stories/select.stories.tsx index 34136d2bb3..6fac92d091 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -1391,6 +1391,85 @@ export const CustomItemHeight = { }, }; +const AVATAR_DECORATIONS: {[key: string]: string[]} = { + arcane: ["jinx", "atlas-gauntlets", "flame-chompers", "fishbones", "hexcore", "shimmer"], + anime: ["cat-ears", "heart-bloom", "in-love", "in-tears", "soul-leaving-body", "starry-eyed"], + "lofi-vibes": ["chromawave", "cozy-cat", "cozy-headphones", "doodling", "rainy-mood"], + valorant: [ + "a-hint-of-clove", + "blade-storm", + "cypher", + "frag-out", + "omen-cowl", + "reyna-leer", + "vct-supernova", + "viper", + "yoru", + "carnalito", + "a-hint-of-clove", + "blade-storm", + "cypher", + "frag-out", + "omen-cowl", + "reyna-leer", + "vct-supernova", + "viper", + "yoru", + "carnalito", + ], + spongebob: [ + "flower-clouds", + "gary-the-snail", + "imagination", + "musclebob", + "sandy-cheeks", + "spongebob", + ], + arcade: ["clyde-invaders", "hot-shot", "joystick", "mallow-jump", "pipedream", "snake"], + "street-fighter": ["akuma", "cammy", "chun-li", "guile", "juri", "ken", "m.bison", "ryu"], +}; + +export const NonVirtualizedVsVirtualizedWithSections = { + render: () => { + const SelectComponent = ({isVirtualized}: {isVirtualized: boolean}) => ( + + ); + + return ( +
+ + +
+ ); + }, +}; + export const ValidationBehaviorAria = { render: ValidationBehaviorAriaTemplate, args: { From c2e94596b9c3fdff2a01001edf2730e8da8a92d1 Mon Sep 17 00:00:00 2001 From: Vincentius Roger Kuswara Date: Mon, 30 Dec 2024 20:11:13 +0800 Subject: [PATCH 2/4] fix: scroll shadow is now working in virtualized components --- .../autocomplete/src/use-autocomplete.ts | 1 + .../listbox/src/virtualized-listbox.tsx | 51 +++++++++++++++++-- packages/components/select/src/use-select.ts | 1 + .../select/stories/select.stories.tsx | 41 +++++++++++---- 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 381809b85b..610808b1cb 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -461,6 +461,7 @@ export function useAutocomplete(originalProps: UseAutocomplete itemHeight, } : undefined, + scrollShadowProps: slotsProps.scrollShadowProps, ...mergeProps(slotsProps.listboxProps, listBoxProps, { shouldHighlightOnFocus: true, }), diff --git a/packages/components/listbox/src/virtualized-listbox.tsx b/packages/components/listbox/src/virtualized-listbox.tsx index 48c9499356..477513c728 100644 --- a/packages/components/listbox/src/virtualized-listbox.tsx +++ b/packages/components/listbox/src/virtualized-listbox.tsx @@ -1,8 +1,10 @@ -import {useMemo, useRef} from "react"; +import {useMemo, useRef, useState} from "react"; import {mergeProps} from "@react-aria/utils"; import {useVirtualizer} from "@tanstack/react-virtual"; import {isEmpty} from "@nextui-org/shared-utils"; import {Node} from "@react-types/shared"; +import {ScrollShadowProps, useScrollShadow} from "@nextui-org/scroll-shadow"; +import {filterDOMProps} from "@nextui-org/react-utils"; import ListboxItem from "./listbox-item"; import ListboxSection from "./listbox-section"; @@ -12,6 +14,8 @@ import {UseListboxReturn} from "./use-listbox"; interface Props extends UseListboxReturn { isVirtualized?: boolean; virtualization?: VirtualizationProps; + /* Here in virtualized listbox, scroll shadow needs custom implementation. Hence this is the only way to pass props to scroll shadow */ + scrollShadowProps?: Partial; } const getItemSizesForCollection = (collection: Node[], itemHeight: number) => { @@ -29,6 +33,31 @@ const getItemSizesForCollection = (collection: Node[], itemHeight: numbe return sizes; }; +const getScrollState = (element: HTMLDivElement | null) => { + if ( + !element || + element.scrollTop === undefined || + element.clientHeight === undefined || + element.scrollHeight === undefined + ) { + return { + isTop: false, + isBottom: false, + isMiddle: false, + }; + } + + const isAtTop = element.scrollTop === 0; + const isAtBottom = Math.ceil(element.scrollTop + element.clientHeight) >= element.scrollHeight; + const isInMiddle = !isAtTop && !isAtBottom; + + return { + isTop: isAtTop, + isBottom: isAtBottom, + isMiddle: isInMiddle, + }; +}; + const VirtualizedListbox = (props: Props) => { const { Component, @@ -45,6 +74,7 @@ const VirtualizedListbox = (props: Props) => { disableAnimation, getEmptyContentProps, getListProps, + scrollShadowProps, } = props; const {virtualization} = props; @@ -61,7 +91,7 @@ const VirtualizedListbox = (props: Props) => { const listHeight = Math.min(maxListboxHeight, itemHeight * state.collection.size); - const parentRef = useRef(null); + const parentRef = useRef(null); const itemSizes = useMemo( () => getItemSizesForCollection([...state.collection], itemHeight), [state.collection, itemHeight], @@ -75,6 +105,9 @@ const VirtualizedListbox = (props: Props) => { const virtualItems = rowVirtualizer.getVirtualItems(); + /* Here we need the base props for scroll shadow, contains the className (scrollbar-hide and scrollshadow config based on the user inputs on select props) */ + const {getBaseProps: getBasePropsScrollShadow} = useScrollShadow({...scrollShadowProps}); + const ListBoxRow = ({ index, style: virtualizerStyle, @@ -126,6 +159,12 @@ const VirtualizedListbox = (props: Props) => { return listboxItem; }; + const [scrollState, setScrollState] = useState({ + isTop: false, + isBottom: true, + isMiddle: false, + }); + const content = ( {!state.collection.size && !hideEmptyContent && ( @@ -134,12 +173,18 @@ const VirtualizedListbox = (props: Props) => { )}
{ + setScrollState(getScrollState(e.target as HTMLDivElement)); + }} > {listHeight > 0 && itemHeight > 0 && (
(originalProps: UseSelectProps) { className: slots.listbox({ class: clsx(classNames?.listbox, props?.className), }), + scrollShadowProps: slotsProps.scrollShadowProps, ...mergeProps(slotsProps.listboxProps, props, menuProps), } as ListboxProps; }; diff --git a/packages/components/select/stories/select.stories.tsx b/packages/components/select/stories/select.stories.tsx index 6fac92d091..39f2557d2a 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -1405,17 +1405,36 @@ const AVATAR_DECORATIONS: {[key: string]: string[]} = { "vct-supernova", "viper", "yoru", - "carnalito", - "a-hint-of-clove", - "blade-storm", - "cypher", - "frag-out", - "omen-cowl", - "reyna-leer", - "vct-supernova", - "viper", - "yoru", - "carnalito", + "carnalito2", + "a-hint-of-clove2", + "blade-storm2", + "cypher2", + "frag-out2", + "omen-cowl2", + "reyna-leer2", + "vct-supernova2", + "viper2", + "yoru2", + "carnalito3", + "a-hint-of-clove3", + "blade-storm3", + "cypher3", + "frag-out3", + "omen-cowl3", + "reyna-leer3", + "vct-supernova3", + "viper3", + "yoru3", + "carnalito4", + "a-hint-of-clove4", + "blade-storm4", + "cypher4", + "frag-out4", + "omen-cowl4", + "reyna-leer4", + "vct-supernova4", + "viper4", + "yoru4", ], spongebob: [ "flower-clouds", From 848863ff022a58ac376452518e6940633297c14f Mon Sep 17 00:00:00 2001 From: Vincentius Roger Kuswara Date: Mon, 30 Dec 2024 20:13:53 +0800 Subject: [PATCH 3/4] chore: add changeset --- .changeset/quiet-geese-lay.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/quiet-geese-lay.md diff --git a/.changeset/quiet-geese-lay.md b/.changeset/quiet-geese-lay.md new file mode 100644 index 0000000000..1a73adf8e5 --- /dev/null +++ b/.changeset/quiet-geese-lay.md @@ -0,0 +1,7 @@ +--- +"@nextui-org/autocomplete": patch +"@nextui-org/listbox": patch +"@nextui-org/select": patch +--- + +add support for dataset with section, add support for scrollshadow From 86dcb3d994c5db32bad7c93a8b5b02488f4aef91 Mon Sep 17 00:00:00 2001 From: Vincentius Roger Kuswara Date: Mon, 30 Dec 2024 21:11:06 +0800 Subject: [PATCH 4/4] fix: to pass test cases use function call instead of function component --- .../listbox/src/virtualized-listbox.tsx | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/components/listbox/src/virtualized-listbox.tsx b/packages/components/listbox/src/virtualized-listbox.tsx index 477513c728..4f705df676 100644 --- a/packages/components/listbox/src/virtualized-listbox.tsx +++ b/packages/components/listbox/src/virtualized-listbox.tsx @@ -1,6 +1,6 @@ import {useMemo, useRef, useState} from "react"; import {mergeProps} from "@react-aria/utils"; -import {useVirtualizer} from "@tanstack/react-virtual"; +import {useVirtualizer, VirtualItem} from "@tanstack/react-virtual"; import {isEmpty} from "@nextui-org/shared-utils"; import {Node} from "@react-types/shared"; import {ScrollShadowProps, useScrollShadow} from "@nextui-org/scroll-shadow"; @@ -108,14 +108,8 @@ const VirtualizedListbox = (props: Props) => { /* Here we need the base props for scroll shadow, contains the className (scrollbar-hide and scrollshadow config based on the user inputs on select props) */ const {getBaseProps: getBasePropsScrollShadow} = useScrollShadow({...scrollShadowProps}); - const ListBoxRow = ({ - index, - style: virtualizerStyle, - }: { - index: number; - style: React.CSSProperties; - }) => { - const item = [...state.collection][index]; + const renderRow = (virtualItem: VirtualItem) => { + const item = [...state.collection][virtualItem.index]; if (!item) { return null; @@ -131,6 +125,15 @@ const VirtualizedListbox = (props: Props) => { ...item.props, }; + const virtualizerStyle = { + position: "absolute" as const, + top: 0, + left: 0, + width: "100%", + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)`, + }; + if (item.type === "section") { return ( { position: "relative", }} > - {virtualItems.map((virtualItem) => { - return ( - - ); - })} + {virtualItems.map((virtualItem) => renderRow(virtualItem))}
)}