Skip to content

Commit

Permalink
Support passing React.ReactElements for icons (#4994)
Browse files Browse the repository at this point in the history
* Support passing React.ReactElements for icons

* Add changeset

* Fix types

* Woops, fix tests

* Add type tests
  • Loading branch information
camertron authored Oct 9, 2024
1 parent 4da550e commit be58fff
Show file tree
Hide file tree
Showing 10 changed files with 113 additions and 27 deletions.
5 changes: 5 additions & 0 deletions .changeset/shiny-otters-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

[SegmentedControl, Autocomplete] Support passing React.ReactElements for icons.
9 changes: 5 additions & 4 deletions packages/react/src/Autocomplete/AutocompleteMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import {AutocompleteContext} from './AutocompleteContext'
import type {IconProps} from '@primer/octicons-react'
import {PlusIcon} from '@primer/octicons-react'
import VisuallyHidden from '../_VisuallyHidden'
import {isElement} from 'react-is'

type OnSelectedChange<T> = (item: T | T[]) => void
export type AutocompleteMenuItem = MandateProps<ActionListItemProps, 'id'> & {
leadingVisual?: React.FunctionComponent<React.PropsWithChildren<IconProps>>
leadingVisual?: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
text?: string
trailingVisual?: React.FunctionComponent<React.PropsWithChildren<IconProps>>
trailingVisual?: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
}

const getDefaultSortFn = (isItemSelectedFn: (itemId: string) => boolean) => (itemIdA: string, itemIdB: string) =>
Expand Down Expand Up @@ -352,13 +353,13 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
<ActionList.Item key={id} onSelect={() => onAction(item)} {...itemProps} id={id} data-id={id}>
{LeadingVisual && (
<ActionList.LeadingVisual>
<LeadingVisual />
{isElement(LeadingVisual) ? LeadingVisual : <LeadingVisual />}
</ActionList.LeadingVisual>
)}
{children ?? text}
{TrailingVisual && (
<ActionList.TrailingVisual>
<TrailingVisual />
{isElement(TrailingVisual) ? TrailingVisual : <TrailingVisual />}
</ActionList.TrailingVisual>
)}
</ActionList.Item>
Expand Down
22 changes: 17 additions & 5 deletions packages/react/src/Button/ButtonBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,30 @@ import {AriaStatus} from '../live-region'
import {clsx} from 'clsx'
import classes from './ButtonBase.module.css'
import {useFeatureFlag} from '../FeatureFlags'
import {isElement} from 'react-is'

const iconWrapStyles = {
display: 'flex',
pointerEvents: 'none',
}

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

const renderModuleVisual = (Visual: React.ElementType, loading: boolean, visualName: string, counterLabel: boolean) => (
const renderModuleVisual = (
Visual: React.ElementType | React.ReactElement,
loading: boolean,
visualName: string,
counterLabel: boolean,
) => (
<span
data-component={visualName}
className={clsx(!counterLabel && classes.Visual, loading ? classes.loadingSpinner : classes.VisualWrap)}
>
{loading ? <Spinner size="small" /> : <Visual />}
{loading ? <Spinner size="small" /> : isElement(Visual) ? Visual : <Visual />}
</span>
)

Expand Down Expand Up @@ -141,6 +147,8 @@ const ButtonBase = forwardRef(
{Icon ? (
loading ? (
<Spinner size="small" />
) : isElement(Icon) ? (
Icon
) : (
<Icon />
)
Expand All @@ -163,7 +171,7 @@ const ButtonBase = forwardRef(
}
{
/* Render a leading visual unless the button is in a loading state.
Then replace the leading visual with a loading spinner. */
Then replace the leading visual with a loading spinner. */
LeadingVisual && renderModuleVisual(LeadingVisual, Boolean(loading), 'leadingVisual', false)
}
{children && (
Expand Down Expand Up @@ -260,6 +268,8 @@ const ButtonBase = forwardRef(
{Icon ? (
loading ? (
<Spinner size="small" />
) : isElement(Icon) ? (
Icon
) : (
<Icon />
)
Expand Down Expand Up @@ -369,6 +379,8 @@ const ButtonBase = forwardRef(
{Icon ? (
loading ? (
<Spinner size="small" />
) : isElement(Icon) ? (
Icon
) : (
<Icon />
)
Expand Down
10 changes: 9 additions & 1 deletion packages/react/src/Button/__tests__/Button.types.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {StopIcon} from '@primer/octicons-react'
import {LogoGithubIcon, StopIcon} from '@primer/octicons-react'
import React, {useRef} from 'react'
import {Button, IconButton} from '../../Button'

Expand Down Expand Up @@ -80,3 +80,11 @@ export function supportsLeadingVisual() {
export function supportsTrailingVisual() {
return <Button trailingVisual={() => <span />}>child</Button>
}

export function supportsLeadingVisualElement() {
return <Button leadingVisual={<LogoGithubIcon />}>child</Button>
}

export function supportsTrailingVisualElement() {
return <Button trailingVisual={<LogoGithubIcon />}>child</Button>
}
7 changes: 4 additions & 3 deletions packages/react/src/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {SxProp} from '../sx'
import sx from '../sx'
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
import type {TooltipDirection} from '../TooltipV2'
import type {IconProps} from '@primer/octicons-react'

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

/**
* The leading visual which comes before the button content
*/
leadingVisual?: React.ElementType | null
leadingVisual?: React.ElementType | React.ReactElement | null

/**
* The trailing visual which comes after the button content
*/
trailingVisual?: React.ElementType | null
trailingVisual?: React.ElementType | React.ReactElement | null

/**
* Trailing action appears to the right of the trailing visual and is always locked to the end
Expand Down
26 changes: 22 additions & 4 deletions packages/react/src/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {useResponsiveValue} from '../hooks/useResponsiveValue'
import type {WidthOnlyViewportRangeKeys} from '../utils/types/ViewportRangeKeys'
import styled from 'styled-components'
import {defaultSxProp} from '../utils/defaultSxProp'
import {isElement} from 'react-is'

// Needed because passing a ref to `Box` causes a type error
const SegmentedControlList = styled.ul`
Expand Down Expand Up @@ -80,16 +81,33 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
)
? React.Children.toArray(children)[selectedIndex]
: undefined
const getChildIcon = (childArg: React.ReactNode) => {
const getChildIcon = (childArg: React.ReactNode): React.ReactElement | null => {
if (
React.isValidElement<SegmentedControlButtonProps>(childArg) &&
childArg.type === Button &&
childArg.props.leadingIcon
) {
return childArg.props.leadingIcon
if (isElement(childArg.props.leadingIcon)) {
return childArg.props.leadingIcon
} else {
const LeadingIcon = childArg.props.leadingIcon
return <LeadingIcon />
}
}

return React.isValidElement<SegmentedControlIconButtonProps>(childArg) ? childArg.props.icon : null
if (
React.isValidElement<SegmentedControlIconButtonProps>(childArg) &&
childArg.type === SegmentedControlIconButton
) {
if (isElement(childArg.props.icon)) {
childArg.props.icon
} else {
const Icon = childArg.props.icon
return <Icon />
}
}

return null
}
const getChildText = (childArg: React.ReactNode) => {
if (React.isValidElement<SegmentedControlButtonProps>(childArg) && childArg.type === Button) {
Expand Down Expand Up @@ -140,7 +158,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
child.props.onClick && child.props.onClick(event as React.MouseEvent<HTMLLIElement>)
}}
>
{ChildIcon && <ChildIcon />} {getChildText(child)}
{ChildIcon} {getChildText(child)}
</ActionList.Item>
)
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {LogoGithubIcon} from '@primer/octicons-react'
import {SegmentedControl} from './SegmentedControl'
import React from 'react'

export function buttonWithLeadingIconElement() {
return (
<SegmentedControl>
<SegmentedControl.Button leadingIcon={<LogoGithubIcon />}>Button</SegmentedControl.Button>
</SegmentedControl>
)
}

export function iconButtonWithIconElement() {
return (
<SegmentedControl>
<SegmentedControl.IconButton aria-label="A label" icon={<LogoGithubIcon />}>
Button
</SegmentedControl.IconButton>
</SegmentedControl>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {SxProp} from '../sx'
import sx, {merge} from '../sx'
import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from './getSegmentedControlStyles'
import {defaultSxProp} from '../utils/defaultSxProp'
import {isElement} from 'react-is'
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'

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

Expand All @@ -44,11 +45,7 @@ const SegmentedControlButton: React.FC<React.PropsWithChildren<SegmentedControlB
{...rest}
>
<span className="segmentedControl-content">
{LeadingIcon && (
<Box mr={1}>
<LeadingIcon />
</Box>
)}
{LeadingIcon && <Box mr={1}>{isElement(LeadingIcon) ? LeadingIcon : <LeadingIcon />}</Box>}
<Box className="segmentedControl-text">{children}</Box>
</span>
</SegmentedControlButtonStyled>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import sx, {merge} from '../sx'
import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from './getSegmentedControlStyles'
import Box from '../Box'
import {defaultSxProp} from '../utils/defaultSxProp'
import {isElement} from 'react-is'
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'

export type SegmentedControlIconButtonProps = {
'aria-label': string
/** The icon that represents the segmented control item */
icon: React.FunctionComponent<React.PropsWithChildren<IconProps>>
icon: React.FunctionComponent<React.PropsWithChildren<IconProps>> | React.ReactElement
/** Whether the segment is selected. This is used for controlled SegmentedControls, and needs to be updated using the onChange handler on SegmentedControl. */
selected?: boolean
/** Whether the segment is selected. This is used for uncontrolled SegmentedControls to pick one SegmentedControlButton that is selected on the initial render. */
Expand Down Expand Up @@ -55,9 +56,7 @@ export const SegmentedControlIconButton: React.FC<React.PropsWithChildren<Segmen
sx={getSegmentedControlButtonStyles({selected, isIconOnly: true})}
{...rest}
>
<span className="segmentedControl-content">
<Icon />
</span>
<span className="segmentedControl-content">{isElement(Icon) ? Icon : <Icon />}</span>
</SegmentedControlIconButtonStyled>
</Box>
)
Expand Down
24 changes: 24 additions & 0 deletions packages/react/src/__tests__/Autocomplete.types.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {LogoGithubIcon} from '@primer/octicons-react'
import {Autocomplete} from '..'
import React from 'react'

export function itemWithIconElements() {
return (
<>
<label htmlFor="autocompleteId" id="autocompleteLabel">
Autocomplete field
</label>
<Autocomplete id="autocompleteId">
<Autocomplete.Overlay>
<Autocomplete.Menu
aria-labelledby="autocompleteLabel"
selectedItemIds={[]}
items={[
{text: 'Item1', id: 'item-1', leadingVisual: <LogoGithubIcon />, trailingVisual: <LogoGithubIcon />},
]}
></Autocomplete.Menu>
</Autocomplete.Overlay>
</Autocomplete>
</>
)
}

0 comments on commit be58fff

Please sign in to comment.