Skip to content
Merged
8 changes: 7 additions & 1 deletion packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {AsyncComboBoxStory, AsyncComboBoxStoryType, ContextualHelpExample, CustomWidth, Dynamic, EmptyCombobox, Example, Sections, WithIcons} from '../stories/ComboBox.stories';
import {AsyncComboBoxStory, AsyncComboBoxStoryType, ContextualHelpExample, CustomWidth, Dynamic, EmptyCombobox, Example, Sections, WithAvatars, WithIcons} from '../stories/ComboBox.stories';
import {ComboBox} from '../src';
import {expect} from '@storybook/jest';
import type {Meta, StoryObj} from '@storybook/react';
Expand Down Expand Up @@ -58,6 +58,12 @@ export const Icons: Story = {
play: Static.play
};

export const Avatars: Story = {
...WithAvatars,
name: 'With Avatars',
play: Static.play
};

export const ContextualHelp: Story = {
...ContextualHelpExample,
play: async ({canvasElement}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ const meta: Meta<typeof ComboBox<any>> = {
};

export default meta;
export {Static, WithSections, WithDynamic, Icons, ContextualHelp, WithCustomWidth} from './Combobox.stories';
export {Static, WithSections, WithDynamic, Icons, Avatars, ContextualHelp, WithCustomWidth} from './Combobox.stories';
8 changes: 7 additions & 1 deletion packages/@react-spectrum/s2/chromatic/Picker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {AsyncPickerStory, AsyncPickerStoryType, ContextualHelpExample, CustomWidth, Dynamic, Example, Sections, WithIcons} from '../stories/Picker.stories';
import {AsyncPickerStory, AsyncPickerStoryType, ContextualHelpExample, CustomWidth, Dynamic, Example, Sections, WithAvatars, WithIcons} from '../stories/Picker.stories';
import {expect} from '@storybook/jest';
import type {Meta, StoryObj} from '@storybook/react';
import {Picker} from '../src';
Expand Down Expand Up @@ -56,6 +56,12 @@ export const Icons: Story = {
play: Default.play
};

export const Avatars: Story = {
...WithAvatars,
name: 'With Avatars',
play: Default.play
};

export const WithCustomWidth: Story = {
...CustomWidth,
play: Default.play
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ const meta: Meta<typeof Picker<any>> = {
};

export default meta;
export {Default, WithSections, DynamicExample, Icons, WithCustomWidth, ContextualHelp} from './Picker.stories';
export {Default, WithSections, DynamicExample, Icons, Avatars, WithCustomWidth, ContextualHelp} from './Picker.stories';
5 changes: 4 additions & 1 deletion packages/@react-spectrum/s2/src/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

import {centerBaselineBefore} from './CenterBaseline';
import {ContextValue, SlotProps} from 'react-aria-components';
import {createContext, forwardRef} from 'react';
import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared';
Expand Down Expand Up @@ -37,6 +38,8 @@ export interface AvatarProps extends UnsafeStyles, DOMProps, SlotProps {
}

const imageStyles = style({
display: 'flex',
alignItems: 'center',
borderRadius: 'full',
size: 20,
flexShrink: 0,
Expand Down Expand Up @@ -86,7 +89,7 @@ export const Avatar = forwardRef(function Avatar(props: AvatarProps, ref: DOMRef
width: remSize,
height: remSize
}}
UNSAFE_className={UNSAFE_className}
UNSAFE_className={UNSAFE_className + ' ' + centerBaselineBefore}
styles={imageStyles({isOverBackground, isLarge}, props.styles)}
src={src} />
);
Expand Down
18 changes: 18 additions & 0 deletions packages/@react-spectrum/s2/src/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
Virtualizer
} from 'react-aria-components';
import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared';
import {AvatarContext} from './Avatar';
import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections';
import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'};
import {centerBaseline} from './CenterBaseline';
Expand Down Expand Up @@ -306,6 +307,11 @@ const dividerStyle = style({
width: 'full'
});

const avatar = style({
gridArea: 'icon',
marginEnd: 'text-to-visual'
});

// Not from any design, just following the sizing of the existing rows
export const LOADER_ROW_HEIGHTS = {
S: {
Expand Down Expand Up @@ -365,6 +371,13 @@ export interface ComboBoxItemProps extends Omit<ListBoxItemProps, 'children' | '
children: ReactNode
}

const avatarSize = {
S: 16,
M: 20,
L: 22,
XL: 26
} as const;

const checkmarkIconSize = {
S: 'XS',
M: 'M',
Expand Down Expand Up @@ -394,6 +407,11 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode {
icon: {render: centerBaseline({slot: 'icon', styles: iconCenterWrapper}), styles: icon}
}
}],
[AvatarContext, {
slots: {
avatar: {size: avatarSize[size], styles: avatar}
}
}],
[TextContext, {
slots: {
label: {styles: label({size})},
Expand Down
57 changes: 42 additions & 15 deletions packages/@react-spectrum/s2/src/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
Virtualizer
} from 'react-aria-components';
import {AsyncLoadable, FocusableRef, FocusableRefValue, GlobalDOMAttributes, HelpTextProps, LoadingState, PressEvent, RefObject, SpectrumLabelableProps} from '@react-types/shared';
import {AvatarContext} from './Avatar';
import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'};
import {box, iconStyles as checkboxIconStyles} from './Checkbox';
import {centerBaseline} from './CenterBaseline';
Expand Down Expand Up @@ -241,6 +242,11 @@ const iconStyles = style({
}
});

const avatar = style({
gridArea: 'icon',
marginEnd: 'text-to-visual'
});

const loadingWrapperStyles = style({
gridColumnStart: '1',
gridColumnEnd: '-1',
Expand Down Expand Up @@ -468,6 +474,13 @@ function PickerProgressCircle(props) {
);
}

const avatarSize = {
S: 16,
M: 20,
L: 22,
XL: 26
} as const;

interface PickerButtonInnerProps<T extends object> extends PickerStyleProps, Omit<AriaSelectRenderProps, 'isRequired' | 'isFocused'>, Pick<PickerProps<T>, 'loadingState'> {
loadingCircle: ReactNode,
buttonRef: RefObject<HTMLButtonElement | null>
Expand Down Expand Up @@ -519,7 +532,7 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
})}>
{(renderProps) => (
<>
<SelectValue className={valueStyles({isQuiet}) + ' ' + raw('&> * {display: none;}')}>
<SelectValue className={valueStyles({isQuiet}) + ' ' + raw('&> :not([slot=icon], [slot=avatar], [slot=label]) {display: none;}')}>
{({selectedItems, defaultChildren}) => {
return (
<Provider
Expand All @@ -532,6 +545,14 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
}
}
}],
[AvatarContext, {
slots: {
avatar: {
size: avatarSize[size ?? 'M'],
styles: avatar
}
}
}],
[TextContext, {
slots: {
description: {},
Expand Down Expand Up @@ -606,21 +627,27 @@ export function PickerItem(props: PickerItemProps): ReactNode {
icon: {render: centerBaseline({slot: 'icon', styles: iconCenterWrapper}), styles: icon}
}}}>
<DefaultProvider
context={TextContext}
value={{
slots: {
[DEFAULT_SLOT]: {styles: label({size})},
label: {styles: label({size})},
description: {styles: description({...renderProps, size})}
}
}}>
{renderProps.selectionMode === 'single' && !isLink && <CheckmarkIcon size={checkmarkIconSize[size]} className={checkmark({...renderProps, size})} />}
{renderProps.selectionMode === 'multiple' && !isLink && (
<div className={mergeStyles(checkbox, box(checkboxRenderProps))}>
<CheckmarkIcon size={size} className={checkboxIconStyles} />
</div>
context={AvatarContext}
value={{slots: {
avatar: {size: avatarSize[size], styles: avatar}
}}}>
<DefaultProvider
Comment on lines +630 to +634
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the right approach to add another child DefaultProvider here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it says

// A Context.Provider that only sets a value if not inside SelectValue.
function DefaultProvider({context, value, children}: {context: React.Con...

I haven't spent time thinking about it yet (hopefully can soon), is this what you want?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks that helps, I just wanted to make sure that sandwiching this between the DefaultProviders for IconContext and TextContent is okay, since this is diff than the usual Provider that takes an array of contexts

Copy link
Member

@LFDanLu LFDanLu Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this should be fine since those contexts won't collide since they are different contexts after all. Technically you could use a standard Provider I believe since the styles passed down are the same between the context provided at the SelectValue level and the one at the PickerItem level in this case but its probably safer to use DefaultProvider here so those can diverge if need be

context={TextContext}
value={{
slots: {
[DEFAULT_SLOT]: {styles: label({size})},
label: {styles: label({size})},
description: {styles: description({...renderProps, size})}
}
}}>
{renderProps.selectionMode === 'single' && !isLink && <CheckmarkIcon size={checkmarkIconSize[size]} className={checkmark({...renderProps, size})} />}
{renderProps.selectionMode === 'multiple' && !isLink && (
<div className={mergeStyles(checkbox, box(checkboxRenderProps))}>
<CheckmarkIcon size={size} className={checkboxIconStyles} />
</div>
)}
{typeof children === 'string' ? <Text slot="label">{children}</Text> : children}
{typeof children === 'string' ? <Text slot="label">{children}</Text> : children}
</DefaultProvider>
</DefaultProvider>
</DefaultProvider>
);
Expand Down
29 changes: 28 additions & 1 deletion packages/@react-spectrum/s2/stories/ComboBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {Button, ComboBox, ComboBoxItem, ComboBoxSection, Content, ContextualHelp, Footer, Form, Header, Heading, Link, Text} from '../src';
import {Avatar, Button, ComboBox, ComboBoxItem, ComboBoxSection, Content, ContextualHelp, Footer, Form, Header, Heading, Link, Text} from '../src';
import {categorizeArgTypes, getActionArgs} from './utils';
import {ComboBoxProps} from 'react-aria-components';
import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg';
Expand Down Expand Up @@ -154,6 +154,33 @@ export const WithIcons: Story = {
}
};

const SRC_URL_1 = 'https://i.imgur.com/xIe7Wlb.png';
const SRC_URL_2 = 'https://mir-s3-cdn-cf.behance.net/project_modules/disp/690bc6105945313.5f84bfc9de488.png';

export const WithAvatars: Story = {
render: (args) => (
<ComboBox {...args}>
<ComboBoxItem textValue="User One">
<Avatar slot="avatar" src={SRC_URL_1} />
<Text slot="label">User One</Text>
<Text slot="description">[email protected]</Text>
</ComboBoxItem>
<ComboBoxItem textValue="User Two">
<Avatar slot="avatar" src={SRC_URL_2} />
<Text slot="label">User Two</Text>
<Text slot="description">[email protected]<br />123-456-7890</Text>
</ComboBoxItem>
<ComboBoxItem textValue="User Three">
<Avatar slot="avatar" src={SRC_URL_2} />
<Text slot="label">User Three</Text>
</ComboBoxItem>
</ComboBox>
),
args: {
label: 'Share'
}
};

export const Validation: Story = {
render: (args) => (
<Form>
Expand Down
28 changes: 28 additions & 0 deletions packages/@react-spectrum/s2/stories/Picker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import {
Avatar,
Button,
Content,
ContextualHelp,
Expand Down Expand Up @@ -144,6 +145,33 @@ export const WithIcons: Story = {
}
};

const SRC_URL_1 = 'https://i.imgur.com/xIe7Wlb.png';
const SRC_URL_2 = 'https://mir-s3-cdn-cf.behance.net/project_modules/disp/690bc6105945313.5f84bfc9de488.png';

export const WithAvatars: Story = {
render: (args) => (
<Picker {...args}>
<PickerItem textValue="User One">
<Avatar slot="avatar" src={SRC_URL_1} />
<Text slot="label">User One</Text>
<Text slot="description">[email protected]</Text>
</PickerItem>
<PickerItem textValue="User Two">
<Avatar slot="avatar" src={SRC_URL_2} />
<Text slot="label">User Two</Text>
<Text slot="description">[email protected]<br />123-456-7890</Text>
</PickerItem>
<PickerItem textValue="User Three">
<Avatar slot="avatar" src={SRC_URL_2} />
<Text slot="label">User Three</Text>
</PickerItem>
</Picker>
),
args: {
label: 'Share'
}
};

function VirtualizedPicker(props) {
let items: IExampleItem[] = [];
for (let i = 0; i < 10000; i++) {
Expand Down
26 changes: 25 additions & 1 deletion packages/dev/s2-docs/pages/s2/ComboBox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function Example() {

### Slots

`ComboBoxItem` supports icons, and `label` and `description` text slots.
`ComboBoxItem` supports icons, avatars, and `label` and `description` text slots.

```tsx render
"use client";
Expand Down Expand Up @@ -83,6 +83,30 @@ import UserSettings from '@react-spectrum/s2/icons/UserSettings';
</ComboBox>
```

```tsx render
"use client";
import {Avatar, ComboBox, ComboBoxItem, Text} from '@react-spectrum/s2';

const users = Array.from({length: 10}, (_, i) => ({
id: `user${i + 1}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
avatar: 'https://i.imgur.com/kJOwAdv.png'
}));

<ComboBox label="Share" items={users}>
{(item) => (
<ComboBoxItem id={item.id} textValue={item.name}>
{/*- begin highlight -*/}
<Avatar slot="avatar" src={item.avatar} />
{/*- end highlight -*/}
<Text slot="label">{item.name}</Text>
<Text slot="description">{item.email}</Text>
</ComboBoxItem>
)}
</ComboBox>
```
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this doc example is good enough for the site, or if it's okay to have two tsx render blocks one after another like I did here. Same thoughts for the Picker docs I added

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could be moved up into the previous example, which would then render two comboboxes next to each other, one with icons, one with avatars (since they probably shouldn't be mixed)
It'll be hidden by the "expand" button, but should be obvious with the highlights once expanded

I'm sure we'll have some other opinions so happy to do this ourselves later if we can't come to an immediate consensus


<InlineAlert variant="notice">
<Heading>Accessibility</Heading>
<Content>Interactive elements (e.g. buttons) within picker items are not allowed. This will break keyboard and screen reader navigation. Only add textual or decorative graphics (e.g. icons) as children.</Content>
Expand Down
26 changes: 25 additions & 1 deletion packages/dev/s2-docs/pages/s2/Picker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function Example() {

### Slots

`PickerItem` supports icons, and `label` and `description` text slots.
`PickerItem` supports icons, avatars, and `label` and `description` text slots.

```tsx render
"use client";
Expand Down Expand Up @@ -83,6 +83,30 @@ import UserSettings from '@react-spectrum/s2/icons/UserSettings';
</Picker>
```

```tsx render
"use client";
import {Avatar, Picker, PickerItem, Text} from '@react-spectrum/s2';

const users = Array.from({length: 10}, (_, i) => ({
id: `user${i + 1}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
avatar: 'https://i.imgur.com/kJOwAdv.png'
}));

<Picker label="Share" items={users}>
{(item) => (
<PickerItem id={item.id} textValue={item.name}>
{/*- begin highlight -*/}
<Avatar slot="avatar" src={item.avatar} />
{/*- end highlight -*/}
<Text slot="label">{item.name}</Text>
<Text slot="description">{item.email}</Text>
</PickerItem>
)}
</Picker>
```

<InlineAlert variant="notice">
<Heading>Accessibility</Heading>
<Content>Interactive elements (e.g. buttons) within picker items are not allowed. This will break keyboard and screen reader navigation. Only add textual or decorative graphics (e.g. icons) as children.</Content>
Expand Down