-
Notifications
You must be signed in to change notification settings - Fork 1
feat(SelectNext, TreeSelect): add dependencies prop (DS-5530)
#475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8a216ff
f2007cb
b66e308
f78a02b
4aecac5
b4a5834
c39a4af
c1c42c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -186,6 +186,22 @@ with `defaultInputValue`, and the `onInputChange` callback is called whenever th | |
|
|
||
| <Story of={Stories.Searchable} /> | ||
|
|
||
| ### Dependencies | ||
|
|
||
| The collection caches a rendered item by the identity of its data object, so an item is not | ||
| re-rendered when a value used inside the render function changes. List such values in | ||
| `dependencies` to invalidate that cache — for example the search query when the options | ||
| highlight it. | ||
|
|
||
| Keep the length of the array the same between renders, otherwise the items are not | ||
| re-rendered. To depend on a list of values, wrap it: `dependencies={[filters]}`. | ||
|
|
||
| The same applies to `Select.Section`: it inherits `dependencies` from the Select, so the | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This paragraph has no example behind it
The section variant already exists as a test fixture (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving it as prose: it's a one-line rule. The section code path is covered by unit tests for both inherited and per-section |
||
| prop only has to be set once, on the root. A section written out in JSX can also take its | ||
| own `dependencies`, for values only its items use. | ||
|
|
||
| <Story of={Stories.Dependencies} /> | ||
|
|
||
| ### Minimum options for search | ||
|
|
||
| Short lists don't need a search input. The `minOptionsThreshold` prop shows the search only when | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ import type { Meta, StoryObj } from '@storybook/react'; | |
|
|
||
| import { Button } from '../Button'; | ||
| import { FlexBox } from '../FlexBox'; | ||
| import { Highlight } from '../Highlight'; | ||
| import { useAsyncList, useFilter } from '../index'; | ||
| import { Typography } from '../Typography'; | ||
|
|
||
|
|
@@ -33,7 +34,7 @@ const meta = { | |
| 'Select.ItemAddon': Select.ItemAddon, | ||
| }, | ||
| argTypes: {}, | ||
| tags: ['status:updated', 'date:2026-07-30'], | ||
| tags: ['status:updated', 'date:2026-09-09'], | ||
| } satisfies Meta<typeof Select>; | ||
|
|
||
| export default meta; | ||
|
|
@@ -543,6 +544,30 @@ export const Searchable: Story = { | |
| }, | ||
| }; | ||
|
|
||
| export const Dependencies: Story = { | ||
| render: function Render() { | ||
| const [inputValue, setInputValue] = useState(''); | ||
|
|
||
| return ( | ||
| <Select | ||
| items={options} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Story data belongs inside AGENTS.md, Storybook Stories: "Define story data and helpers inside The Source panel for this story shows Same at
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keeping |
||
| label="Attack type" | ||
| dependencies={[inputValue]} | ||
| onInputChange={setInputValue} | ||
| style={{ inlineSize: 200 }} | ||
| placeholder="Select an option" | ||
| isSearchable | ||
| > | ||
| {(item) => ( | ||
| <Select.Item id={item.id} textValue={item.name}> | ||
| <Highlight text={item.name} query={inputValue} /> | ||
| </Select.Item> | ||
| )} | ||
| </Select> | ||
| ); | ||
| }, | ||
| }; | ||
|
|
||
| export const SearchableMinOptionsThreshold: Story = { | ||
| render: function Render() { | ||
| return ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1204,4 +1204,95 @@ describe('Select', () => { | |
| expect(getRoot()).not.toHaveAttribute('data-disabled', 'true'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('dependencies', () => { | ||
| type Option = { id: string; name: string }; | ||
|
|
||
| const items: Option[] = [{ id: '1', name: 'one' }]; | ||
|
|
||
| const renderWithSuffix = (suffix: string) => ( | ||
| <Select<Option> | ||
| label="label" | ||
| items={items} | ||
| dependencies={[suffix]} | ||
| defaultOpen | ||
| > | ||
| {(item) => ( | ||
| <Select.Item id={item.id}>{`${item.name}-${suffix}`}</Select.Item> | ||
| )} | ||
| </Select> | ||
| ); | ||
|
|
||
| type Group = { id: string; name: string; children: Option[] }; | ||
|
|
||
| const groups: Group[] = [ | ||
| { id: 'group-1', name: 'Group 1', children: items }, | ||
| ]; | ||
|
|
||
| const renderSectionsWithSuffix = (suffix: string) => ( | ||
| <Select<Group> | ||
| label="label" | ||
| items={groups} | ||
| dependencies={[suffix]} | ||
| defaultOpen | ||
| > | ||
| {(group) => ( | ||
| <Select.Section | ||
| id={group.id} | ||
| title={group.name} | ||
| items={group.children} | ||
| > | ||
| {(item) => ( | ||
| <Select.Item id={item.id}>{`${item.name}-${suffix}`}</Select.Item> | ||
| )} | ||
| </Select.Section> | ||
| )} | ||
| </Select> | ||
| ); | ||
|
|
||
| const renderSectionWithDependencies = (suffix: string) => ( | ||
| <Select label="label" defaultOpen> | ||
| <Select.Section | ||
| id="group-1" | ||
| title="Group 1" | ||
| items={items} | ||
| dependencies={[suffix]} | ||
| > | ||
| {(item) => ( | ||
| <Select.Item id={item.id}>{`${item.name}-${suffix}`}</Select.Item> | ||
| )} | ||
| </Select.Section> | ||
| </Select> | ||
| ); | ||
|
|
||
| it('should re-render the options when a dependency changes', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. test-coverage — the sectioned shape, which is the one that fails, is untested. This test covers a flat top-level list only. it('should re-render options inside a section when a dependency changes', () => {
// <Select items={sections} dependencies={[suffix]}> with a
// <Select.Section items={section.children}> render function
});
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in b66e308 — Checked it is a real regression test: with the |
||
| const { rerender } = render(renderWithSuffix('a')); | ||
|
|
||
| expect(getOptions()[0]).toHaveTextContent('one-a'); | ||
|
|
||
| rerender(renderWithSuffix('b')); | ||
|
|
||
| expect(getOptions()[0]).toHaveTextContent('one-b'); | ||
| }); | ||
|
|
||
| it('should re-render the options inside a section when a dependency changes', () => { | ||
| const { rerender } = render(renderSectionsWithSuffix('a')); | ||
|
|
||
| expect(getOptions()[0]).toHaveTextContent('one-a'); | ||
|
|
||
| rerender(renderSectionsWithSuffix('b')); | ||
|
|
||
| expect(getOptions()[0]).toHaveTextContent('one-b'); | ||
| }); | ||
|
|
||
| it('should re-render the options when a dependency of the section changes', () => { | ||
| const { rerender } = render(renderSectionWithDependencies('a')); | ||
|
|
||
| expect(getOptions()[0]).toHaveTextContent('one-a'); | ||
|
|
||
| rerender(renderSectionWithDependencies('b')); | ||
|
|
||
| expect(getOptions()[0]).toHaveTextContent('one-b'); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| 'use client'; | ||
|
|
||
| import type { ForwardedRef } from 'react'; | ||
| import type { ForwardedRef, ReactElement } from 'react'; | ||
| import { useContext } from 'react'; | ||
|
|
||
| import type { | ||
|
|
@@ -13,6 +13,7 @@ import { filterDOMProps, mergeProps } from '@koobiq/react-core'; | |
| import { | ||
| useListBoxSection, | ||
| createBranchComponent, | ||
| Collection, | ||
| SectionNode, | ||
| } from '@koobiq/react-primitives'; | ||
|
|
||
|
|
@@ -27,10 +28,20 @@ export type SelectSectionProps<T> = ExtendableComponentPropsWithRef< | |
| SectionProps<T> & { | ||
| /** The unique id of the item. */ | ||
| id?: Key; | ||
| /** | ||
| * Values the section's items depend on, in addition to the `dependencies` | ||
| * of the Select. Takes effect for a section written out in JSX; for | ||
| * sections rendered from the Select's `items`, list the values on the Select. | ||
| */ | ||
| dependencies?: ReadonlyArray<unknown>; | ||
| }, | ||
| 'section' | ||
| >; | ||
|
|
||
| export type SelectSectionComponent = <T extends object>( | ||
| props: SelectSectionProps<T> | ||
| ) => ReactElement | null; | ||
|
|
||
| function SelectSectionInner<T extends object>( | ||
| props: SelectSectionProps<T>, | ||
| ref: ForwardedRef<HTMLElement>, | ||
|
|
@@ -69,7 +80,19 @@ function SelectSectionInner<T extends object>( | |
| ); | ||
| } | ||
|
|
||
| export const SelectSection = createBranchComponent( | ||
| const SelectSectionRoot = createBranchComponent( | ||
| SectionNode, | ||
| SelectSectionInner | ||
| SelectSectionInner, | ||
| // Render the children through `Collection` rather than the built-in | ||
| // `useCollectionChildren`, so the section inherits `dependencies` from the | ||
| // Select it is rendered in and adds its own on top. | ||
| ({ items, children, dependencies }) => ( | ||
| <Collection items={items} dependencies={dependencies}> | ||
| {children} | ||
| </Collection> | ||
| ) | ||
| ); | ||
|
|
||
| // The type is spelled out: the inferred one leaks an unresolved type parameter | ||
| // into the declaration output, which API Extractor cannot follow. | ||
| export const SelectSection = SelectSectionRoot as SelectSectionComponent; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. displayName is still
Pre-existing, but this change makes it slightly harder to fix later (the cast target has no SelectSectionRoot.displayName = 'Select.Section';
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-existing and out of scope here. No part of SelectNext or TreeSelect sets |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -149,6 +149,14 @@ export type SelectNextProps< | |
| defaultInputValue?: string; | ||
| /** Handler that is called when the Select search input value changes. */ | ||
| onInputChange?: (value: string) => void; | ||
| /** | ||
|
lskramarov marked this conversation as resolved.
|
||
| * Values the rendered items depend on. The collection caches an item by its | ||
| * object identity, so a value used inside the render function — a search | ||
| * query, for instance — has to be listed here for the items to re-render. | ||
| * The array must keep the same length between renders; to depend on a | ||
| * list, wrap it: `[filters]`. | ||
| */ | ||
| dependencies?: ReadonlyArray<unknown>; | ||
|
Comment on lines
+152
to
+159
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Regenerated in b4a5834. One correction:
lskramarov marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This value is spliced straight into React Nothing here, in the identical JSDoc at One extra sentence in the JSDoc (and the MDX) would close it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in c39a4af: one sentence in both JSDoc blocks and both MDX sections. The array must keep the same length between renders, and a list is passed wrapped: |
||
| /** The filter function used to determine if an option should be included in the Select list. */ | ||
| defaultFilter?: (textValue: string, inputValue: string) => boolean; | ||
| /** The props used for each slot inside. */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,9 +25,9 @@ const items: FileNode[] = [ | |
| ]; | ||
|
|
||
| function TreeSelectFixture<M extends SelectionMode = 'single'>( | ||
| props: Partial<TreeSelectProps<FileNode, M>> = {} | ||
| props: Partial<TreeSelectProps<FileNode, M>> & { suffix?: string } = {} | ||
| ) { | ||
| const { slotProps, ...otherProps } = props; | ||
| const { slotProps, suffix = '', ...otherProps } = props; | ||
|
|
||
| return ( | ||
| <Provider> | ||
|
|
@@ -59,7 +59,7 @@ function TreeSelectFixture<M extends SelectionMode = 'single'>( | |
| textValue={item.title} | ||
| data-testid={`item-${item.id}`} | ||
| > | ||
| <Tree.ItemContent>{item.title}</Tree.ItemContent> | ||
| <Tree.ItemContent>{`${item.title}${suffix}`}</Tree.ItemContent> | ||
| <Collection items={item.children}>{renderItem}</Collection> | ||
| </Tree.Item> | ||
| ); | ||
|
|
@@ -70,7 +70,7 @@ function TreeSelectFixture<M extends SelectionMode = 'single'>( | |
| } | ||
|
|
||
| function renderTreeSelect<M extends SelectionMode = 'single'>( | ||
| props: Partial<TreeSelectProps<FileNode, M>> = {} | ||
| props: Partial<TreeSelectProps<FileNode, M>> & { suffix?: string } = {} | ||
| ) { | ||
| return render(<TreeSelectFixture {...props} />); | ||
| } | ||
|
|
@@ -998,4 +998,25 @@ describe('TreeSelect', () => { | |
| expect(screen.getByTestId('item-7')).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('dependencies', () => { | ||
| const propsWithSuffix = (suffix: string) => ({ | ||
| suffix, | ||
| dependencies: [suffix], | ||
| defaultExpandedKeys: [1], | ||
| defaultOpen: true, | ||
| }); | ||
|
|
||
| it('should re-render the items when a dependency changes', () => { | ||
| const { rerender } = renderTreeSelect(propsWithSuffix('-a')); | ||
|
|
||
| expect(screen.getByTestId('item-7')).toHaveTextContent('README.md-a'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. test-coverage — only the root-level leaf is asserted.
I verified the assertion below passes today, so this is a free coverage gain rather than a bug: add expect(screen.getByTestId('item-2')).toHaveTextContent('Http-a');
// …after rerender
expect(screen.getByTestId('item-2')).toHaveTextContent('Http-b');
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in f78a02b — |
||
| expect(screen.getByTestId('item-2')).toHaveTextContent('Http-a'); | ||
|
|
||
| rerender(<TreeSelectFixture {...propsWithSuffix('-b')} />); | ||
|
|
||
| expect(screen.getByTestId('item-7')).toHaveTextContent('README.md-b'); | ||
| expect(screen.getByTestId('item-2')).toHaveTextContent('Http-b'); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -123,6 +123,14 @@ export type TreeSelectProps< | |||||||||||
| defaultInputValue?: string; | ||||||||||||
| /** Handler called when the search query changes. */ | ||||||||||||
| onInputChange?: (value: string) => void; | ||||||||||||
| /** | ||||||||||||
|
lskramarov marked this conversation as resolved.
|
||||||||||||
| * Values the rendered items depend on. The collection caches an item by its | ||||||||||||
| * object identity, so a value used inside the render function — a search | ||||||||||||
| * query, for instance — has to be listed here for the items to re-render. | ||||||||||||
| * The array must keep the same length between renders; to depend on a | ||||||||||||
| * list, wrap it: `[filters]`. | ||||||||||||
| */ | ||||||||||||
| dependencies?: ReadonlyArray<unknown>; | ||||||||||||
|
lskramarov marked this conversation as resolved.
|
||||||||||||
| /** The filter function used to determine whether an item should be included in the search results. */ | ||||||||||||
| defaultFilter?: (textValue: string, inputValue: string) => boolean; | ||||||||||||
| /** The props used for each slot inside. */ | ||||||||||||
|
|
@@ -136,7 +144,8 @@ export type TreeSelectProps< | |||||||||||
| control?: FormFieldSelectProps; | ||||||||||||
| popover?: PopoverProps; | ||||||||||||
| dropdownFooter?: DropdownFooterProps & DataAttributeProps; | ||||||||||||
| tree?: Omit<AriaTreeProps<T>, 'children' | 'items'> & DataAttributeProps; | ||||||||||||
| tree?: Omit<AriaTreeProps<T>, 'children' | 'items' | 'dependencies'> & | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
So
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one doesn't reproduce. |
||||||||||||
| DataAttributeProps; | ||||||||||||
| 'search-input'?: SearchInputProps; | ||||||||||||
| }; | ||||||||||||
| } & Omit<AriaTreeSelectProps<T, M>, 'description' | 'validationState'>; | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The search query is the one dependency the component already owns
Both new docs sections use the search query as the motivating — and only — example. But
SelectInnerholdsinputValueitself viauseControlledState, andTreeSelectInnerdoes the same; the collection is built one level above, inSelectRender/TreeSelectRender, with<Collection {...props} />.So for the canonical case the API asks the consumer to hand back a value the component already has, and anyone who combines
isSearchablewith per-item highlighting and forgetsdependencies={[inputValue]}gets silently stale, unhighlighted options.Worth considering lifting the search value so the root
Collectiongetsdependencies={[...(props.dependencies ?? []), searchValue]}wheneverisSearchableis set. That makes the common case correct by construction and leavesdependenciesfor genuinely external values — which is what the prop is actually for.Not blocking; the prop as specified is still useful either way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keeping it explicit. Filtering doesn't rebuild the collection today. An automatic dependency would re-render every item on each keystroke in every searchable Select, including ones that don't highlight. And a consumer who highlights already holds the query for
Highlight, so it's one extradependencies={[query]}.