Skip to content

Commit

Permalink
feat(Button): add support for leadingVisual and trailingVisual (#3715)
Browse files Browse the repository at this point in the history
Co-authored-by: Josh Black <joshblack@users.noreply.github.com>
  • Loading branch information
joshblack and joshblack authored Sep 7, 2023
1 parent 6ce0ba5 commit 1f88928
Show file tree
Hide file tree
Showing 11 changed files with 154 additions and 33 deletions.
7 changes: 7 additions & 0 deletions .changeset/nasty-jobs-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/react': minor
---

Add support for leadingVisual and trailingVisual props to Button

<!-- Changed components: Button -->
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 15 additions & 3 deletions src/Button/Button.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,24 @@
{
"name": "leadingIcon",
"type": "React.ComponentType<OcticonProps>",
"description": "An icon to display before the button text."
"description": "An icon to display before the button text.",
"deprecated": true
},
{
"name": "leadingVisual",
"type": "React.ElementType",
"description": "A visual to display before the button text."
},
{
"name": "trailingIcon",
"type": "React.ComponentType<OcticonProps>",
"description": "An icon to display after the button text."
"description": "An icon to display after the button text.",
"deprecated": true
},
{
"name": "trailingVisual",
"type": "React.ElementType",
"description": "A visual to display after the button text."
},
{
"name": "as",
Expand Down Expand Up @@ -67,4 +79,4 @@
]
}
]
}
}
4 changes: 2 additions & 2 deletions src/Button/Button.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ export const Invisible = () => <Button variant="invisible">Invisible</Button>

export const Outline = () => <Button variant="outline">Outline</Button>

export const LeadingVisual = () => <Button leadingIcon={HeartIcon}>Leading visual</Button>
export const LeadingVisual = () => <Button leadingVisual={HeartIcon}>Leading visual</Button>

export const TrailingVisual = () => <Button trailingIcon={EyeIcon}>Trailing visual</Button>
export const TrailingVisual = () => <Button trailingVisual={EyeIcon}>Trailing visual</Button>

export const TrailingCounter = () => {
const [count, setCount] = useState(0)
Expand Down
8 changes: 4 additions & 4 deletions src/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ export default {
type: 'boolean',
},
},
leadingIcon: OcticonArgType([EyeClosedIcon, EyeIcon, SearchIcon, XIcon, HeartIcon]),
trailingIcon: OcticonArgType([EyeClosedIcon, EyeIcon, SearchIcon, XIcon, HeartIcon]),
leadingVisual: OcticonArgType([EyeClosedIcon, EyeIcon, SearchIcon, XIcon, HeartIcon]),
trailingVisual: OcticonArgType([EyeClosedIcon, EyeIcon, SearchIcon, XIcon, HeartIcon]),
trailingAction: OcticonArgType([TriangleDownIcon]),
trailingVisualCount: {
control: {
Expand All @@ -50,8 +50,8 @@ export default {
disabled: false,
variant: 'default',
alignContent: 'center',
trailingIcon: null,
leadingIcon: null,
trailingVisual: null,
leadingVisual: null,
trailingAction: null,
trailingVisualCount: undefined,
},
Expand Down
11 changes: 7 additions & 4 deletions src/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {BetterSystemStyleObject} from '../sx'

const ButtonComponent = forwardRef(({children, sx: sxProp = defaultSxProp, ...props}, forwardedRef): JSX.Element => {
let sxStyles = sxProp
const leadingVisual = props.leadingVisual ?? props.leadingIcon
const trailingVisual = props.trailingVisual ?? props.trailingIcon

// grap the button props that have associated data attributes in the styles
const {block, size, leadingIcon, trailingIcon, trailingAction} = props
const {block, size, trailingAction} = props

if (sxProp !== null && Object.keys(sxProp).length > 0) {
sxStyles = generateCustomSxProp({block, size, leadingIcon, trailingIcon, trailingAction}, sxProp)
sxStyles = generateCustomSxProp({block, size, leadingVisual, trailingVisual, trailingAction}, sxProp)
}

return (
Expand Down Expand Up @@ -63,13 +65,14 @@ sx={{
// We need to make sure we append the customCSSSelector to the original class selector. i.e & - > &[data-attribute="Icon"][data-size="small"]
*/
export function generateCustomSxProp(
props: Partial<Pick<ButtonProps, 'size' | 'block' | 'leadingIcon' | 'trailingIcon' | 'trailingAction'>>,
props: Partial<Pick<ButtonProps, 'size' | 'block' | 'leadingVisual' | 'trailingVisual' | 'trailingAction'>>,
providedSx: BetterSystemStyleObject,
) {
// Possible data attributes: data-size, data-block, data-no-visuals
const size = props.size && props.size !== 'medium' ? `[data-size="${props.size}"]` : '' // medium is a default size therefore it doesn't have a data attribute that used for styling
const block = props.block ? `[data-block="block"]` : ''
const noVisuals = props.leadingIcon || props.trailingIcon || props.trailingAction ? '' : '[data-no-visuals="true"]'
const noVisuals =
props.leadingVisual || props.trailingVisual || props.trailingAction ? '' : '[data-no-visuals="true"]'

// this is custom selector. We need to make sure we add the data attributes to the base css class (& -> &[data-attributename="value"]])
const cssSelector = `&${size}${block}${noVisuals}` // &[data-size="small"][data-block="block"][data-no-visuals="true"]
Expand Down
14 changes: 7 additions & 7 deletions src/Button/ButtonBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import {defaultSxProp} from '../utils/defaultSxProp'
const ButtonBase = forwardRef(
({children, as: Component = 'button', sx: sxProp = defaultSxProp, ...props}, forwardedRef): JSX.Element => {
const {
leadingIcon: LeadingIcon,
trailingIcon: TrailingIcon,
trailingAction: TrailingAction,
icon: Icon,
variant = 'default',
Expand All @@ -21,6 +19,8 @@ const ButtonBase = forwardRef(
block = false,
...rest
} = props
const LeadingVisual = props.leadingVisual ?? props.leadingIcon
const TrailingVisual = props.trailingVisual ?? props.trailingIcon

const innerRef = React.useRef<HTMLButtonElement>(null)
useRefObjectAsForwardedRef(forwardedRef, innerRef)
Expand Down Expand Up @@ -65,22 +65,22 @@ const ButtonBase = forwardRef(
ref={innerRef}
data-block={block ? 'block' : null}
data-size={size === 'small' || size === 'large' ? size : undefined}
data-no-visuals={!LeadingIcon && !TrailingIcon && !TrailingAction ? true : undefined}
data-no-visuals={!LeadingVisual && !TrailingVisual && !TrailingAction ? true : undefined}
>
{Icon ? (
<Icon />
) : (
<>
<Box as="span" data-component="buttonContent" sx={getAlignContentSize(alignContent)}>
{LeadingIcon && (
{LeadingVisual && (
<Box as="span" data-component="leadingVisual" sx={{...iconWrapStyles}}>
<LeadingIcon />
<LeadingVisual />
</Box>
)}
{children && <span data-component="text">{children}</span>}
{TrailingIcon && (
{TrailingVisual && (
<Box as="span" data-component="trailingVisual" sx={{...iconWrapStyles}}>
<TrailingIcon />
<TrailingVisual />
</Box>
)}
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React from 'react'
import {IconButton, Button} from '../Button'
import {behavesAsComponent} from '../utils/testing'
import {render, fireEvent} from '@testing-library/react'
import {axe, toHaveNoViolations} from 'jest-axe'
import {SearchIcon} from '@primer/octicons-react'
expect.extend(toHaveNoViolations)
import {render, screen, fireEvent} from '@testing-library/react'
import {axe} from 'jest-axe'
import React from 'react'
import {IconButton, Button} from '../../Button'
import {behavesAsComponent} from '../../utils/testing'

describe('Button', () => {
behavesAsComponent({Component: Button, options: {skipSx: true}})
Expand Down Expand Up @@ -89,4 +88,81 @@ describe('Button', () => {
const button = container.getByRole('button')
expect(button).toMatchSnapshot()
})

it('should render the leadingIcon prop before the button content', () => {
render(
<Button leadingIcon={() => <span data-testid="leadingIcon" />}>
<span>content</span>
</Button>,
)
expect(screen.getByTestId('leadingIcon')).toBeInTheDocument()

const position = screen.getByText('content').compareDocumentPosition(screen.getByTestId('leadingIcon'))
expect(position).toBe(Node.DOCUMENT_POSITION_PRECEDING)
})

it('should render the leadingVisual prop before the button content', () => {
render(
<Button leadingVisual={() => <span data-testid="leadingVisual" />}>
<span>content</span>
</Button>,
)

expect(screen.getByTestId('leadingVisual')).toBeInTheDocument()

const position = screen.getByText('content').compareDocumentPosition(screen.getByTestId('leadingVisual'))
expect(position).toBe(Node.DOCUMENT_POSITION_PRECEDING)
})

it('should only render the leadingVisual prop if leadingIcon is also defined', () => {
render(
<Button
leadingVisual={() => <span data-testid="leadingVisual" />}
leadingIcon={() => <span data-testid="leadingIcon" />}
>
<span>content</span>
</Button>,
)

expect(screen.getByTestId('leadingVisual')).toBeInTheDocument()
expect(screen.queryByText('leadingIcon')).toEqual(null)
})

it('should render the trailingIcon prop after the button content', () => {
render(
<Button trailingIcon={() => <span data-testid="trailingIcon" />}>
<span>content</span>
</Button>,
)
expect(screen.getByTestId('trailingIcon')).toBeInTheDocument()

const position = screen.getByText('content').compareDocumentPosition(screen.getByTestId('trailingIcon'))
expect(position).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})

it('should render the trailingVisual prop after the button content', () => {
render(
<Button trailingVisual={() => <span data-testid="trailingVisual" />}>
<span>content</span>
</Button>,
)
expect(screen.getByTestId('trailingVisual')).toBeInTheDocument()

const position = screen.getByText('content').compareDocumentPosition(screen.getByTestId('trailingVisual'))
expect(position).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})

it('should only render the trailingVisual prop if trailingIcon is also defined', () => {
render(
<Button
trailingVisual={() => <span data-testid="trailingVisual" />}
trailingIcon={() => <span data-testid="trailingIcon" />}
>
<span>content</span>
</Button>,
)

expect(screen.getByTestId('trailingVisual')).toBeInTheDocument()
expect(screen.queryByText('trailingIcon')).toEqual(null)
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {StopIcon} from '@primer/octicons-react'
import React, {useRef} from 'react'
import {Button, IconButton} from '../Button'
import {Button, IconButton} from '../../Button'

export function shouldAcceptOnlyAChildProp() {
return <Button>child</Button>
Expand Down Expand Up @@ -68,3 +68,11 @@ export function iconButtonShouldNotAcceptOutlandishProps() {
// @ts-expect-error system props should not be accepted
return <Button icon={StopIcon} aria-label="Stop icon" anOutlandshPropThatShouldNotBeAllowedOnA={'Button'} />
}

export function supportsLeadingVisual() {
return <Button leadingVisual={() => <span />}>child</Button>
}

export function supportsTrailingVisual() {
return <Button trailingVisual={() => <span />}>child</Button>
}
23 changes: 19 additions & 4 deletions src/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,35 @@ export type ButtonProps = {
/**
* The icon for the IconButton
*/
icon?: React.ElementType | null | undefined
icon?: React.ElementType | null

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

/**
* The leading icon comes before button content
*/
leadingIcon?: React.ElementType | null | undefined
leadingIcon?: React.ElementType | null

/**
* The trailing icon comes after button content
*/
trailingIcon?: React.ElementType | null | undefined
trailingIcon?: React.ElementType | null

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

/**
* Trailing action appears to the right of the trailing visual and is always locked to the end
*/
trailingAction?: React.ElementType | null | undefined
trailingAction?: React.ElementType | null

children: React.ReactNode

/**
* Content alignment for when visuals are present
*/
Expand Down

0 comments on commit 1f88928

Please sign in to comment.