From b7e861d816927159a57e7440db2916fcbad42b0a Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 5 Aug 2026 13:24:43 +0300 Subject: [PATCH 1/3] docs(SelectNext): add server-side search example --- .../src/components/SelectNext/Select.mdx | 12 +++ .../components/SelectNext/Select.stories.tsx | 102 ++++++++++++++++++ 2 files changed, 114 insertions(+) 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..47c5b8ec 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,107 @@ export const SearchableMinOptionsThreshold: Story = { }, }; +export const ServerSearch: Story = { + render: function Render() { + type Person = { name: string; isPinned?: 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, + isPinned: true, + })); + + const url = + cursor ?? + `https://swapi.py4e.com/api/people/?search=${encodeURIComponent(filterText ?? '')}`; + + 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, + }; + }, + }); + + const value = list.selectedKeys === 'all' ? [] : [...list.selectedKeys]; + const selectedSet = new Set(value.map(String)); + + const pinnedSet = new Set( + list.items.filter(({ isPinned }) => isPinned).map(({ name }) => name) + ); + + return ( + + ); + }, +}; + export const Open: Story = { render: function Render() { const [isOpen, { toggle, set }] = useBoolean(false); From cf82239d2c3731f054ad9886e195f5a61446fdf6 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 5 Aug 2026 13:45:26 +0300 Subject: [PATCH 2/3] docs(SelectNext): improve server-side search example --- .../src/components/SelectNext/Select.stories.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/components/src/components/SelectNext/Select.stories.tsx b/packages/components/src/components/SelectNext/Select.stories.tsx index 47c5b8ec..59c41875 100644 --- a/packages/components/src/components/SelectNext/Select.stories.tsx +++ b/packages/components/src/components/SelectNext/Select.stories.tsx @@ -574,7 +574,7 @@ export const SearchableMinOptionsThreshold: Story = { export const ServerSearch: Story = { render: function Render() { - type Person = { name: string; isPinned?: boolean }; + type Person = { name: string; isRetained?: boolean }; type PeopleResponse = { next: string | null; results: Person[] }; const savedNames = ['Luke Skywalker']; @@ -592,7 +592,7 @@ export const ServerSearch: Story = { const selectedItems = selectedNames.map((name) => ({ name, - isPinned: true, + isRetained: true, })); const url = @@ -623,8 +623,8 @@ export const ServerSearch: Story = { const value = list.selectedKeys === 'all' ? [] : [...list.selectedKeys]; const selectedSet = new Set(value.map(String)); - const pinnedSet = new Set( - list.items.filter(({ isPinned }) => isPinned).map(({ name }) => name) + const retainedSet = new Set( + list.items.filter(({ isRetained }) => isRetained).map(({ name }) => name) ); return ( @@ -651,10 +651,10 @@ export const ServerSearch: Story = { } }} defaultFilter={(textValue, inputValue) => { - if (pinnedSet.has(textValue)) { - return ( - selectedSet.has(textValue) && contains(textValue, inputValue) - ); + if (retainedSet.has(textValue)) { + return selectedSet.has(textValue) + ? contains(textValue, inputValue) + : inputValue === ''; } return list.loadingState !== 'filtering'; From ad54b84ea085c02f55ab7c3bae2093ef1cfb73f4 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 5 Aug 2026 16:28:52 +0300 Subject: [PATCH 3/3] docs(SelectNext): handle server search errors --- .../components/SelectNext/Select.stories.tsx | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/components/src/components/SelectNext/Select.stories.tsx b/packages/components/src/components/SelectNext/Select.stories.tsx index 59c41875..7d72c24e 100644 --- a/packages/components/src/components/SelectNext/Select.stories.tsx +++ b/packages/components/src/components/SelectNext/Select.stories.tsx @@ -599,30 +599,38 @@ export const ServerSearch: Story = { cursor ?? `https://swapi.py4e.com/api/people/?search=${encodeURIComponent(filterText ?? '')}`; - const response = await fetch(url, { signal }); + try { + const response = await fetch(url, { signal }); - if (!response.ok) { - throw new Error(`Failed to load people: ${response.status}`); - } + if (!response.ok) { + throw new Error(`Failed to load people: ${response.status}`); + } - const data: PeopleResponse = await response.json(); + const data: PeopleResponse = await response.json(); - const results = data.results.filter( - ({ name }) => !selectedSet.has(name) - ); + const results = data.results.filter( + ({ name }) => !selectedSet.has(name) + ); - setHasMore(data.next != null); + setHasMore(data.next != null); - return { - items: cursor == null ? [...selectedItems, ...results] : results, - cursor: data.next ?? undefined, - }; + 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) ); @@ -659,6 +667,7 @@ export const ServerSearch: Story = { return list.loadingState !== 'filtering'; }} + noItemsText={list.error ? 'Failed to load characters' : undefined} isLoading={list.isLoading || hasMore} onLoadMore={list.loadMore} selectionMode="multiple"