diff --git a/packages/components/src/components/SelectNext/Select.mdx b/packages/components/src/components/SelectNext/Select.mdx index 6b73f606..1ef87b52 100644 --- a/packages/components/src/components/SelectNext/Select.mdx +++ b/packages/components/src/components/SelectNext/Select.mdx @@ -194,6 +194,18 @@ so the search is always shown. +### Server search + +This example shows how to implement server search in SelectNext with `useAsyncList`. It manages the +loading state, provides an `AbortSignal` for cancelling outdated requests, and supports pagination +through `loadMore`. + +Selected options are kept between requests and pinned at the top of the list. During search, the +pinned options are filtered locally, while the rest of the options are displayed exactly as +returned by the server. + + + ### Open #### Default open diff --git a/packages/components/src/components/SelectNext/Select.stories.tsx b/packages/components/src/components/SelectNext/Select.stories.tsx index 94f922f4..7d72c24e 100644 --- a/packages/components/src/components/SelectNext/Select.stories.tsx +++ b/packages/components/src/components/SelectNext/Select.stories.tsx @@ -13,6 +13,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../Button'; import { FlexBox } from '../FlexBox'; +import { useAsyncList, useFilter } from '../index'; import { Typography } from '../Typography'; import type { SelectNextProps as SelectProps } from './index.js'; @@ -571,6 +572,116 @@ export const SearchableMinOptionsThreshold: Story = { }, }; +export const ServerSearch: Story = { + render: function Render() { + type Person = { name: string; isRetained?: boolean }; + type PeopleResponse = { next: string | null; results: Person[] }; + + const savedNames = ['Luke Skywalker']; + const [hasMore, setHasMore] = useState(true); + const { contains } = useFilter({ sensitivity: 'base' }); + + const list = useAsyncList({ + getKey: ({ name }) => name, + initialSelectedKeys: savedNames, + async load({ signal, filterText, cursor, selectedKeys }) { + const selectedNames = + selectedKeys === 'all' ? [] : [...selectedKeys].map(String); + + const selectedSet = new Set(selectedNames); + + const selectedItems = selectedNames.map((name) => ({ + name, + isRetained: true, + })); + + const url = + cursor ?? + `https://swapi.py4e.com/api/people/?search=${encodeURIComponent(filterText ?? '')}`; + + try { + const response = await fetch(url, { signal }); + + if (!response.ok) { + throw new Error(`Failed to load people: ${response.status}`); + } + + const data: PeopleResponse = await response.json(); + + const results = data.results.filter( + ({ name }) => !selectedSet.has(name) + ); + + setHasMore(data.next != null); + + return { + items: cursor == null ? [...selectedItems, ...results] : results, + cursor: data.next ?? undefined, + }; + } catch (error) { + if (!signal.aborted) setHasMore(false); + + throw error; + } + }, + }); + + const value = list.selectedKeys === 'all' ? [] : [...list.selectedKeys]; + const selectedSet = new Set(value.map(String)); + + // Retained items are client-side copies, not the selection source of truth. + // A deselected copy may remain until the next server response replaces it. + const retainedSet = new Set( + list.items.filter(({ isRetained }) => isRetained).map(({ name }) => name) + ); + + return ( + + ); + }, +}; + export const Open: Story = { render: function Render() { const [isOpen, { toggle, set }] = useBoolean(false);