diff --git a/package.json b/package.json index bc698a30bb2..73e0d04ac3b 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "babel-plugin-react-remove-properties": "^0.3.0", "babel-plugin-transform-glob-import": "^1.0.1", "chalk": "^4.1.2", - "chromatic": "^13.1.3", + "chromatic": "^15.0.0", "clsx": "^2.0.0", "color-space": "^1.16.0", "concurrently": "^6.0.2", diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 5b7db71b366..dab71456292 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -123,7 +123,13 @@ export interface PickerProps ReactNode } interface PickerButtonProps extends PickerStyleProps, ButtonRenderProps {} @@ -227,7 +233,8 @@ const valueStyles = style({ }, truncate: true, display: 'flex', - alignItems: 'center' + alignItems: 'center', + height: '100%' }); const iconStyles = style({ @@ -298,6 +305,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick placeholder = stringFormatter.format('picker.placeholder'), isQuiet, loadingState, + renderValue, onLoadMore, ...pickerProps } = props; @@ -376,6 +384,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick extends PickerStyleProps, Omit, Pick, 'loadingState'> { +interface PickerButtonInnerProps extends PickerStyleProps, Omit, Pick, 'loadingState' | 'renderValue'> { loadingCircle: ReactNode, buttonRef: RefObject } @@ -498,7 +507,8 @@ const PickerButton = createHideableComponent(function PickerButton {(renderProps) => ( <> - :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 + ? 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} ); }} diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index e5685f6b536..fdd5514d098 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -331,3 +331,39 @@ return ( } } }; + + +type ExampleIconItem = IExampleItem & { icon: string }; +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 => ( + + {(item: ExampleIconItem) => ( + + + {item.label} + + )} + +); + +export type CustomRenderValuePickerStoryType = typeof CustomRenderValuePicker; +export const CustomRenderValue: StoryObj = { + render: CustomRenderValuePicker, + args: { + label: 'Pick users', + selectionMode: 'multiple', + items: exampleIconItems, + renderValue: (selectedItems) => ( +
+ {selectedItems.map(item => ( + {item.label} + ))} +
+ ) + } +}; 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(); diff --git a/packages/dev/s2-docs/pages/s2/Picker.mdx b/packages/dev/s2-docs/pages/s2/Picker.mdx index f97556c364d..012c6e136e1 100644 --- a/packages/dev/s2-docs/pages/s2/Picker.mdx +++ b/packages/dev/s2-docs/pages/s2/Picker.mdx @@ -243,6 +243,52 @@ function Example(props) { } ``` +### Custom Render Value + +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 +"use client"; +import {Avatar, Picker, PickerItem, Text} from '@react-spectrum/s2'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +let users = [ + {id: 'abraham-baker', avatar: 'https://www.untitledui.com/images/avatars/abraham-baker', name: 'Abraham Baker', email: 'abraham@example.com'}, + {id: 'adriana-sullivan', avatar: 'https://www.untitledui.com/images/avatars/adriana-sullivan', name: 'Adriana Sullivan', email: 'adriana@example.com'}, + {id: 'jonathan-kelly', avatar: 'https://www.untitledui.com/images/avatars/jonathan-kelly', name: 'Jonathan Kelly', email: 'jonathan@example.com'}, + {id: 'zara-bush', avatar: 'https://www.untitledui.com/images/avatars/zara-bush', name: 'Zara Bush', email: 'zara@example.com'} +]; + +function Example() { + return ( +
+ ( +
+ {selectedItems.map(item => ( + + ))} +
+ )} + ///- end highlight -/// + > + {(item) => + + + {item.name} + {item.email} + + } +
+
+ ); +} +``` + ## 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. diff --git a/yarn.lock b/yarn.lock index bc855cd44a8..eb08c077e2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13197,9 +13197,9 @@ __metadata: languageName: node linkType: hard -"chromatic@npm:^13.1.3": - version: 13.1.3 - resolution: "chromatic@npm:13.1.3" +"chromatic@npm:^15.0.0": + version: 15.1.0 + resolution: "chromatic@npm:15.1.0" peerDependencies: "@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0 @@ -13212,7 +13212,7 @@ __metadata: chroma: dist/bin.js chromatic: dist/bin.js chromatic-cli: dist/bin.js - checksum: 10c0/5fa2d381e06d1b089ecb790247844cfb510b063c4d8f8c0d2a3d0620ff94864003158e34338246bb1d07504d554e73dc8d5b639dc3e176ce3c88816fdc853285 + checksum: 10c0/aea449b3c07e599e9b4c1cd866ffa57a5fc6b158b7c1ae4c462f74133869927d0932a077191011bdb841ab81a2dde54b0a35370736ef1986b6854453f01086de languageName: node linkType: hard @@ -24902,7 +24902,7 @@ __metadata: babel-plugin-react-remove-properties: "npm:^0.3.0" babel-plugin-transform-glob-import: "npm:^1.0.1" chalk: "npm:^4.1.2" - chromatic: "npm:^13.1.3" + chromatic: "npm:^15.0.0" clsx: "npm:^2.0.0" color-space: "npm:^1.16.0" concurrently: "npm:^6.0.2"