From 956e893fd81a883af9d3c5c63182ecdec6a56386 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Fri, 26 Jun 2026 11:53:25 +0100 Subject: [PATCH] IconButton: add hasTriangleDownIcon prop for dropdown affordance Adds a working `hasTriangleDownIcon` boolean prop to IconButton that renders a TriangleDownIcon after the icon to indicate the button opens a dropdown or menu. The control switches from a fixed square to an auto-width layout with token-based gap and padding per size. The only available trailing glyph is TriangleDownIcon. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/icon-button-triangle-down.md | 5 ++++ .../react/src/Button/ButtonBase.module.css | 29 +++++++++++++++++++ packages/react/src/Button/ButtonBase.tsx | 10 +++++-- .../src/Button/IconButton.dev.stories.tsx | 10 +++++++ .../react/src/Button/IconButton.docs.json | 6 ++++ .../src/Button/__tests__/Button.test.tsx | 23 +++++++++++++++ packages/react/src/Button/types.ts | 11 +++++++ 7 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 .changeset/icon-button-triangle-down.md 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