diff --git a/.changeset/icon-button-triangle-down.md b/.changeset/icon-button-triangle-down.md
new file mode 100644
index 00000000000..3c303da2a73
--- /dev/null
+++ b/.changeset/icon-button-triangle-down.md
@@ -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
diff --git a/packages/react/src/Button/ButtonBase.module.css b/packages/react/src/Button/ButtonBase.module.css
index b9c26348a66..a02298b9d0f 100644
--- a/packages/react/src/Button/ButtonBase.module.css
+++ b/packages/react/src/Button/ButtonBase.module.css
@@ -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 */
diff --git a/packages/react/src/Button/ButtonBase.tsx b/packages/react/src/Button/ButtonBase.tsx
index 19357e9ae43..2283d2e16d5 100644
--- a/packages/react/src/Button/ButtonBase.tsx
+++ b/packages/react/src/Button/ButtonBase.tsx
@@ -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
@@ -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',
@@ -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.
@@ -114,10 +117,11 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
{Icon ? (
loading ? (
- ) : isElement(Icon) ? (
- Icon
) : (
-
+ <>
+ {isElement(Icon) ? Icon : }
+ {hasTriangleDownIcon ? : null}
+ >
)
) : (
<>
diff --git a/packages/react/src/Button/IconButton.dev.stories.tsx b/packages/react/src/Button/IconButton.dev.stories.tsx
index 241264c3f1a..c417966c2ef 100644
--- a/packages/react/src/Button/IconButton.dev.stories.tsx
+++ b/packages/react/src/Button/IconButton.dev.stories.tsx
@@ -61,3 +61,13 @@ export const IconButtonWithinFlexContainer = () => (
)
+
+export const WithTriangleDownIcon = () => (
+
+
+
+
+
+
+
+)
diff --git a/packages/react/src/Button/IconButton.docs.json b/packages/react/src/Button/IconButton.docs.json
index 4af4062608e..a3aedce9910 100644
--- a/packages/react/src/Button/IconButton.docs.json
+++ b/packages/react/src/Button/IconButton.docs.json
@@ -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",
diff --git a/packages/react/src/Button/__tests__/Button.test.tsx b/packages/react/src/Button/__tests__/Button.test.tsx
index f2c11962df8..26f5f27cedf 100644
--- a/packages/react/src/Button/__tests__/Button.test.tsx
+++ b/packages/react/src/Button/__tests__/Button.test.tsx
@@ -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()
+ expect(container.querySelector('[data-component="triangleDownIcon"]')).toBeInTheDocument()
+ })
+ it('should not render a TriangleDownIcon by default', () => {
+ const {container} = render()
+ expect(container.querySelector('[data-component="triangleDownIcon"]')).not.toBeInTheDocument()
+ })
})
describe('data-component attributes', () => {
@@ -388,6 +396,21 @@ describe('data-component attributes', () => {
const {container} = render()
expect(container.querySelector('[data-component="IconButton"]')).toBeInTheDocument()
})
+
+ it('should set data-has-triangle-down-icon when hasTriangleDownIcon is passed', () => {
+ const {container} = render()
+ 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()
+ expect(container.querySelector('[data-component="IconButton"]')).not.toHaveAttribute(
+ 'data-has-triangle-down-icon',
+ )
+ })
})
describe('LinkButton', () => {
diff --git a/packages/react/src/Button/types.ts b/packages/react/src/Button/types.ts
index acdd7190527..4037a94ead5 100644
--- a/packages/react/src/Button/types.ts
+++ b/packages/react/src/Button/types.ts
@@ -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