Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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/filterpicker-loading-items.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': minor
---

Add `isLoadingItems` prop to `FilterPicker` and `FilterListBox`. Unlike `isLoading`, this does not disable the trigger — the popover can still be opened while items are being fetched. Inside the popover, a loading disclaimer is shown. When `allowsCustomValue={false}`, the search input is hidden and the disclaimer becomes the focus target; when `allowsCustomValue={true}`, the search input remains visible so a custom value can still be typed and applied. The disclaimer label is customizable via `loadingItemsLabel` (defaults to `"Loading items..."`).
3 changes: 3 additions & 0 deletions src/components/fields/FilterListBox/FilterListBox.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ A searchable list selection component that combines a ListBox with an integrated
- **`onOptionClick`** `(key: Key) => void` — Callback when an option is clicked
- **`items`** `Iterable<T>` — Array of items for dynamic content with render function pattern
- **`isLoading`** `boolean` (default: `false`) — Whether the FilterListBox is in a loading state (shows loading icon in search input)
- **`isLoadingItems`** `boolean` (default: `false`) — Whether the items are currently loading. Shows a loading disclaimer inside the popover. When `allowsCustomValue` is `false`, the search input is hidden and the disclaimer acts as the focus target; when `true`, the search input remains visible so custom values can still be typed, and the disclaimer is shown below it
- **`loadingItemsLabel`** `ReactNode` (default: `Loading items...`) — Label displayed inside the loading disclaimer when `isLoadingItems` is `true`
- **`autoFocus`** `boolean` — Whether the search input should have autofocus
- **`customValueProps`** `Partial<CubeItemProps>` — Props to apply to existing custom values (already selected but not in predefined options)
- **`newCustomValueProps`** `Partial<CubeItemProps>` — Props to apply to new custom values appearing in search results (merged with `customValueProps`)
Expand Down Expand Up @@ -150,6 +152,7 @@ The `mods` property accepts the following modifiers you can override:
- `disabled` `boolean` — Applied when `isDisabled={true}`
- `focused` `boolean` — Applied when the FilterListBox has focus
- `loading` `boolean` — Applied when `isLoading={true}`
- `loading-items` `boolean` — Applied when `isLoadingItems={true}`
- `searchable` `boolean` — Always true for FilterListBox
- `prefix` `boolean` — Applied when loading icon is shown

Expand Down
8 changes: 5 additions & 3 deletions src/components/fields/FilterListBox/FilterListBox.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const mergedChildren = useMemo(() => {
### Loading States
- **Global Loading**: `isLoading` prop shows loading spinner in search input
- **Search Icon**: Alternates between search icon and loading spinner
- **Loading Items**: `isLoadingItems` prop shows a loading disclaimer inside the popover. When `allowsCustomValue` is `false`, the search input is hidden and the disclaimer becomes the focus target for keyboard navigation; when `true`, the search input remains available so custom values can still be typed while the disclaimer is shown below it. The disclaimer label is customizable via `loadingItemsLabel`

## Styling System

Expand All @@ -140,14 +141,15 @@ const mergedChildren = useMemo(() => {

### Grid Layout
```css
gridRows: 'max-content max-content 1sf'
/* Header (optional) | Search Input | ListBox (flexible) */
gridRows: 'max-content max-content max-content 1sf'
/* Header (optional) | Search Input (optional) | Loading Disclaimer (optional) | ListBox (flexible) */
```

### Modifier States
- **focused**: Search input has focus
- **invalid/valid**: Validation state styling
- **loading**: Loading state indication
- **loading**: Loading state indication (`isLoading`)
- **loading-items**: Items-loading state indication (`isLoadingItems`)
- **searchable**: Always true for this component

## Performance Considerations
Expand Down
107 changes: 103 additions & 4 deletions src/components/fields/FilterListBox/FilterListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const FilterListBoxWrapperElement = tasty({
display: 'grid',
flow: 'column',
gridColumns: '1sf',
gridRows: 'max-content max-content 1sf',
gridRows: 'max-content max-content max-content 1sf',
gap: 0,
position: 'relative',
radius: true,
Expand Down Expand Up @@ -105,6 +105,35 @@ const StyledHeaderWithoutBorder = tasty(StyledHeader, {
},
});

const LoadingDisclaimerElement = tasty({
qa: 'FilterListBoxLoadingDisclaimer',
styles: {
display: 'flex',
flow: 'row',
gap: '1x',
padding: '.75x 1.5x',
color: '#dark-03',
preset: 'p4',
placeItems: 'center start',
fill: '#clear',
border: 'bottom',
height: {
'': 'auto',
focusable: '($size + 1x)',
},
$size: {
'': '$size-md',
'size=small': '$size-sm',
'size=medium': '$size-md',
'size=large': '$size-lg',
},
outline: {
'': '#purple-03.0',
focused: '#purple-03',
},
},
});

export interface CubeFilterListBoxProps<T>
extends Omit<CubeListBoxProps<T>, 'filter'>,
FieldBaseProps {
Expand All @@ -123,6 +152,19 @@ export interface CubeFilterListBoxProps<T>
searchInputStyles?: Styles;
/** Whether the FilterListBox is in loading state (shows loading icon in search input) */
isLoading?: boolean;
/**
* Whether the items are currently loading. Shows a "loading items" disclaimer
* inside the popover. When `allowsCustomValue` is `false`, the search input is
* hidden and the disclaimer acts as the focus target for keyboard navigation.
* When `allowsCustomValue` is `true`, the search input remains visible and the
* disclaimer is shown below it (so a custom value can still be typed and applied).
*/
isLoadingItems?: boolean;
/**
* Label displayed inside the loading disclaimer when `isLoadingItems` is `true`.
* @default "Loading items..."
*/
loadingItemsLabel?: ReactNode;
/** Ref for accessing the search input element */
searchInputRef?: RefObject<HTMLInputElement | null>;
/** Whether to allow entering custom values that are not present in the predefined options */
Expand Down Expand Up @@ -226,6 +268,8 @@ export const FilterListBox = forwardRef(function FilterListBox<
validationState,
isDisabled,
isLoading,
isLoadingItems,
loadingItemsLabel = 'Loading items...',
searchPlaceholder = 'Search...',
autoFocus,
filter,
Expand Down Expand Up @@ -846,13 +890,22 @@ export const FilterListBox = forwardRef(function FilterListBox<
},
});

const showSearchInput = !isLoadingItems || allowsCustomValue;
const showLoadingDisclaimer = !!isLoadingItems;

const mods = useMemo(
() => ({
invalid: isInvalid,
valid: validationState === 'valid',
disabled: !!isDisabled,
focused: isFocused,
loading: !!isLoading,
'loading-items': !!isLoadingItems,
// `searchable` marks this as a FilterListBox context (vs a bare ListBox)
// and tells inner components — notably ListBox — to drop their own
// borders. It must stay true even when the search input is hidden
// (e.g. isLoadingItems && !allowsCustomValue), otherwise ListBox would
// render a redundant inner border.
searchable: true,
prefix: !!isLoading,
...externalMods,
Expand All @@ -863,6 +916,7 @@ export const FilterListBox = forwardRef(function FilterListBox<
isDisabled,
isFocused,
isLoading,
isLoadingItems,
externalMods,
],
);
Expand Down Expand Up @@ -899,13 +953,16 @@ export const FilterListBox = forwardRef(function FilterListBox<
}
};

// Custom option click handler that ensures search input receives focus
// Custom option click handler that ensures the active focus target (search
// input, or the disclaimer when it replaces the search input) receives focus
// so subsequent keyboard navigation keeps working after a mouse click.
const handleOptionClick = (key: Key) => {
// Focus the search input to enable keyboard navigation
// Use setTimeout to ensure this happens after React state updates
setTimeout(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
} else if (disclaimerRef.current) {
disclaimerRef.current.focus();
}
}, 0);

Expand Down Expand Up @@ -955,6 +1012,47 @@ export const FilterListBox = forwardRef(function FilterListBox<
</SearchWrapperElement>
);

// When the search input is hidden (e.g. items are loading and custom values
// are not allowed), the disclaimer takes over the search input's role as the
// focus target so arrow-key navigation over the (possibly partial) list still
// works.
const disclaimerIsFocusable = showLoadingDisclaimer && !showSearchInput;
const disclaimerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (disclaimerIsFocusable && autoFocus) {
disclaimerRef.current?.focus();
}
// Run only when we switch into a focusable-disclaimer state so we don't
// steal focus on unrelated re-renders.
}, [disclaimerIsFocusable]);

const loadingDisclaimer = showLoadingDisclaimer ? (
<LoadingDisclaimerElement
ref={disclaimerRef}
data-size={size}
mods={{ focusable: disclaimerIsFocusable, ...mods }}
{...(disclaimerIsFocusable
? {
tabIndex: 0,
role: 'combobox',
'aria-expanded': 'true',
'aria-haspopup': 'listbox',
'aria-activedescendant':
listStateRef.current?.selectionManager.focusedKey != null
? `ListBoxItem-${listStateRef.current?.selectionManager.focusedKey}`
: undefined,
...keyboardProps,
}
: {})}
>
<LoadingIcon />
<span>{loadingItemsLabel}</span>
</LoadingDisclaimerElement>
) : (
<div role="presentation" />
);

const filterListBoxField = (
<FilterListBoxWrapperElement
ref={ref}
Expand All @@ -970,7 +1068,8 @@ export const FilterListBox = forwardRef(function FilterListBox<
) : (
<div role="presentation" />
)}
{searchInput}
Comment thread
cursor[bot] marked this conversation as resolved.
{showSearchInput ? searchInput : <div role="presentation" />}
{loadingDisclaimer}
<ListBox
ref={listBoxRef}
aria-label={innerAriaLabel}
Expand Down
37 changes: 37 additions & 0 deletions src/components/fields/FilterPicker/FilterPicker.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ A versatile selection component that combines a trigger button with a searchable
- **`selectionMode`** `'single' | 'multiple'` (default: `single`) — Selection mode for the picker
- **`allowsCustomValue`** `boolean` (default: `false`) — Whether to allow entering custom values that are not present in the predefined options
- **`isClearable`** `boolean` (default: `false`) — Whether the filter picker is clearable using a clear button in the rightIcon slot
- **`isLoadingItems`** `boolean` (default: `false`) — Whether the items are currently loading. Unlike `isLoading`, this does NOT disable the trigger, so the popover can still be opened while items are being fetched. Shows a `LoadingIcon` in the trigger and a loading disclaimer inside the popover. When `allowsCustomValue` is `false`, the search input is hidden and only the disclaimer is shown; when `true`, the search input remains visible so users can still type and apply a custom value.
- **`disallowEmptySelection`** `boolean` (default: `false`) — Whether to disallow empty selection
- **`disabledKeys`** `Key[]` — Array of keys for disabled items
- **`items`** `Iterable<T>` — Array of items to render when using the render function pattern for large datasets with dynamic content
Expand Down Expand Up @@ -481,6 +482,42 @@ const categories = [
</FilterPicker>
```

### Loading Items

Use `isLoadingItems` to indicate that items are currently being fetched. Unlike `isLoading`, the trigger stays enabled so users can still open the popover and interact with items that have already loaded.

<Story of={FilterPickerStories.LoadingItemsState} />

```jsx
<FilterPicker
label="Loading Items"
placeholder="Select a fruit..."
isLoadingItems={true}
selectionMode="multiple"
>
<FilterPicker.Item key="apple">Apple</FilterPicker.Item>
<FilterPicker.Item key="banana">Banana</FilterPicker.Item>
</FilterPicker>
```

When `allowsCustomValue` is `true`, the search input remains visible so users can still type and apply a custom value while items are loading. The loading disclaimer is shown below the search input.

<Story of={FilterPickerStories.LoadingItemsWithCustomValue} />

```jsx
<FilterPicker
label="Loading Items (Custom Value)"
placeholder="Type or pick a fruit..."
isLoadingItems={true}
allowsCustomValue={true}
selectionMode="multiple"
searchPlaceholder="Search or type custom value..."
>
<FilterPicker.Item key="apple">Apple</FilterPicker.Item>
<FilterPicker.Item key="banana">Banana</FilterPicker.Item>
</FilterPicker>
```

### Complex Example

<Story of={FilterPickerStories.ComplexExample} />
Expand Down
1 change: 1 addition & 0 deletions src/components/fields/FilterPicker/FilterPicker.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ FilterPicker (forwardRef)
- **Checkbox Mode**: Optional checkboxes for clear multiple selection UX
- **Click Behavior**: Differentiated click handling for checkbox vs content areas
- **Loading States**: Button loading state integration
- **Loading Items State**: `isLoadingItems` leaves the trigger enabled so the popover can be opened while items are being fetched. Shows a loading disclaimer inside the popover; hides the search input when `allowsCustomValue` is `false`, keeps it visible when `true` (so a custom value can still be typed and applied)
- **Validation States**: Visual validation state feedback on trigger

## Component Props Interface
Expand Down
75 changes: 75 additions & 0 deletions src/components/fields/FilterPicker/FilterPicker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,14 @@ const meta: Meta<typeof FilterPicker> = {
defaultValue: { summary: false },
},
},
isLoadingItems: {
control: 'boolean',
description:
'Whether items are loading. Does NOT disable the trigger; shows a loading disclaimer inside the popover. With allowsCustomValue=true the search input stays visible; with allowsCustomValue=false the search input is hidden.',
table: {
defaultValue: { summary: false },
},
},
isRequired: {
control: { type: 'boolean' },
description: 'Whether the field is required',
Expand Down Expand Up @@ -1154,6 +1162,73 @@ export const LoadingState: Story = {
},
};

export const LoadingItemsState: Story = {
args: {
label: 'Loading Items',
placeholder: 'Select a fruit...',
isLoadingItems: true,
selectionMode: 'multiple',
searchPlaceholder: 'Search options...',
width: '30x',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('button');
await userEvent.click(trigger);
},
render: (args) => (
<FilterPicker {...args}>
{fruits.slice(0, 2).map((fruit) => (
<FilterPicker.Item key={fruit.key} textValue={fruit.label}>
{fruit.label}
</FilterPicker.Item>
))}
</FilterPicker>
),
parameters: {
docs: {
description: {
story:
'With `isLoadingItems={true}`, the trigger stays enabled and the popover can be opened. Since `allowsCustomValue` is `false`, the search input is hidden and a loading disclaimer is shown. Already-loaded items remain clickable.',
},
},
},
};

export const LoadingItemsWithCustomValue: Story = {
args: {
label: 'Loading Items (Custom Value)',
placeholder: 'Type or pick a fruit...',
isLoadingItems: true,
allowsCustomValue: true,
selectionMode: 'multiple',
searchPlaceholder: 'Search or type custom value...',
width: '30x',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('button');
await userEvent.click(trigger);
},
render: (args) => (
<FilterPicker {...args}>
{fruits.slice(0, 2).map((fruit) => (
<FilterPicker.Item key={fruit.key} textValue={fruit.label}>
{fruit.label}
</FilterPicker.Item>
))}
</FilterPicker>
),
parameters: {
docs: {
description: {
story:
'With `isLoadingItems={true}` and `allowsCustomValue={true}`, the search input remains visible so users can still type a custom value while items are loading. The loading disclaimer appears below the search input.',
},
},
},
};

export const DisabledState: Story = {
args: {
label: 'Disabled Picker',
Expand Down
Loading
Loading