Skip to content

Commit be58fff

Browse files
authored
Support passing React.ReactElements for icons (#4994)
* Support passing React.ReactElements for icons * Add changeset * Fix types * Woops, fix tests * Add type tests
1 parent 4da550e commit be58fff

File tree

10 files changed

+113
-27
lines changed

10 files changed

+113
-27
lines changed

.changeset/shiny-otters-call.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
[SegmentedControl, Autocomplete] Support passing React.ReactElements for icons.

packages/react/src/Autocomplete/AutocompleteMenu.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ import {AutocompleteContext} from './AutocompleteContext'
1212
import type {IconProps} from '@primer/octicons-react'
1313
import {PlusIcon} from '@primer/octicons-react'
1414
import VisuallyHidden from '../_VisuallyHidden'
15+
import {isElement} from 'react-is'
1516

1617
type OnSelectedChange<T> = (item: T | T[]) => void
1718
export type AutocompleteMenuItem = MandateProps<ActionListItemProps, 'id'> & {
18-
leadingVisual?: React.FunctionComponent<React.PropsWithChildren<IconProps>>
19+
leadingVisual?: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
1920
text?: string
20-
trailingVisual?: React.FunctionComponent<React.PropsWithChildren<IconProps>>
21+
trailingVisual?: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
2122
}
2223

2324
const getDefaultSortFn = (isItemSelectedFn: (itemId: string) => boolean) => (itemIdA: string, itemIdB: string) =>
@@ -352,13 +353,13 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
352353
<ActionList.Item key={id} onSelect={() => onAction(item)} {...itemProps} id={id} data-id={id}>
353354
{LeadingVisual && (
354355
<ActionList.LeadingVisual>
355-
<LeadingVisual />
356+
{isElement(LeadingVisual) ? LeadingVisual : <LeadingVisual />}
356357
</ActionList.LeadingVisual>
357358
)}
358359
{children ?? text}
359360
{TrailingVisual && (
360361
<ActionList.TrailingVisual>
361-
<TrailingVisual />
362+
{isElement(TrailingVisual) ? TrailingVisual : <TrailingVisual />}
362363
</ActionList.TrailingVisual>
363364
)}
364365
</ActionList.Item>

packages/react/src/Button/ButtonBase.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,30 @@ import {AriaStatus} from '../live-region'
1919
import {clsx} from 'clsx'
2020
import classes from './ButtonBase.module.css'
2121
import {useFeatureFlag} from '../FeatureFlags'
22+
import {isElement} from 'react-is'
2223

2324
const iconWrapStyles = {
2425
display: 'flex',
2526
pointerEvents: 'none',
2627
}
2728

28-
const renderVisual = (Visual: React.ElementType, loading: boolean, visualName: string) => (
29+
const renderVisual = (Visual: React.ElementType | React.ReactElement, loading: boolean, visualName: string) => (
2930
<Box as="span" data-component={visualName} sx={{...iconWrapStyles}}>
30-
{loading ? <Spinner size="small" /> : <Visual />}
31+
{loading ? <Spinner size="small" /> : isElement(Visual) ? Visual : <Visual />}
3132
</Box>
3233
)
3334

34-
const renderModuleVisual = (Visual: React.ElementType, loading: boolean, visualName: string, counterLabel: boolean) => (
35+
const renderModuleVisual = (
36+
Visual: React.ElementType | React.ReactElement,
37+
loading: boolean,
38+
visualName: string,
39+
counterLabel: boolean,
40+
) => (
3541
<span
3642
data-component={visualName}
3743
className={clsx(!counterLabel && classes.Visual, loading ? classes.loadingSpinner : classes.VisualWrap)}
3844
>
39-
{loading ? <Spinner size="small" /> : <Visual />}
45+
{loading ? <Spinner size="small" /> : isElement(Visual) ? Visual : <Visual />}
4046
</span>
4147
)
4248

@@ -141,6 +147,8 @@ const ButtonBase = forwardRef(
141147
{Icon ? (
142148
loading ? (
143149
<Spinner size="small" />
150+
) : isElement(Icon) ? (
151+
Icon
144152
) : (
145153
<Icon />
146154
)
@@ -163,7 +171,7 @@ const ButtonBase = forwardRef(
163171
}
164172
{
165173
/* Render a leading visual unless the button is in a loading state.
166-
Then replace the leading visual with a loading spinner. */
174+
Then replace the leading visual with a loading spinner. */
167175
LeadingVisual && renderModuleVisual(LeadingVisual, Boolean(loading), 'leadingVisual', false)
168176
}
169177
{children && (
@@ -260,6 +268,8 @@ const ButtonBase = forwardRef(
260268
{Icon ? (
261269
loading ? (
262270
<Spinner size="small" />
271+
) : isElement(Icon) ? (
272+
Icon
263273
) : (
264274
<Icon />
265275
)
@@ -369,6 +379,8 @@ const ButtonBase = forwardRef(
369379
{Icon ? (
370380
loading ? (
371381
<Spinner size="small" />
382+
) : isElement(Icon) ? (
383+
Icon
372384
) : (
373385
<Icon />
374386
)

packages/react/src/Button/__tests__/Button.types.test.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {StopIcon} from '@primer/octicons-react'
1+
import {LogoGithubIcon, StopIcon} from '@primer/octicons-react'
22
import React, {useRef} from 'react'
33
import {Button, IconButton} from '../../Button'
44

@@ -80,3 +80,11 @@ export function supportsLeadingVisual() {
8080
export function supportsTrailingVisual() {
8181
return <Button trailingVisual={() => <span />}>child</Button>
8282
}
83+
84+
export function supportsLeadingVisualElement() {
85+
return <Button leadingVisual={<LogoGithubIcon />}>child</Button>
86+
}
87+
88+
export function supportsTrailingVisualElement() {
89+
return <Button trailingVisual={<LogoGithubIcon />}>child</Button>
90+
}

packages/react/src/Button/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {SxProp} from '../sx'
44
import sx from '../sx'
55
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
66
import type {TooltipDirection} from '../TooltipV2'
7+
import type {IconProps} from '@primer/octicons-react'
78

89
export const StyledButton = styled.button<SxProp>`
910
${getGlobalFocusStyles('-2px')};
@@ -66,17 +67,17 @@ export type ButtonProps = {
6667
/**
6768
* The icon for the IconButton
6869
*/
69-
icon?: React.ElementType | null
70+
icon?: React.FunctionComponent<IconProps> | React.ElementType | React.ReactElement | null
7071

7172
/**
7273
* The leading visual which comes before the button content
7374
*/
74-
leadingVisual?: React.ElementType | null
75+
leadingVisual?: React.ElementType | React.ReactElement | null
7576

7677
/**
7778
* The trailing visual which comes after the button content
7879
*/
79-
trailingVisual?: React.ElementType | null
80+
trailingVisual?: React.ElementType | React.ReactElement | null
8081

8182
/**
8283
* Trailing action appears to the right of the trailing visual and is always locked to the end

packages/react/src/SegmentedControl/SegmentedControl.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {useResponsiveValue} from '../hooks/useResponsiveValue'
1313
import type {WidthOnlyViewportRangeKeys} from '../utils/types/ViewportRangeKeys'
1414
import styled from 'styled-components'
1515
import {defaultSxProp} from '../utils/defaultSxProp'
16+
import {isElement} from 'react-is'
1617

1718
// Needed because passing a ref to `Box` causes a type error
1819
const SegmentedControlList = styled.ul`
@@ -80,16 +81,33 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
8081
)
8182
? React.Children.toArray(children)[selectedIndex]
8283
: undefined
83-
const getChildIcon = (childArg: React.ReactNode) => {
84+
const getChildIcon = (childArg: React.ReactNode): React.ReactElement | null => {
8485
if (
8586
React.isValidElement<SegmentedControlButtonProps>(childArg) &&
8687
childArg.type === Button &&
8788
childArg.props.leadingIcon
8889
) {
89-
return childArg.props.leadingIcon
90+
if (isElement(childArg.props.leadingIcon)) {
91+
return childArg.props.leadingIcon
92+
} else {
93+
const LeadingIcon = childArg.props.leadingIcon
94+
return <LeadingIcon />
95+
}
9096
}
9197

92-
return React.isValidElement<SegmentedControlIconButtonProps>(childArg) ? childArg.props.icon : null
98+
if (
99+
React.isValidElement<SegmentedControlIconButtonProps>(childArg) &&
100+
childArg.type === SegmentedControlIconButton
101+
) {
102+
if (isElement(childArg.props.icon)) {
103+
childArg.props.icon
104+
} else {
105+
const Icon = childArg.props.icon
106+
return <Icon />
107+
}
108+
}
109+
110+
return null
93111
}
94112
const getChildText = (childArg: React.ReactNode) => {
95113
if (React.isValidElement<SegmentedControlButtonProps>(childArg) && childArg.type === Button) {
@@ -140,7 +158,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
140158
child.props.onClick && child.props.onClick(event as React.MouseEvent<HTMLLIElement>)
141159
}}
142160
>
143-
{ChildIcon && <ChildIcon />} {getChildText(child)}
161+
{ChildIcon} {getChildText(child)}
144162
</ActionList.Item>
145163
)
146164
})}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {LogoGithubIcon} from '@primer/octicons-react'
2+
import {SegmentedControl} from './SegmentedControl'
3+
import React from 'react'
4+
5+
export function buttonWithLeadingIconElement() {
6+
return (
7+
<SegmentedControl>
8+
<SegmentedControl.Button leadingIcon={<LogoGithubIcon />}>Button</SegmentedControl.Button>
9+
</SegmentedControl>
10+
)
11+
}
12+
13+
export function iconButtonWithIconElement() {
14+
return (
15+
<SegmentedControl>
16+
<SegmentedControl.IconButton aria-label="A label" icon={<LogoGithubIcon />}>
17+
Button
18+
</SegmentedControl.IconButton>
19+
</SegmentedControl>
20+
)
21+
}

packages/react/src/SegmentedControl/SegmentedControlButton.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {SxProp} from '../sx'
77
import sx, {merge} from '../sx'
88
import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from './getSegmentedControlStyles'
99
import {defaultSxProp} from '../utils/defaultSxProp'
10+
import {isElement} from 'react-is'
1011
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
1112

1213
export type SegmentedControlButtonProps = {
@@ -17,7 +18,7 @@ export type SegmentedControlButtonProps = {
1718
/** Whether the segment is selected. This is used for uncontrolled `SegmentedControls` to pick one `SegmentedControlButton` that is selected on the initial render. */
1819
defaultSelected?: boolean
1920
/** The leading icon comes before item label */
20-
leadingIcon?: React.FunctionComponent<React.PropsWithChildren<IconProps>>
21+
leadingIcon?: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
2122
} & SxProp &
2223
ButtonHTMLAttributes<HTMLButtonElement | HTMLLIElement>
2324

@@ -44,11 +45,7 @@ const SegmentedControlButton: React.FC<React.PropsWithChildren<SegmentedControlB
4445
{...rest}
4546
>
4647
<span className="segmentedControl-content">
47-
{LeadingIcon && (
48-
<Box mr={1}>
49-
<LeadingIcon />
50-
</Box>
51-
)}
48+
{LeadingIcon && <Box mr={1}>{isElement(LeadingIcon) ? LeadingIcon : <LeadingIcon />}</Box>}
5249
<Box className="segmentedControl-text">{children}</Box>
5350
</span>
5451
</SegmentedControlButtonStyled>

packages/react/src/SegmentedControl/SegmentedControlIconButton.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import sx, {merge} from '../sx'
77
import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from './getSegmentedControlStyles'
88
import Box from '../Box'
99
import {defaultSxProp} from '../utils/defaultSxProp'
10+
import {isElement} from 'react-is'
1011
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
1112

1213
export type SegmentedControlIconButtonProps = {
1314
'aria-label': string
1415
/** The icon that represents the segmented control item */
15-
icon: React.FunctionComponent<React.PropsWithChildren<IconProps>>
16+
icon: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
1617
/** Whether the segment is selected. This is used for controlled SegmentedControls, and needs to be updated using the onChange handler on SegmentedControl. */
1718
selected?: boolean
1819
/** Whether the segment is selected. This is used for uncontrolled SegmentedControls to pick one SegmentedControlButton that is selected on the initial render. */
@@ -55,9 +56,7 @@ export const SegmentedControlIconButton: React.FC<React.PropsWithChildren<Segmen
5556
sx={getSegmentedControlButtonStyles({selected, isIconOnly: true})}
5657
{...rest}
5758
>
58-
<span className="segmentedControl-content">
59-
<Icon />
60-
</span>
59+
<span className="segmentedControl-content">{isElement(Icon) ? Icon : <Icon />}</span>
6160
</SegmentedControlIconButtonStyled>
6261
</Box>
6362
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {LogoGithubIcon} from '@primer/octicons-react'
2+
import {Autocomplete} from '..'
3+
import React from 'react'
4+
5+
export function itemWithIconElements() {
6+
return (
7+
<>
8+
<label htmlFor="autocompleteId" id="autocompleteLabel">
9+
Autocomplete field
10+
</label>
11+
<Autocomplete id="autocompleteId">
12+
<Autocomplete.Overlay>
13+
<Autocomplete.Menu
14+
aria-labelledby="autocompleteLabel"
15+
selectedItemIds={[]}
16+
items={[
17+
{text: 'Item1', id: 'item-1', leadingVisual: <LogoGithubIcon />, trailingVisual: <LogoGithubIcon />},
18+
]}
19+
></Autocomplete.Menu>
20+
</Autocomplete.Overlay>
21+
</Autocomplete>
22+
</>
23+
)
24+
}

0 commit comments

Comments
 (0)