Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-mugs-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": patch
---

Fix FilterListBox filtering bug.
102 changes: 39 additions & 63 deletions src/components/fields/FilterListBox/FilterListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
import { mergeProps, modAttrs, useCombinedRefs } from '../../../utils/react';
import { useFocus } from '../../../utils/react/interactions';
import { StyledHeader } from '../../actions/Menu/styled';
import { ItemBase } from '../../content/ItemBase';
import { useFieldProps, useFormProps, wrapWithField } from '../../form';
import { CubeItemProps } from '../../Item';
import { CubeListBoxProps, ListBox } from '../ListBox/ListBox';
Expand Down Expand Up @@ -759,19 +758,6 @@ export const FilterListBox = forwardRef(function FilterListBox<
],
);

const hasResults = useMemo(() => {
if (!listStateRef.current?.collection) return !!enhancedChildren;

// Collection is already filtered by React Stately, just check if it has items
for (const node of listStateRef.current.collection) {
if (node.type === 'item') return true;
if (node.childNodes && [...node.childNodes].length > 0) return true;
}
return false;
}, [listStateRef.current?.collection, enhancedChildren, searchValue]);

const showEmptyMessage = !hasResults && searchValue.trim();

// Handler must be defined before we render ListBox so we can pass it.
const handleSelectionChange = (selection: any) => {
if (allowsCustomValue) {
Expand Down Expand Up @@ -876,55 +862,45 @@ export const FilterListBox = forwardRef(function FilterListBox<
<div role="presentation" />
)}
{searchInput}
{showEmptyMessage ? (
<ItemBase
preset="t4"
color="#dark-03"
size={size}
padding="(.5x - 1bw)"
>
{emptyLabel !== undefined ? emptyLabel : 'No results found'}
</ItemBase>
) : (
<ListBox
ref={listBoxRef}
aria-label={innerAriaLabel}
selectedKey={selectedKey}
defaultSelectedKey={defaultSelectedKey}
selectedKeys={selectedKeys}
defaultSelectedKeys={defaultSelectedKeys}
selectionMode={selectionMode}
isDisabled={isDisabled}
listRef={listRef}
stateRef={listStateRef}
listStyles={listStyles}
shouldFocusWrap={shouldFocusWrap}
optionStyles={optionStyles}
sectionStyles={sectionStyles}
headingStyles={headingStyles}
validationState={validationState}
disallowEmptySelection={props.disallowEmptySelection}
disabledKeys={props.disabledKeys}
focusOnHover={focusOnHover}
shouldUseVirtualFocus={true}
showSelectAll={showSelectAll}
selectAllLabel={selectAllLabel}
footer={footer}
footerStyles={footerStyles}
mods={mods}
size="medium"
styles={listBoxStyles}
isCheckable={isCheckable}
items={items as any}
allValueProps={allValueProps}
filter={filterFn}
onSelectionChange={handleSelectionChange}
onEscape={onEscape}
onOptionClick={handleOptionClick}
>
{enhancedChildren as any}
</ListBox>
)}
<ListBox
ref={listBoxRef}
aria-label={innerAriaLabel}
selectedKey={selectedKey}
defaultSelectedKey={defaultSelectedKey}
selectedKeys={selectedKeys}
defaultSelectedKeys={defaultSelectedKeys}
selectionMode={selectionMode}
isDisabled={isDisabled}
listRef={listRef}
stateRef={listStateRef}
listStyles={listStyles}
shouldFocusWrap={shouldFocusWrap}
optionStyles={optionStyles}
sectionStyles={sectionStyles}
headingStyles={headingStyles}
validationState={validationState}
disallowEmptySelection={props.disallowEmptySelection}
disabledKeys={props.disabledKeys}
focusOnHover={focusOnHover}
shouldUseVirtualFocus={true}
showSelectAll={showSelectAll}
selectAllLabel={selectAllLabel}
footer={footer}
footerStyles={footerStyles}
mods={mods}
size="medium"
styles={listBoxStyles}
isCheckable={isCheckable}
items={items as any}
allValueProps={allValueProps}
filter={filterFn}
emptyLabel={emptyLabel !== undefined ? emptyLabel : 'No results found'}
onSelectionChange={handleSelectionChange}
onEscape={onEscape}
onOptionClick={handleOptionClick}
>
{enhancedChildren as any}
</ListBox>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Empty List Shows Misleading "No Results" Message

The FilterListBox now displays the "No results found" message (or a custom empty label) when the list is initially empty, even without an active search. This is misleading, as the message implies filtering has occurred, but the list simply has no items.

Additional Locations (1)

Fix in Cursor Fix in Web

</FilterListBoxWrapperElement>
);

Expand Down
214 changes: 116 additions & 98 deletions src/components/fields/ListBox/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,12 @@ export interface CubeListBoxProps<T>
* Useful for implementing search/filter functionality.
*/
filter?: (nodes: Iterable<any>) => Iterable<any>;

/**
* Label to display when the list is empty (no items available).
* Defaults to "No items".
*/
emptyLabel?: ReactNode;
}

const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES];
Expand Down Expand Up @@ -508,6 +514,7 @@ export const ListBox = forwardRef(function ListBox<T extends object>(
selectAllLabel,
allValueProps,
filter,
emptyLabel = 'No items',
form,
...otherProps
} = props;
Expand Down Expand Up @@ -886,110 +893,121 @@ export const ListBox = forwardRef(function ListBox<T extends object>(
)}
{/* Scroll container wrapper */}
<ListBoxScrollElement ref={scrollRef} mods={mods} {...focusProps}>
<ListElement
qa={qa || 'ListBox'}
{...mergedListBoxProps}
ref={listRef}
styles={listStyles}
aria-disabled={isDisabled || undefined}
mods={{ sections: hasSections }}
style={
shouldVirtualize
? {
position: 'relative',
height: `${rowVirtualizer.getTotalSize() + 3}px`,
}
: undefined
}
>
{shouldVirtualize
? rowVirtualizer.getVirtualItems().map((virtualItem) => {
const item = itemsArray[virtualItem.index];

return (
<Option
key={virtualItem.key}
size={size}
item={item}
state={listState}
styles={optionStyles}
isParentDisabled={isDisabled}
validationState={validationState}
focusOnHover={focusOnHover}
isCheckable={isCheckable}
// We don't need to measure the element here, because the height is already set by the virtualizer
// This is a workaround to avoid glitches when selecting/deselecting items
virtualRef={rowVirtualizer.measureElement as any}
virtualStyle={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${virtualItem.start}px)`,
}}
virtualIndex={virtualItem.index}
lastFocusSourceRef={lastFocusSourceRef}
onClick={onOptionClick}
/>
);
})
: (() => {
const renderedItems: ReactNode[] = [];
let isFirstSection = true;

for (const item of listState.collection) {
if (item.type === 'section') {
if (!isFirstSection) {
{listState.collection.size === 0 ? (
<ItemBase
preset="t4"
color="#dark-03"
size={size}
padding="(.5x - 1bw)"
>
{emptyLabel}
</ItemBase>
) : (
<ListElement
qa={qa || 'ListBox'}
{...mergedListBoxProps}
ref={listRef}
styles={listStyles}
aria-disabled={isDisabled || undefined}
mods={{ sections: hasSections }}
style={
shouldVirtualize
? {
position: 'relative',
height: `${rowVirtualizer.getTotalSize() + 3}px`,
}
: undefined
}
>
{shouldVirtualize
? rowVirtualizer.getVirtualItems().map((virtualItem) => {
const item = itemsArray[virtualItem.index];

return (
<Option
key={virtualItem.key}
size={size}
item={item}
state={listState}
styles={optionStyles}
isParentDisabled={isDisabled}
validationState={validationState}
focusOnHover={focusOnHover}
isCheckable={isCheckable}
// We don't need to measure the element here, because the height is already set by the virtualizer
// This is a workaround to avoid glitches when selecting/deselecting items
virtualRef={rowVirtualizer.measureElement as any}
virtualStyle={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${virtualItem.start}px)`,
}}
virtualIndex={virtualItem.index}
lastFocusSourceRef={lastFocusSourceRef}
onClick={onOptionClick}
/>
);
})
: (() => {
const renderedItems: ReactNode[] = [];
let isFirstSection = true;

for (const item of listState.collection) {
if (item.type === 'section') {
if (!isFirstSection) {
renderedItems.push(
<StyledDivider
key={`divider-${String(item.key)}`}
role="separator"
aria-orientation="horizontal"
/>,
);
}

renderedItems.push(
<StyledDivider
key={`divider-${String(item.key)}`}
role="separator"
aria-orientation="horizontal"
<ListBoxSection
key={item.key}
item={item}
state={listState}
optionStyles={optionStyles}
headingStyles={headingStyles}
sectionStyles={sectionStyles}
isParentDisabled={isDisabled}
validationState={validationState}
focusOnHover={focusOnHover}
isCheckable={isCheckable}
size={size}
lastFocusSourceRef={lastFocusSourceRef}
onClick={onOptionClick}
/>,
);
}

renderedItems.push(
<ListBoxSection
key={item.key}
item={item}
state={listState}
optionStyles={optionStyles}
headingStyles={headingStyles}
sectionStyles={sectionStyles}
isParentDisabled={isDisabled}
validationState={validationState}
focusOnHover={focusOnHover}
isCheckable={isCheckable}
size={size}
lastFocusSourceRef={lastFocusSourceRef}
onClick={onOptionClick}
/>,
);

isFirstSection = false;
} else {
renderedItems.push(
<Option
key={item.key}
size={size}
item={item}
state={listState}
styles={optionStyles}
isParentDisabled={isDisabled}
validationState={validationState}
focusOnHover={focusOnHover}
isCheckable={isCheckable}
lastFocusSourceRef={lastFocusSourceRef}
onClick={onOptionClick}
/>,
);
isFirstSection = false;
} else {
renderedItems.push(
<Option
key={item.key}
size={size}
item={item}
state={listState}
styles={optionStyles}
isParentDisabled={isDisabled}
validationState={validationState}
focusOnHover={focusOnHover}
isCheckable={isCheckable}
lastFocusSourceRef={lastFocusSourceRef}
onClick={onOptionClick}
/>,
);
}
}
}

return renderedItems;
})()}
</ListElement>
return renderedItems;
})()}
</ListElement>
)}
</ListBoxScrollElement>
{footer ? (
<StyledFooter styles={footerStyles} data-size={size}>
Expand Down
Loading