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
1 change: 1 addition & 0 deletions app/baklava.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export { DateTimeInput } from '../src/components/forms/controls/datetime/DateTim
export { ListBox } from '../src/components/forms/controls/ListBox/ListBox.tsx';
export { ListBoxLazy } from '../src/components/forms/controls/ListBoxLazy/ListBoxLazy.tsx';
export { ListBoxMulti } from '../src/components/forms/controls/ListBoxMulti/ListBoxMulti.tsx';
export { ListBoxMultiLazy } from '../src/components/forms/controls/ListBoxMultiLazy/ListBoxMultiLazy.tsx';
export { ComboBox } from '../src/components/forms/controls/ComboBox/ComboBox.tsx';
export { Select } from '../src/components/forms/controls/Select/Select.tsx';
export { SelectMulti } from '../src/components/forms/controls/SelectMulti/SelectMulti.tsx';
Expand Down
28 changes: 20 additions & 8 deletions src/components/forms/controls/ListBox/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export type OptionProps = ComponentProps<typeof Button> & {
* A list box item that can be selected.
*/
export const Option = (props: OptionProps) => {
const { unstyled, itemKey, label, icon, iconDecoration, onSelect, Icon = BkIcon, ...propsRest } = props;
const { ref, unstyled, itemKey, label, icon, iconDecoration, onSelect, Icon = BkIcon, ...propsRest } = props;

const itemRef = React.useRef<React.ComponentRef<typeof Button>>(null);
const itemDef = React.useMemo<ItemWithKey>(() => ({ itemKey, itemRef, isContentItem: true }), [itemKey]);
Expand All @@ -118,7 +118,7 @@ export const Option = (props: OptionProps) => {
<Button
unstyled
id={id}
ref={itemRef}
ref={mergeRefs(ref, itemRef)}
role="option"
tabIndex={isFocused ? 0 : -1}
data-item-key={itemKey}
Expand Down Expand Up @@ -345,7 +345,7 @@ const HiddenSelectedState = ({ ref, name, form, inputProps }: HiddenSelectedStat
);
};

const EmptyPlaceholder = (props: React.ComponentProps<'div'>) => {
export const EmptyPlaceholder = (props: React.ComponentProps<'div'>) => {
return (
<div
{...props}
Expand All @@ -360,6 +360,22 @@ const EmptyPlaceholder = (props: React.ComponentProps<'div'>) => {
);
};

export const LoadingSpinner = (props: React.ComponentProps<'span'>) => {
return (
<span
{...props}
className={cx(
cl['bk-list-box__item'],
cl['bk-list-box__item--static'],
cl['bk-list-box__item--loading'],
props.className,
)}
>
Loading... <Spinner inline size="small"/>
</span>
);
};

/**
* A list box is a composite control, consisting of a (flat) list of items. Each item can be either an option that can
* be selected, or an action that can be activated. The items list may be partial, in case of virtualization (see
Expand Down Expand Up @@ -524,11 +540,7 @@ export const ListBox = Object.assign(
}

{isLoading &&
<span
className={cx(cl['bk-list-box__item'], cl['bk-list-box__item--static'], cl['bk-list-box__item--loading'])}
>
Loading... <Spinner inline size="small"/>
</span>
<LoadingSpinner id={`${id}_loading-spinner`}/>
}
</div>
</listBox.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
inset-inline-start: 0;
inline-size: 100%;
}

// Add a separator below the virtual list when it has items and is not the last child.
&:not(:empty, :last-child) + .bk-list-box-lazy__item {
border-block-start: bk.$size-1 solid bk.$theme-dropdown-menu-menu-border-default;
}
}
}
}
60 changes: 60 additions & 0 deletions src/components/forms/controls/ListBoxLazy/ListBoxLazy.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { generateData } from '../../../tables/util/generateData.ts'; // FIXME: move to a common location

import { InputSearch } from '../Input/InputSearch.tsx';
import { Button } from '../../../actions/Button/Button.tsx';

import { type ItemKey, type VirtualItemKeys } from '../ListBox/ListBoxStore.tsx';
import { ListBoxLazy } from './ListBoxLazy.tsx';
Expand Down Expand Up @@ -182,3 +183,62 @@ const ListBoxLazyWithFilterC = (props: ListBoxLazyArgs) => {
export const ListBoxLazyWithFilter: Story = {
render: args => <ListBoxLazyWithFilterC {...args}/>,
};

const ListBoxLazyWithCustomLoadMoreItemsTriggerC = (props: ListBoxLazyArgs) => {
const pageSize = 20;
const maxItems = 90;

const [isLoading, setIsLoading] = React.useState(false);
const [limit, setLimit] = React.useState(pageSize);
const [items, setItems] = React.useState<Array<{ id: string, name: string }>>([]);

const hasMoreItems = items.length < maxItems;

const updateLimit = React.useCallback(async () => {
if (hasMoreItems) {
setLimit(Math.min(limit + pageSize, maxItems));
setIsLoading(true); // Immediately set `isLoading` so we can skip a render cycle (before the effect kicks in)
}
}, [limit, hasMoreItems]);

React.useEffect(() => {
setIsLoading(false);

if (hasMoreItems) {
setIsLoading(true);
window.setTimeout(() => {
setIsLoading(false);
setItems(generateData({ numItems: limit }));
}, 2000);
}
}, [limit, hasMoreItems]);

const virtualItemKeys = items.map(item => item.id);

const renderLoadMoreItemsTrigger = () => {
if (limit === maxItems) { return null; }

return (
<Button kind="primary" onPress={updateLimit}>Load more items</Button>
);
};

return (
<ListBoxLazy
{...props}
limit={limit}
virtualItemKeys={virtualItemKeys}
isLoading={isLoading}
renderItem={item => <div>Item {item.index + 1}</div>}
renderItemLabel={item => `Item ${item.index + 1}`}
loadMoreItemsTriggerType="custom"
loadMoreItemsTrigger={renderLoadMoreItemsTrigger()}
/>
);
};
export const ListBoxLazyWithCustomLoadMoreItemsTrigger: Story = {
render: args => <ListBoxLazyWithCustomLoadMoreItemsTriggerC {...args}/>,
args: {
},
};

Loading