Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/icon-button-triangle-down.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

IconButton: Add `hasTriangleDownIcon` to render a `TriangleDownIcon` after the icon, indicating the button opens a dropdown or menu
29 changes: 29 additions & 0 deletions packages/react/src/Button/ButtonBase.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,35 @@
&:where([data-size='large']) {
width: var(--control-large-size);
}

/* Dropdown affordance: the icon is followed by a TriangleDownIcon, so the
button is no longer a fixed square. */
&:where([data-has-triangle-down-icon]) {
width: auto;
grid-auto-flow: column;
align-items: center;
gap: var(--control-medium-gap);
/* stylelint-disable-next-line primer/spacing */
padding-inline: var(--control-medium-paddingInline-condensed);

&:where([data-size='small']) {
gap: var(--control-small-gap);
/* stylelint-disable-next-line primer/spacing */
padding-inline: var(--control-small-paddingInline-condensed);
}

&:where([data-size='large']) {
gap: var(--control-large-gap);
/* stylelint-disable-next-line primer/spacing */
padding-inline: var(--control-large-paddingInline-condensed);
}

& :where([data-component='triangleDownIcon']) {
/* Pull the caret slightly tighter to the icon, matching the
`trailingAction` treatment on text buttons. */
margin-inline-start: calc(var(--base-size-4) * -1);
}
}
}

/* LinkButton */
Expand Down
10 changes: 7 additions & 3 deletions packages/react/src/Button/ButtonBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {AriaStatus} from '../live-region'
import {clsx} from 'clsx'
import classes from './ButtonBase.module.css'
import {isElement} from 'react-is'
import {TriangleDownIcon} from '@primer/octicons-react'

const renderModuleVisual = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -36,6 +37,7 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
['aria-labelledby']: ariaLabelledBy,
count,
icon: Icon,
hasTriangleDownIcon,
id,
variant = 'default',
size = 'medium',
Expand Down Expand Up @@ -99,6 +101,7 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
data-variant={variant}
data-label-wrap={labelWrap}
data-has-count={count !== undefined ? true : undefined}
data-has-triangle-down-icon={Icon && hasTriangleDownIcon ? true : undefined}
data-icon-only-counter={count !== undefined && LeadingVisual && !children ? true : undefined}
aria-describedby={ariaDescribedByIds.filter(descriptionID => Boolean(descriptionID)).join(' ') || undefined}
// aria-labelledby is needed because the accessible name becomes unset when the button is in a loading state.
Expand All @@ -114,10 +117,11 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
{Icon ? (
loading ? (
<Spinner size="small" />
) : isElement(Icon) ? (
Icon
) : (
<Icon />
<>
{isElement(Icon) ? Icon : <Icon />}
{hasTriangleDownIcon ? <TriangleDownIcon data-component="triangleDownIcon" /> : null}
</>
)
) : (
<>
Expand Down
10 changes: 10 additions & 0 deletions packages/react/src/Button/IconButton.dev.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,13 @@ export const IconButtonWithinFlexContainer = () => (
<IconButton icon={BoldIcon} aria-label="Icon button" />
</Stack>
)

export const WithTriangleDownIcon = () => (
<Stack direction="horizontal" align="center">
<IconButton icon={BoldIcon} aria-label="Formatting options" hasTriangleDownIcon size="small" />
<IconButton icon={BoldIcon} aria-label="Formatting options" hasTriangleDownIcon />
<IconButton icon={BoldIcon} aria-label="Formatting options" hasTriangleDownIcon size="large" />
<IconButton icon={BoldIcon} aria-label="Formatting options" hasTriangleDownIcon variant="primary" />
<IconButton icon={BoldIcon} aria-label="Formatting options" hasTriangleDownIcon variant="invisible" />
</Stack>
)
6 changes: 6 additions & 0 deletions packages/react/src/Button/IconButton.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@
"defaultValue": "",
"description": "provide an octicon. It will be placed in the center of the button"
},
{
"name": "hasTriangleDownIcon",
"type": "boolean",
"defaultValue": "false",
"description": "Whether to render a `TriangleDownIcon` after the icon to indicate the button opens a dropdown or menu."
},
{
"name": "aria-label",
"type": "string",
Expand Down
23 changes: 23 additions & 0 deletions packages/react/src/Button/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,14 @@ describe('Button', () => {
const triggerEl = getByRole('button', {name: 'Heart'})
expect(triggerEl).toHaveAccessibleDescription('Love is all around (command h)')
})
it('should render a TriangleDownIcon when hasTriangleDownIcon is passed', () => {
const {container} = render(<IconButton icon={HeartIcon} aria-label="Heart" hasTriangleDownIcon />)
expect(container.querySelector('[data-component="triangleDownIcon"]')).toBeInTheDocument()
})
it('should not render a TriangleDownIcon by default', () => {
const {container} = render(<IconButton icon={HeartIcon} aria-label="Heart" />)
expect(container.querySelector('[data-component="triangleDownIcon"]')).not.toBeInTheDocument()
})
})

describe('data-component attributes', () => {
Expand Down Expand Up @@ -388,6 +396,21 @@ describe('data-component attributes', () => {
const {container} = render(<IconButton icon={SearchIcon} aria-label="Search" />)
expect(container.querySelector('[data-component="IconButton"]')).toBeInTheDocument()
})

it('should set data-has-triangle-down-icon when hasTriangleDownIcon is passed', () => {
const {container} = render(<IconButton icon={SearchIcon} aria-label="Search" hasTriangleDownIcon />)
expect(container.querySelector('[data-component="IconButton"]')).toHaveAttribute(
'data-has-triangle-down-icon',
'true',
)
})

it('should not set data-has-triangle-down-icon by default', () => {
const {container} = render(<IconButton icon={SearchIcon} aria-label="Search" />)
expect(container.querySelector('[data-component="IconButton"]')).not.toHaveAttribute(
'data-has-triangle-down-icon',
)
})
})

describe('LinkButton', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/react/src/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,23 @@ export type ButtonProps = {
children?: React.ReactNode

count?: number | string

/**
* Whether to render a `TriangleDownIcon` after the icon to indicate the button opens a
* dropdown or menu. Only has an effect on `IconButton`.
*/
hasTriangleDownIcon?: boolean
} & ButtonBaseProps

export type IconButtonProps = ButtonA11yProps & {
icon: React.ElementType
unsafeDisableTooltip?: boolean
description?: string
/**
* Whether to render a `TriangleDownIcon` after the icon to indicate the button opens a
* dropdown or menu.
*/
hasTriangleDownIcon?: boolean
tooltipDirection?: TooltipDirection
/** @deprecated Use `keybindingHint` instead. */
keyshortcuts?: string
Expand Down
Loading