Skip to content

Commit 8a49fc9

Browse files
dkarioDylan KarioLFDanLudevongovettyihuiliao
authored
feat: add Avatar support for ComboBoxItem and PickerItem (#8931)
* feat: add Avatar support for ComboBoxItem and PickerItem For the new `avatar` style macro, I copied a subset of ComboBox's `image` style macro. Avatar controls size via its `size` prop, so I didn't include that in the style macro. * feat: avatar support for Picker button, render for AvatarContextValue * Add center baseline to Avatar by default * Simplify s2-docs for ComboBox and Picker with Avatars * Add defaultValue and placeholder to new ComboBox/Picker docs --------- Co-authored-by: Dylan Kario <[email protected]> Co-authored-by: Daniel Lu <[email protected]> Co-authored-by: Devon Govett <[email protected]> Co-authored-by: Yihui Liao <[email protected]>
1 parent 3e2e190 commit 8a49fc9

File tree

11 files changed

+221
-63
lines changed

11 files changed

+221
-63
lines changed

packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

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

61+
export const Avatars: Story = {
62+
...WithAvatars,
63+
name: 'With Avatars',
64+
play: Static.play
65+
};
66+
6167
export const ContextualHelp: Story = {
6268
...ContextualHelpExample,
6369
play: async ({canvasElement}) => {

packages/@react-spectrum/s2/chromatic/ComboboxRTL.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ const meta: Meta<typeof ComboBox<any>> = {
2323
};
2424

2525
export default meta;
26-
export {Static, WithSections, WithDynamic, Icons, ContextualHelp, WithCustomWidth} from './Combobox.stories';
26+
export {Static, WithSections, WithDynamic, Icons, Avatars, ContextualHelp, WithCustomWidth} from './Combobox.stories';

packages/@react-spectrum/s2/chromatic/Picker.stories.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

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

59+
export const Avatars: Story = {
60+
...WithAvatars,
61+
name: 'With Avatars',
62+
play: Default.play
63+
};
64+
5965
export const WithCustomWidth: Story = {
6066
...CustomWidth,
6167
play: Default.play

packages/@react-spectrum/s2/chromatic/PickerRTL.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ const meta: Meta<typeof Picker<any>> = {
2222
};
2323

2424
export default meta;
25-
export {Default, WithSections, DynamicExample, Icons, WithCustomWidth, ContextualHelp} from './Picker.stories';
25+
export {Default, WithSections, DynamicExample, Icons, Avatars, WithCustomWidth, ContextualHelp} from './Picker.stories';

packages/@react-spectrum/s2/src/Avatar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {centerBaselineBefore} from './CenterBaseline';
1314
import {ContextValue, SlotProps} from 'react-aria-components';
1415
import {createContext, forwardRef} from 'react';
1516
import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared';
@@ -37,6 +38,8 @@ export interface AvatarProps extends UnsafeStyles, DOMProps, SlotProps {
3738
}
3839

3940
const imageStyles = style({
41+
display: 'flex',
42+
alignItems: 'center',
4043
borderRadius: 'full',
4144
size: 20,
4245
flexShrink: 0,
@@ -86,7 +89,7 @@ export const Avatar = forwardRef(function Avatar(props: AvatarProps, ref: DOMRef
8689
width: remSize,
8790
height: remSize
8891
}}
89-
UNSAFE_className={UNSAFE_className}
92+
UNSAFE_className={UNSAFE_className + ' ' + centerBaselineBefore}
9093
styles={imageStyles({isOverBackground, isLarge}, props.styles)}
9194
src={src} />
9295
);

packages/@react-spectrum/s2/src/ComboBox.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
Virtualizer
3535
} from 'react-aria-components';
3636
import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared';
37+
import {AvatarContext} from './Avatar';
3738
import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections';
3839
import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'};
3940
import {centerBaseline} from './CenterBaseline';
@@ -306,6 +307,11 @@ const dividerStyle = style({
306307
width: 'full'
307308
});
308309

310+
const avatar = style({
311+
gridArea: 'icon',
312+
marginEnd: 'text-to-visual'
313+
});
314+
309315
// Not from any design, just following the sizing of the existing rows
310316
export const LOADER_ROW_HEIGHTS = {
311317
S: {
@@ -365,6 +371,13 @@ export interface ComboBoxItemProps extends Omit<ListBoxItemProps, 'children' | '
365371
children: ReactNode
366372
}
367373

374+
const avatarSize = {
375+
S: 16,
376+
M: 20,
377+
L: 22,
378+
XL: 26
379+
} as const;
380+
368381
const checkmarkIconSize = {
369382
S: 'XS',
370383
M: 'M',
@@ -394,6 +407,11 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode {
394407
icon: {render: centerBaseline({slot: 'icon', styles: iconCenterWrapper}), styles: icon}
395408
}
396409
}],
410+
[AvatarContext, {
411+
slots: {
412+
avatar: {size: avatarSize[size], styles: avatar}
413+
}
414+
}],
397415
[TextContext, {
398416
slots: {
399417
label: {styles: label({size})},

packages/@react-spectrum/s2/src/Picker.tsx

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
Virtualizer
3434
} from 'react-aria-components';
3535
import {AsyncLoadable, FocusableRef, FocusableRefValue, GlobalDOMAttributes, HelpTextProps, LoadingState, PressEvent, RefObject, SpectrumLabelableProps} from '@react-types/shared';
36+
import {AvatarContext} from './Avatar';
3637
import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'};
3738
import {box, iconStyles as checkboxIconStyles} from './Checkbox';
3839
import {centerBaseline} from './CenterBaseline';
@@ -241,6 +242,11 @@ const iconStyles = style({
241242
}
242243
});
243244

245+
const avatar = style({
246+
gridArea: 'icon',
247+
marginEnd: 'text-to-visual'
248+
});
249+
244250
const loadingWrapperStyles = style({
245251
gridColumnStart: '1',
246252
gridColumnEnd: '-1',
@@ -468,6 +474,13 @@ function PickerProgressCircle(props) {
468474
);
469475
}
470476

477+
const avatarSize = {
478+
S: 16,
479+
M: 20,
480+
L: 22,
481+
XL: 26
482+
} as const;
483+
471484
interface PickerButtonInnerProps<T extends object> extends PickerStyleProps, Omit<AriaSelectRenderProps, 'isRequired' | 'isFocused'>, Pick<PickerProps<T>, 'loadingState'> {
472485
loadingCircle: ReactNode,
473486
buttonRef: RefObject<HTMLButtonElement | null>
@@ -519,7 +532,7 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
519532
})}>
520533
{(renderProps) => (
521534
<>
522-
<SelectValue className={valueStyles({isQuiet}) + ' ' + raw('&> * {display: none;}')}>
535+
<SelectValue className={valueStyles({isQuiet}) + ' ' + raw('&> :not([slot=icon], [slot=avatar], [slot=label]) {display: none;}')}>
523536
{({selectedItems, defaultChildren}) => {
524537
return (
525538
<Provider
@@ -532,6 +545,14 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
532545
}
533546
}
534547
}],
548+
[AvatarContext, {
549+
slots: {
550+
avatar: {
551+
size: avatarSize[size ?? 'M'],
552+
styles: avatar
553+
}
554+
}
555+
}],
535556
[TextContext, {
536557
slots: {
537558
description: {},
@@ -606,21 +627,27 @@ export function PickerItem(props: PickerItemProps): ReactNode {
606627
icon: {render: centerBaseline({slot: 'icon', styles: iconCenterWrapper}), styles: icon}
607628
}}}>
608629
<DefaultProvider
609-
context={TextContext}
610-
value={{
611-
slots: {
612-
[DEFAULT_SLOT]: {styles: label({size})},
613-
label: {styles: label({size})},
614-
description: {styles: description({...renderProps, size})}
615-
}
616-
}}>
617-
{renderProps.selectionMode === 'single' && !isLink && <CheckmarkIcon size={checkmarkIconSize[size]} className={checkmark({...renderProps, size})} />}
618-
{renderProps.selectionMode === 'multiple' && !isLink && (
619-
<div className={mergeStyles(checkbox, box(checkboxRenderProps))}>
620-
<CheckmarkIcon size={size} className={checkboxIconStyles} />
621-
</div>
630+
context={AvatarContext}
631+
value={{slots: {
632+
avatar: {size: avatarSize[size], styles: avatar}
633+
}}}>
634+
<DefaultProvider
635+
context={TextContext}
636+
value={{
637+
slots: {
638+
[DEFAULT_SLOT]: {styles: label({size})},
639+
label: {styles: label({size})},
640+
description: {styles: description({...renderProps, size})}
641+
}
642+
}}>
643+
{renderProps.selectionMode === 'single' && !isLink && <CheckmarkIcon size={checkmarkIconSize[size]} className={checkmark({...renderProps, size})} />}
644+
{renderProps.selectionMode === 'multiple' && !isLink && (
645+
<div className={mergeStyles(checkbox, box(checkboxRenderProps))}>
646+
<CheckmarkIcon size={size} className={checkboxIconStyles} />
647+
</div>
622648
)}
623-
{typeof children === 'string' ? <Text slot="label">{children}</Text> : children}
649+
{typeof children === 'string' ? <Text slot="label">{children}</Text> : children}
650+
</DefaultProvider>
624651
</DefaultProvider>
625652
</DefaultProvider>
626653
);

packages/@react-spectrum/s2/stories/ComboBox.stories.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

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

157+
const SRC_URL_1 = 'https://i.imgur.com/xIe7Wlb.png';
158+
const SRC_URL_2 = 'https://mir-s3-cdn-cf.behance.net/project_modules/disp/690bc6105945313.5f84bfc9de488.png';
159+
160+
export const WithAvatars: Story = {
161+
render: (args) => (
162+
<ComboBox {...args}>
163+
<ComboBoxItem textValue="User One">
164+
<Avatar slot="avatar" src={SRC_URL_1} />
165+
<Text slot="label">User One</Text>
166+
<Text slot="description">[email protected]</Text>
167+
</ComboBoxItem>
168+
<ComboBoxItem textValue="User Two">
169+
<Avatar slot="avatar" src={SRC_URL_2} />
170+
<Text slot="label">User Two</Text>
171+
<Text slot="description">[email protected]<br />123-456-7890</Text>
172+
</ComboBoxItem>
173+
<ComboBoxItem textValue="User Three">
174+
<Avatar slot="avatar" src={SRC_URL_2} />
175+
<Text slot="label">User Three</Text>
176+
</ComboBoxItem>
177+
</ComboBox>
178+
),
179+
args: {
180+
label: 'Share'
181+
}
182+
};
183+
157184
export const Validation: Story = {
158185
render: (args) => (
159186
<Form>

packages/@react-spectrum/s2/stories/Picker.stories.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import {
14+
Avatar,
1415
Button,
1516
Content,
1617
ContextualHelp,
@@ -144,6 +145,33 @@ export const WithIcons: Story = {
144145
}
145146
};
146147

148+
const SRC_URL_1 = 'https://i.imgur.com/xIe7Wlb.png';
149+
const SRC_URL_2 = 'https://mir-s3-cdn-cf.behance.net/project_modules/disp/690bc6105945313.5f84bfc9de488.png';
150+
151+
export const WithAvatars: Story = {
152+
render: (args) => (
153+
<Picker {...args}>
154+
<PickerItem textValue="User One">
155+
<Avatar slot="avatar" src={SRC_URL_1} />
156+
<Text slot="label">User One</Text>
157+
<Text slot="description">[email protected]</Text>
158+
</PickerItem>
159+
<PickerItem textValue="User Two">
160+
<Avatar slot="avatar" src={SRC_URL_2} />
161+
<Text slot="label">User Two</Text>
162+
<Text slot="description">[email protected]<br />123-456-7890</Text>
163+
</PickerItem>
164+
<PickerItem textValue="User Three">
165+
<Avatar slot="avatar" src={SRC_URL_2} />
166+
<Text slot="label">User Three</Text>
167+
</PickerItem>
168+
</Picker>
169+
),
170+
args: {
171+
label: 'Share'
172+
}
173+
};
174+
147175
function VirtualizedPicker(props) {
148176
let items: IExampleItem[] = [];
149177
for (let i = 0; i < 10000; i++) {

packages/dev/s2-docs/pages/s2/ComboBox.mdx

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,34 +53,56 @@ function Example() {
5353

5454
### Slots
5555

56-
`ComboBoxItem` supports icons, and `label` and `description` text slots.
56+
`ComboBoxItem` supports icons, avatars, and `label` and `description` text slots.
5757

5858
```tsx render
5959
"use client";
60-
import {ComboBox, ComboBoxItem, Text} from '@react-spectrum/s2';
60+
import {Avatar, ComboBox, ComboBoxItem, Text} from '@react-spectrum/s2';
61+
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
6162
import Comment from '@react-spectrum/s2/icons/Comment';
6263
import Edit from '@react-spectrum/s2/icons/Edit';
6364
import UserSettings from '@react-spectrum/s2/icons/UserSettings';
6465

65-
<ComboBox label="Permissions" defaultSelectedKey="read" placeholder="Select a permission level">
66-
<ComboBoxItem id="read" textValue="Read">
67-
{/*- begin highlight -*/}
68-
<Comment />
69-
<Text slot="label">Read</Text>
70-
<Text slot="description">Comment only</Text>
71-
{/*- end highlight -*/}
72-
</ComboBoxItem>
73-
<ComboBoxItem id="write" textValue="Write">
74-
<Edit />
75-
<Text slot="label">Write</Text>
76-
<Text slot="description">Read and write only</Text>
77-
</ComboBoxItem>
78-
<ComboBoxItem id="admin" textValue="Admin">
79-
<UserSettings />
80-
<Text slot="label">Admin</Text>
81-
<Text slot="description">Full access</Text>
82-
</ComboBoxItem>
83-
</ComboBox>
66+
const users = Array.from({length: 5}, (_, i) => ({
67+
id: `user${i + 1}`,
68+
name: `User ${i + 1}`,
69+
email: `user${i + 1}@example.com`,
70+
avatar: 'https://i.imgur.com/kJOwAdv.png'
71+
}));
72+
73+
<div className={style({display: 'flex', gap: 12, flexWrap: 'wrap'})}>
74+
<ComboBox label="Permissions" defaultSelectedKey="read" placeholder="Select a permission level">
75+
<ComboBoxItem id="read" textValue="Read">
76+
{/*- begin highlight -*/}
77+
<Comment />
78+
<Text slot="label">Read</Text>
79+
<Text slot="description">Comment only</Text>
80+
{/*- end highlight -*/}
81+
</ComboBoxItem>
82+
<ComboBoxItem id="write" textValue="Write">
83+
<Edit />
84+
<Text slot="label">Write</Text>
85+
<Text slot="description">Read and write only</Text>
86+
</ComboBoxItem>
87+
<ComboBoxItem id="admin" textValue="Admin">
88+
<UserSettings />
89+
<Text slot="label">Admin</Text>
90+
<Text slot="description">Full access</Text>
91+
</ComboBoxItem>
92+
</ComboBox>
93+
<ComboBox label="Share" items={users} defaultSelectedKey="user1" placeholder="Select a user">
94+
{(item) => (
95+
<ComboBoxItem id={item.id} textValue={item.name}>
96+
{/*- begin highlight -*/}
97+
<Avatar slot="avatar" src={item.avatar} />
98+
{/*- end highlight -*/}
99+
<Text slot="label">{item.name}</Text>
100+
<Text slot="description">{item.email}</Text>
101+
</ComboBoxItem>
102+
)}
103+
</ComboBox>
104+
</div>
105+
84106
```
85107

86108
<InlineAlert variant="notice">

0 commit comments

Comments
 (0)