diff --git a/.changeset/filterpicker-loading-items.md b/.changeset/filterpicker-loading-items.md new file mode 100644 index 000000000..f65f6943d --- /dev/null +++ b/.changeset/filterpicker-loading-items.md @@ -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..."`). diff --git a/src/components/fields/FilterListBox/FilterListBox.docs.mdx b/src/components/fields/FilterListBox/FilterListBox.docs.mdx index 41994c9bd..b37045ac2 100644 --- a/src/components/fields/FilterListBox/FilterListBox.docs.mdx +++ b/src/components/fields/FilterListBox/FilterListBox.docs.mdx @@ -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` — 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` — Props to apply to existing custom values (already selected but not in predefined options) - **`newCustomValueProps`** `Partial` — Props to apply to new custom values appearing in search results (merged with `customValueProps`) @@ -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 diff --git a/src/components/fields/FilterListBox/FilterListBox.spec.md b/src/components/fields/FilterListBox/FilterListBox.spec.md index 89f75192c..0a2af5d58 100644 --- a/src/components/fields/FilterListBox/FilterListBox.spec.md +++ b/src/components/fields/FilterListBox/FilterListBox.spec.md @@ -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 @@ -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 diff --git a/src/components/fields/FilterListBox/FilterListBox.tsx b/src/components/fields/FilterListBox/FilterListBox.tsx index f6af5a4a7..768ff417e 100644 --- a/src/components/fields/FilterListBox/FilterListBox.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.tsx @@ -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, @@ -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 extends Omit, 'filter'>, FieldBaseProps { @@ -123,6 +152,19 @@ export interface CubeFilterListBoxProps 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; /** Whether to allow entering custom values that are not present in the predefined options */ @@ -226,6 +268,8 @@ export const FilterListBox = forwardRef(function FilterListBox< validationState, isDisabled, isLoading, + isLoadingItems, + loadingItemsLabel = 'Loading items...', searchPlaceholder = 'Search...', autoFocus, filter, @@ -846,6 +890,9 @@ export const FilterListBox = forwardRef(function FilterListBox< }, }); + const showSearchInput = !isLoadingItems || allowsCustomValue; + const showLoadingDisclaimer = !!isLoadingItems; + const mods = useMemo( () => ({ invalid: isInvalid, @@ -853,6 +900,12 @@ export const FilterListBox = forwardRef(function FilterListBox< 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, @@ -863,6 +916,7 @@ export const FilterListBox = forwardRef(function FilterListBox< isDisabled, isFocused, isLoading, + isLoadingItems, externalMods, ], ); @@ -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); @@ -955,6 +1012,47 @@ export const FilterListBox = forwardRef(function FilterListBox< ); + // 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(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 ? ( + + + {loadingItemsLabel} + + ) : ( +
+ ); + const filterListBoxField = ( )} - {searchInput} + {showSearchInput ? searchInput :
} + {loadingDisclaimer}