From 70879069c255bb87dfec4ec7dd869b272fefda54 Mon Sep 17 00:00:00 2001 From: Alastair Choo Date: Wed, 28 Jan 2026 16:06:29 +0000 Subject: [PATCH 1/8] feat: add types for renderValue prop --- packages/@react-spectrum/s2/src/Picker.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 766d4be7882..6bf3ab347a4 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -30,6 +30,7 @@ import { Provider, SectionProps, SelectValue, + SelectValueProps, Virtualizer } from 'react-aria-components'; import {AsyncLoadable, FocusableRef, FocusableRefValue, GlobalDOMAttributes, HelpTextProps, LoadingState, PressEvent, RefObject, SpectrumLabelableProps} from '@react-types/shared'; @@ -123,7 +124,12 @@ export interface PickerProps['children'] } interface PickerButtonProps extends PickerStyleProps, ButtonRenderProps {} @@ -483,7 +489,7 @@ const avatarSize = { XL: 26 } as const; -interface PickerButtonInnerProps extends PickerStyleProps, Omit, Pick, 'loadingState'> { +interface PickerButtonInnerProps extends PickerStyleProps, Omit, Pick, 'loadingState' | 'renderValue'> { loadingCircle: ReactNode, buttonRef: RefObject } From 6425a6f4832e3b7c4aa0857a1dbb0f30d5243323 Mon Sep 17 00:00:00 2001 From: Alastair Choo Date: Thu, 29 Jan 2026 15:55:25 +0000 Subject: [PATCH 2/8] initial impl of renderValue --- packages/@react-spectrum/s2/src/Picker.tsx | 26 ++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 6bf3ab347a4..c9f8970a328 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -30,7 +30,6 @@ import { Provider, SectionProps, SelectValue, - SelectValueProps, Virtualizer } from 'react-aria-components'; import {AsyncLoadable, FocusableRef, FocusableRefValue, GlobalDOMAttributes, HelpTextProps, LoadingState, PressEvent, RefObject, SpectrumLabelableProps} from '@react-types/shared'; @@ -126,10 +125,11 @@ export interface PickerProps ReactNode } interface PickerButtonProps extends PickerStyleProps, ButtonRenderProps {} @@ -304,6 +304,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick placeholder = stringFormatter.format('picker.placeholder'), isQuiet, loadingState, + renderValue, onLoadMore, ...pickerProps } = props; @@ -383,6 +384,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick :not([slot=icon], [slot=avatar], [slot=label], [data-slot=label]) {display: none;}')}> {({selectedItems, defaultChildren}) => { + const selectedValues = selectedItems.filter((item): item is T => item != null); + const defaultRenderedValue = selectedItems.length <= 1 + ? defaultChildren + : {stringFormatter.format('picker.selectedCount', {count: selectedItems.length})}; + const renderedValue = selectedItems.length > 0 && renderValue + ? renderValue(selectedValues) + : defaultRenderedValue; + return ( - {selectedItems.length <= 1 - ? defaultChildren - : {stringFormatter.format('picker.selectedCount', {count: selectedItems.length})} - } + {renderedValue} ); }} From eabc6251e13f9e95531415c79ea717f828650747 Mon Sep 17 00:00:00 2001 From: Alastair Choo Date: Mon, 2 Feb 2026 10:10:26 +0000 Subject: [PATCH 3/8] remove css restriction on non-slot elements --- packages/@react-spectrum/s2/src/Picker.tsx | 9 ++++- .../s2/stories/Picker.stories.tsx | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index c9f8970a328..a00db460637 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -127,7 +127,7 @@ export interface PickerProps :not([slot=icon], [slot=avatar], [slot=label], [data-slot=label]) {display: none;}')}> + :not([slot=icon], [slot=avatar], [slot=label], [data-slot=label]) {display: none;}')) + }> {({selectedItems, defaultChildren}) => { const selectedValues = selectedItems.filter((item): item is T => item != null); const defaultRenderedValue = selectedItems.length <= 1 diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index e5685f6b536..c8fe60b41d3 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -331,3 +331,40 @@ return ( } } }; + + +type ExampleIconItem = IExampleItem & { icon: string }; +const exampleIconItems: ExampleIconItem[] = [ + {id: 'chocolate', label: 'Chocolate', icon: SRC_URL_1}, + {id: 'strawberry', label: 'Strawberry', icon: SRC_URL_2}, + {id: 'vanilla', label: 'Vanilla', icon: SRC_URL_1}, + {id: 'mint', label: 'Mint', icon: SRC_URL_2}, + {id: 'cookie-dough', label: 'Chocolate Chip Cookie Dough', icon: SRC_URL_1} +]; + +const CustomRenderValuePicker = (args: PickerProps): ReactElement => ( + + {(item: ExampleIconItem) => ( + + + {item.label} + + )} + +); + +export type CustomRenderValuePickerStoryType = typeof CustomRenderValuePicker; +export const CustomRenderValue: StoryObj = { + render: CustomRenderValuePicker, + args: { + selectionMode: 'multiple', + items: exampleIconItems, + renderValue: (selectedItems) => ( +
+ {selectedItems.map(item => ( + + ))} +
+ ) + } +}; From d5c4f3c1bc714120be1b7e4b024f62cb3606b865 Mon Sep 17 00:00:00 2001 From: Alastair Choo Date: Mon, 2 Feb 2026 18:15:56 +0000 Subject: [PATCH 4/8] clamp picker value height --- packages/@react-spectrum/s2/src/Picker.tsx | 10 +++++----- packages/@react-spectrum/s2/stories/Picker.stories.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index a00db460637..d9559968d78 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -233,7 +233,8 @@ const valueStyles = style({ }, truncate: true, display: 'flex', - alignItems: 'center' + alignItems: 'center', + height: '100%' }); const iconStyles = style({ @@ -543,15 +544,14 @@ const PickerButton = createHideableComponent(function PickerButton :not([slot=icon], [slot=avatar], [slot=label], [data-slot=label]) {display: none;}')) }> {({selectedItems, defaultChildren}) => { const selectedValues = selectedItems.filter((item): item is T => item != null); - const defaultRenderedValue = selectedItems.length <= 1 + const defaultRenderedValue = selectedValues.length <= 1 ? defaultChildren - : {stringFormatter.format('picker.selectedCount', {count: selectedItems.length})}; - const renderedValue = selectedItems.length > 0 && renderValue + : {stringFormatter.format('picker.selectedCount', {count: selectedValues.length})}; + const renderedValue = selectedValues.length > 0 && renderValue ? renderValue(selectedValues) : defaultRenderedValue; diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index c8fe60b41d3..00518b35c11 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -360,9 +360,9 @@ export const CustomRenderValue: StoryObj = { selectionMode: 'multiple', items: exampleIconItems, renderValue: (selectedItems) => ( -
+
{selectedItems.map(item => ( - + {item.label} ))}
) From 195d5d9ebbce3215fb2be9ae642c94916b9feb53 Mon Sep 17 00:00:00 2001 From: Alastair Choo Date: Wed, 4 Feb 2026 11:59:05 +0000 Subject: [PATCH 5/8] test: renderValue displays selected items correctly --- .../@react-spectrum/s2/test/Picker.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/@react-spectrum/s2/test/Picker.test.tsx b/packages/@react-spectrum/s2/test/Picker.test.tsx index cc6769f7475..61cd74fa021 100644 --- a/packages/@react-spectrum/s2/test/Picker.test.tsx +++ b/packages/@react-spectrum/s2/test/Picker.test.tsx @@ -129,6 +129,42 @@ describe('Picker', () => { } }); + it('should support custom renderValue output', async () => { + let items = [ + {id: 'chocolate', name: 'Chocolate'}, + {id: 'strawberry', name: 'Strawberry'}, + {id: 'vanilla', name: 'Vanilla'} + ]; + let renderValue = jest.fn((selectedItems) => ( + + {selectedItems.map((item) => item.name).join(', ')} + + )); + let tree = render( + + {(item: any) => {item.name}} + + ); + + // expect the placeholder to be rendered when no items are selected + expect(tree.queryByTestId('custom-value')).toBeNull(); + + let selectTester = testUtilUser.createTester('Select', {root: tree.container, interactionType: 'mouse'}); + await selectTester.open(); + await selectTester.selectOption({option: 0}); + await selectTester.selectOption({option: 2}); + await selectTester.close(); + + // check that the clicked items are rendered in the custom renderValue output + let lastSelectedItems = renderValue.mock.calls[renderValue.mock.calls.length - 1][0]; + expect(lastSelectedItems.map((item) => item.name)).toEqual(['Chocolate', 'Vanilla']); + expect(tree.getByTestId('custom-value')).toHaveTextContent('Chocolate, Vanilla'); + }); + it('should support contextual help', async () => { // Issue with how we don't render the contextual help button in the fake DOM since PressResponder isn't using createHideableComponent let warn = jest.spyOn(global.console, 'warn').mockImplementation(); From 8324d8105c81b1d5cf36ee537292afef6b5df21e Mon Sep 17 00:00:00 2001 From: Alastair Choo Date: Wed, 4 Feb 2026 12:54:46 +0000 Subject: [PATCH 6/8] docs: document renderValue usage --- packages/@react-spectrum/s2/src/Picker.tsx | 2 +- packages/dev/s2-docs/pages/s2/Picker.mdx | 37 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index d9559968d78..30e54d96222 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -125,7 +125,7 @@ export interface PickerProps + + Koala + Kangaroo + Platypus + Bald Eagle + Bison + Skunk + +
Current selection: {JSON.stringify(animal)}
+
+ ); +} +``` + ## Forms Use the `name` prop to submit the `id` of the selected item to the server. Set the `isRequired` prop to validate that the user selects an option, or implement custom client or server-side validation. See the [Forms](forms) guide to learn more. From 061e14ac9f1dde70b95983ce73d01e5bb9a27c80 Mon Sep 17 00:00:00 2001 From: Alastair Choo Date: Fri, 6 Feb 2026 18:50:37 +0000 Subject: [PATCH 7/8] docs: improve docs example --- .../s2/stories/Picker.stories.tsx | 13 +++---- packages/dev/s2-docs/pages/s2/Picker.mdx | 37 ++++++++++++------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index 00518b35c11..6e385b40c20 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -334,13 +334,11 @@ return ( type ExampleIconItem = IExampleItem & { icon: string }; -const exampleIconItems: ExampleIconItem[] = [ - {id: 'chocolate', label: 'Chocolate', icon: SRC_URL_1}, - {id: 'strawberry', label: 'Strawberry', icon: SRC_URL_2}, - {id: 'vanilla', label: 'Vanilla', icon: SRC_URL_1}, - {id: 'mint', label: 'Mint', icon: SRC_URL_2}, - {id: 'cookie-dough', label: 'Chocolate Chip Cookie Dough', icon: SRC_URL_1} -]; +const exampleIconItems: ExampleIconItem[] = Array.from({length: 5}, (_, i) => ({ + id: `user${i + 1}`, + label: `User ${i + 1}`, + icon: 'https://mir-s3-cdn-cf.behance.net/project_modules/disp/690bc6105945313.5f84bfc9de488.png', +})); const CustomRenderValuePicker = (args: PickerProps): ReactElement => ( @@ -357,6 +355,7 @@ export type CustomRenderValuePickerStoryType = typeof CustomRenderValuePicker; export const CustomRenderValue: StoryObj = { render: CustomRenderValuePicker, args: { + label: 'Pick users', selectionMode: 'multiple', items: exampleIconItems, renderValue: (selectedItems) => ( diff --git a/packages/dev/s2-docs/pages/s2/Picker.mdx b/packages/dev/s2-docs/pages/s2/Picker.mdx index 0777c25c2ba..25e65a4bd5b 100644 --- a/packages/dev/s2-docs/pages/s2/Picker.mdx +++ b/packages/dev/s2-docs/pages/s2/Picker.mdx @@ -246,34 +246,45 @@ function Example(props) { Use the `renderValue` prop to provide a custom element to display selected items. The callback is given an array of the selected user-defined objects. -```tsx render docs={docs.exports.Picker} links={docs.links} props={['selectionMode']} wide +```tsx render "use client"; -import {Picker, PickerItem} from '@react-spectrum/s2'; +import {Avatar, Picker, PickerItem, Text} from '@react-spectrum/s2'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useState} from 'react'; function Example(props) { - let [animal, setAnimal] = useState("bison"); + const users = Array.from({length: 5}, (_, i) => ({ + id: `user${i + 1}`, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + avatar: 'https://mir-s3-cdn-cf.behance.net/project_modules/disp/690bc6105945313.5f84bfc9de488.png', + })); return (
( +
+ {selectedItems.map(item => ( + + ))} +
+ )} ///- end highlight -/// > - Koala - Kangaroo - Platypus - Bald Eagle - Bison - Skunk + {(item) => + + + {item.name} + {item.email} + + }
-
Current selection: {JSON.stringify(animal)}
); } From 8b90e2b7a4738b047129b8a93bd342577e093627 Mon Sep 17 00:00:00 2001 From: Alastair Choo Date: Fri, 6 Feb 2026 18:51:54 +0000 Subject: [PATCH 8/8] fix render without picker item --- packages/@react-spectrum/s2/src/Picker.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 30e54d96222..1e7ff28afe2 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -548,10 +548,10 @@ const PickerButton = createHideableComponent(function PickerButton {({selectedItems, defaultChildren}) => { const selectedValues = selectedItems.filter((item): item is T => item != null); - const defaultRenderedValue = selectedValues.length <= 1 + const defaultRenderedValue = selectedItems.length <= 1 ? defaultChildren - : {stringFormatter.format('picker.selectedCount', {count: selectedValues.length})}; - const renderedValue = selectedValues.length > 0 && renderValue + : {stringFormatter.format('picker.selectedCount', {count: selectedItems.length})}; + const renderedValue = selectedItems.length > 0 && renderValue ? renderValue(selectedValues) : defaultRenderedValue;