Skip to content

Commit 16c57ec

Browse files
authored
fix+feat(select, listbox): bug on dataset with "sections", add support for scrollshadow (#4462)
* fix: add custom function to calculate rowHeight for dataset with sections * fix: scroll shadow is now working in virtualized components * chore: add changeset * fix: to pass test cases use function call instead of function component
1 parent e7ff673 commit 16c57ec

File tree

5 files changed

+197
-27
lines changed

5 files changed

+197
-27
lines changed

.changeset/quiet-geese-lay.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@nextui-org/autocomplete": patch
3+
"@nextui-org/listbox": patch
4+
"@nextui-org/select": patch
5+
---
6+
7+
add support for dataset with section, add support for scrollshadow

packages/components/autocomplete/src/use-autocomplete.ts

+1
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ export function useAutocomplete<T extends object>(originalProps: UseAutocomplete
461461
itemHeight,
462462
}
463463
: undefined,
464+
scrollShadowProps: slotsProps.scrollShadowProps,
464465
...mergeProps(slotsProps.listboxProps, listBoxProps, {
465466
shouldHighlightOnFocus: true,
466467
}),

packages/components/listbox/src/virtualized-listbox.tsx

+87-26
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import {useRef} from "react";
1+
import {useMemo, useRef, useState} from "react";
22
import {mergeProps} from "@react-aria/utils";
3-
import {useVirtualizer} from "@tanstack/react-virtual";
3+
import {useVirtualizer, VirtualItem} from "@tanstack/react-virtual";
44
import {isEmpty} from "@nextui-org/shared-utils";
5+
import {Node} from "@react-types/shared";
6+
import {ScrollShadowProps, useScrollShadow} from "@nextui-org/scroll-shadow";
7+
import {filterDOMProps} from "@nextui-org/react-utils";
58

69
import ListboxItem from "./listbox-item";
710
import ListboxSection from "./listbox-section";
@@ -11,8 +14,50 @@ import {UseListboxReturn} from "./use-listbox";
1114
interface Props extends UseListboxReturn {
1215
isVirtualized?: boolean;
1316
virtualization?: VirtualizationProps;
17+
/* Here in virtualized listbox, scroll shadow needs custom implementation. Hence this is the only way to pass props to scroll shadow */
18+
scrollShadowProps?: Partial<ScrollShadowProps>;
1419
}
1520

21+
const getItemSizesForCollection = (collection: Node<object>[], itemHeight: number) => {
22+
const sizes: number[] = [];
23+
24+
for (const item of collection) {
25+
if (item.type === "section") {
26+
/* +1 for the section header */
27+
sizes.push(([...item.childNodes].length + 1) * itemHeight);
28+
} else {
29+
sizes.push(itemHeight);
30+
}
31+
}
32+
33+
return sizes;
34+
};
35+
36+
const getScrollState = (element: HTMLDivElement | null) => {
37+
if (
38+
!element ||
39+
element.scrollTop === undefined ||
40+
element.clientHeight === undefined ||
41+
element.scrollHeight === undefined
42+
) {
43+
return {
44+
isTop: false,
45+
isBottom: false,
46+
isMiddle: false,
47+
};
48+
}
49+
50+
const isAtTop = element.scrollTop === 0;
51+
const isAtBottom = Math.ceil(element.scrollTop + element.clientHeight) >= element.scrollHeight;
52+
const isInMiddle = !isAtTop && !isAtBottom;
53+
54+
return {
55+
isTop: isAtTop,
56+
isBottom: isAtBottom,
57+
isMiddle: isInMiddle,
58+
};
59+
};
60+
1661
const VirtualizedListbox = (props: Props) => {
1762
const {
1863
Component,
@@ -29,6 +74,7 @@ const VirtualizedListbox = (props: Props) => {
2974
disableAnimation,
3075
getEmptyContentProps,
3176
getListProps,
77+
scrollShadowProps,
3278
} = props;
3379

3480
const {virtualization} = props;
@@ -45,24 +91,29 @@ const VirtualizedListbox = (props: Props) => {
4591

4692
const listHeight = Math.min(maxListboxHeight, itemHeight * state.collection.size);
4793

48-
const parentRef = useRef(null);
94+
const parentRef = useRef<HTMLDivElement>(null);
95+
const itemSizes = useMemo(
96+
() => getItemSizesForCollection([...state.collection], itemHeight),
97+
[state.collection, itemHeight],
98+
);
4999

50100
const rowVirtualizer = useVirtualizer({
51-
count: state.collection.size,
101+
count: [...state.collection].length,
52102
getScrollElement: () => parentRef.current,
53-
estimateSize: () => itemHeight,
103+
estimateSize: (i) => itemSizes[i],
54104
});
55105

56106
const virtualItems = rowVirtualizer.getVirtualItems();
57107

58-
const renderRow = ({
59-
index,
60-
style: virtualizerStyle,
61-
}: {
62-
index: number;
63-
style: React.CSSProperties;
64-
}) => {
65-
const item = [...state.collection][index];
108+
/* 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) */
109+
const {getBaseProps: getBasePropsScrollShadow} = useScrollShadow({...scrollShadowProps});
110+
111+
const renderRow = (virtualItem: VirtualItem) => {
112+
const item = [...state.collection][virtualItem.index];
113+
114+
if (!item) {
115+
return null;
116+
}
66117

67118
const itemProps = {
68119
color,
@@ -74,6 +125,15 @@ const VirtualizedListbox = (props: Props) => {
74125
...item.props,
75126
};
76127

128+
const virtualizerStyle = {
129+
position: "absolute" as const,
130+
top: 0,
131+
left: 0,
132+
width: "100%",
133+
height: `${virtualItem.size}px`,
134+
transform: `translateY(${virtualItem.start}px)`,
135+
};
136+
77137
if (item.type === "section") {
78138
return (
79139
<ListboxSection
@@ -102,6 +162,12 @@ const VirtualizedListbox = (props: Props) => {
102162
return listboxItem;
103163
};
104164

165+
const [scrollState, setScrollState] = useState({
166+
isTop: false,
167+
isBottom: true,
168+
isMiddle: false,
169+
});
170+
105171
const content = (
106172
<Component {...getListProps()}>
107173
{!state.collection.size && !hideEmptyContent && (
@@ -110,11 +176,18 @@ const VirtualizedListbox = (props: Props) => {
110176
</li>
111177
)}
112178
<div
179+
{...filterDOMProps(getBasePropsScrollShadow())}
113180
ref={parentRef}
181+
data-bottom-scroll={scrollState.isTop}
182+
data-top-bottom-scroll={scrollState.isMiddle}
183+
data-top-scroll={scrollState.isBottom}
114184
style={{
115185
height: maxListboxHeight,
116186
overflow: "auto",
117187
}}
188+
onScroll={(e) => {
189+
setScrollState(getScrollState(e.target as HTMLDivElement));
190+
}}
118191
>
119192
{listHeight > 0 && itemHeight > 0 && (
120193
<div
@@ -124,19 +197,7 @@ const VirtualizedListbox = (props: Props) => {
124197
position: "relative",
125198
}}
126199
>
127-
{virtualItems.map((virtualItem) =>
128-
renderRow({
129-
index: virtualItem.index,
130-
style: {
131-
position: "absolute",
132-
top: 0,
133-
left: 0,
134-
width: "100%",
135-
height: `${virtualItem.size}px`,
136-
transform: `translateY(${virtualItem.start}px)`,
137-
},
138-
}),
139-
)}
200+
{virtualItems.map((virtualItem) => renderRow(virtualItem))}
140201
</div>
141202
)}
142203
</div>

packages/components/select/src/use-select.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,9 @@ export type UseSelectProps<T> = Omit<
161161
SelectVariantProps & {
162162
/**
163163
* The height of each item in the listbox.
164+
* For dataset with sections, the itemHeight must be the height of each item (including padding, border, margin).
164165
* This is required for virtualized listboxes to calculate the height of each item.
166+
* @default 36
165167
*/
166168
itemHeight?: number;
167169
/**
@@ -208,7 +210,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
208210
onSelectionChange,
209211
placeholder,
210212
isVirtualized,
211-
itemHeight = 32,
213+
itemHeight = 36,
212214
maxListboxHeight = 256,
213215
children,
214216
disallowEmptySelection = false,
@@ -564,6 +566,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
564566
className: slots.listbox({
565567
class: clsx(classNames?.listbox, props?.className),
566568
}),
569+
scrollShadowProps: slotsProps.scrollShadowProps,
567570
...mergeProps(slotsProps.listboxProps, props, menuProps),
568571
} as ListboxProps;
569572
};

packages/components/select/stories/select.stories.tsx

+98
Original file line numberDiff line numberDiff line change
@@ -1391,6 +1391,104 @@ export const CustomItemHeight = {
13911391
},
13921392
};
13931393

1394+
const AVATAR_DECORATIONS: {[key: string]: string[]} = {
1395+
arcane: ["jinx", "atlas-gauntlets", "flame-chompers", "fishbones", "hexcore", "shimmer"],
1396+
anime: ["cat-ears", "heart-bloom", "in-love", "in-tears", "soul-leaving-body", "starry-eyed"],
1397+
"lofi-vibes": ["chromawave", "cozy-cat", "cozy-headphones", "doodling", "rainy-mood"],
1398+
valorant: [
1399+
"a-hint-of-clove",
1400+
"blade-storm",
1401+
"cypher",
1402+
"frag-out",
1403+
"omen-cowl",
1404+
"reyna-leer",
1405+
"vct-supernova",
1406+
"viper",
1407+
"yoru",
1408+
"carnalito2",
1409+
"a-hint-of-clove2",
1410+
"blade-storm2",
1411+
"cypher2",
1412+
"frag-out2",
1413+
"omen-cowl2",
1414+
"reyna-leer2",
1415+
"vct-supernova2",
1416+
"viper2",
1417+
"yoru2",
1418+
"carnalito3",
1419+
"a-hint-of-clove3",
1420+
"blade-storm3",
1421+
"cypher3",
1422+
"frag-out3",
1423+
"omen-cowl3",
1424+
"reyna-leer3",
1425+
"vct-supernova3",
1426+
"viper3",
1427+
"yoru3",
1428+
"carnalito4",
1429+
"a-hint-of-clove4",
1430+
"blade-storm4",
1431+
"cypher4",
1432+
"frag-out4",
1433+
"omen-cowl4",
1434+
"reyna-leer4",
1435+
"vct-supernova4",
1436+
"viper4",
1437+
"yoru4",
1438+
],
1439+
spongebob: [
1440+
"flower-clouds",
1441+
"gary-the-snail",
1442+
"imagination",
1443+
"musclebob",
1444+
"sandy-cheeks",
1445+
"spongebob",
1446+
],
1447+
arcade: ["clyde-invaders", "hot-shot", "joystick", "mallow-jump", "pipedream", "snake"],
1448+
"street-fighter": ["akuma", "cammy", "chun-li", "guile", "juri", "ken", "m.bison", "ryu"],
1449+
};
1450+
1451+
export const NonVirtualizedVsVirtualizedWithSections = {
1452+
render: () => {
1453+
const SelectComponent = ({isVirtualized}: {isVirtualized: boolean}) => (
1454+
<Select
1455+
disallowEmptySelection
1456+
className="max-w-xs"
1457+
color="secondary"
1458+
defaultSelectedKeys={["jinx"]}
1459+
isVirtualized={isVirtualized}
1460+
label={`Avatar Decoration ${isVirtualized ? "(Virtualized)" : "(Non-virtualized)"}`}
1461+
selectedKeys={["jinx"]}
1462+
selectionMode="single"
1463+
variant="bordered"
1464+
>
1465+
{Object.keys(AVATAR_DECORATIONS).map((key) => (
1466+
<SelectSection
1467+
key={key}
1468+
classNames={{
1469+
heading: "uppercase text-secondary",
1470+
}}
1471+
title={key}
1472+
>
1473+
{AVATAR_DECORATIONS[key].map((item) => (
1474+
<SelectItem key={item} className="capitalize" color="secondary" variant="bordered">
1475+
{item.replace(/-/g, " ")}
1476+
</SelectItem>
1477+
))}
1478+
</SelectSection>
1479+
))}
1480+
</Select>
1481+
);
1482+
1483+
return (
1484+
<div className="flex gap-4 w-full">
1485+
<SelectComponent isVirtualized={false} />
1486+
<SelectComponent isVirtualized={true} />
1487+
</div>
1488+
);
1489+
},
1490+
};
1491+
13941492
export const ValidationBehaviorAria = {
13951493
render: ValidationBehaviorAriaTemplate,
13961494
args: {

0 commit comments

Comments
 (0)