From 3e37f5510087959b369c2ae335dce059f435ed00 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 18 Jun 2026 10:28:50 -0300 Subject: [PATCH 1/2] refactor(button): split icon-only buttons into IconButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Button becomes text-only: drop the iconLeft/iconRight props (icons compose via children) and the theme='icon' / .btn-icon styling. Icon-only buttons move to a dedicated IconButton primitive. - New IconButton (web/components/base/IconButton/) — co-located SCSS + index barrel; variant (ghost/filled), size (small/medium/large), shape (rounded/square), optional tooltip, and a *required* aria-label so icon-only buttons always have an accessible name (inner icon marked aria-hidden). - Migrate inline copy buttons, filled-tile trash buttons and the release-pipeline StageArrow add button off theme='icon' onto IconButton. - Tidy Button: drop unused theme='project' entry; fix btn-with-icon resting fill. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/Button.stories.tsx | 3 - .../components/IconButton.stories.tsx | 284 ++++++++++++++++++ .../web/components/CompareEnvironments.js | 8 +- frontend/web/components/CompareIdentities.tsx | 8 +- frontend/web/components/EditIdentity.tsx | 3 +- .../web/components/ExternalResourcesTable.tsx | 10 +- .../components/GithubRepositoriesTable.tsx | 11 +- frontend/web/components/IdentityTraits.tsx | 11 +- frontend/web/components/RolesTable.tsx | 11 +- .../base/IconButton/IconButton.scss | 81 +++++ .../components/base/IconButton/IconButton.tsx | 66 ++++ .../web/components/base/IconButton/index.ts | 7 + frontend/web/components/base/forms/Button.tsx | 13 +- .../feature-override/FeatureOverrideRow.tsx | 9 +- .../feature-summary/FeatureName.tsx | 11 +- .../components/feature-summary/FeatureRow.tsx | 9 +- .../components/pages/FlagEnvironmentsPage.tsx | 2 +- .../web/components/pages/IdentitiesPage.tsx | 11 +- .../sso/saml/SAMLAttributeMappingTable.tsx | 11 +- .../tabs/EditHealthProvider.tsx | 8 +- .../release-pipelines/StageArrow.tsx | 12 +- .../styles/components/_release-pipelines.scss | 33 ++ frontend/web/styles/project/_buttons.scss | 28 -- 23 files changed, 558 insertions(+), 92 deletions(-) create mode 100644 frontend/documentation/components/IconButton.stories.tsx create mode 100644 frontend/web/components/base/IconButton/IconButton.scss create mode 100644 frontend/web/components/base/IconButton/IconButton.tsx create mode 100644 frontend/web/components/base/IconButton/index.ts diff --git a/frontend/documentation/components/Button.stories.tsx b/frontend/documentation/components/Button.stories.tsx index 3e5533f5bbef..9ef8c0eac35a 100644 --- a/frontend/documentation/components/Button.stories.tsx +++ b/frontend/documentation/components/Button.stories.tsx @@ -74,9 +74,6 @@ export const Variants: Story = { - ), } diff --git a/frontend/documentation/components/IconButton.stories.tsx b/frontend/documentation/components/IconButton.stories.tsx new file mode 100644 index 000000000000..c1d16491c994 --- /dev/null +++ b/frontend/documentation/components/IconButton.stories.tsx @@ -0,0 +1,284 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import IconButton from 'components/base/IconButton' +import type { IconButtonProps } from 'components/base/IconButton' +import { Icon } from 'components/icons' + +const meta: Meta = { + component: IconButton, + parameters: { + docs: { + description: { + component: + 'Icon-only action button. Pass the icon as a child via `` and an `aria-label` (required — the button has no text). `variant` (ghost/filled), `size` (small/medium/large) and `shape` (rounded/square) control the look; an optional `tooltip` shows a label on hover.', + }, + }, + layout: 'centered', + }, + title: 'Components/IconButton', +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + undefined} aria-label='Copy'> + + + ), +} + +export const Sizes: Story = { + parameters: { + docs: { + description: { + story: + '`small` (32px) for inline copy buttons next to text. `medium` (40px, default) matches the legacy row-action visual. `large` (44px) for emphasised affordances.', + }, + }, + }, + render: () => ( + + + + + + small (32) + + + + + + medium (40) + + + + + + large (44) + + + ), +} + +export const Variants: Story = { + parameters: { + docs: { + description: { + story: + '`ghost` (default) is transparent at rest — used inline next to a value. `filled` has a resting surface — used as the dominant action in a row/cell.', + }, + }, + }, + render: () => ( + + + + + + ghost + + + + + + filled + + + ), +} + +export const FilledRowActions: Story = { + parameters: { + docs: { + description: { + story: + 'The `filled` variant in a list of row actions — the visible tile makes the affordance read at a glance.', + }, + }, + }, + render: () => ( +
+ {['payments-api', 'auth-service', 'notifications', 'webhooks'].map( + (label, i, arr) => ( +
+ {label} + + + +
+ ), + )} +
+ ), +} + +export const InlineWithValue: Story = { + parameters: { + docs: { + description: { + story: + 'Typical use: copy-to-clipboard button next to an identifier or value.', + }, + }, + }, + render: () => ( + + ident_8f2c4e9a-7b1d-4c5e-9a3b-2f6d8e0c1a5f + + + + + ), +} + +export const InTableRow: Story = { + parameters: { + docs: { + description: { + story: + 'Inline action in a table row — fits the 32px row affordance area without offsetting other cell content.', + }, + }, + }, + render: () => ( + + + + + + + + + + + + + + + + + + + +
NameValue +
api_key + sk_live_… + + + + +
environment + production + + + + +
+ ), +} + +export const Disabled: Story = { + render: () => ( + + + + ), +} + +export const DifferentIcons: Story = { + parameters: { + docs: { + description: { + story: 'IconButton works with any `` — copy, edit, trash, etc.', + }, + }, + }, + render: () => ( + + + + + + + + + + + + ), +} + +export const Shapes: Story = { + parameters: { + docs: { + description: { + story: + '`rounded` (default) for most contexts; `square` when the button sits flush in a segmented or gridded control.', + }, + }, + }, + render: () => ( + + + + + + rounded + + + + + + square + + + ), +} + +export const WithTooltip: Story = { + parameters: { + docs: { + description: { + story: + 'Pass `tooltip` to show a label on hover/focus. `aria-label` is still required as the accessible name.', + }, + }, + }, + render: () => ( + + + + ), +} diff --git a/frontend/web/components/CompareEnvironments.js b/frontend/web/components/CompareEnvironments.js index e3d1b17e99b3..18d85e12bbde 100644 --- a/frontend/web/components/CompareEnvironments.js +++ b/frontend/web/components/CompareEnvironments.js @@ -13,7 +13,7 @@ import Permission from 'common/providers/Permission' import Tag from './tags/Tag' import Icon from './icons/Icon' import Constants from 'common/constants' -import Button from './base/forms/Button' +import IconButton from './base/IconButton' import EmptyState from './EmptyState' import Tooltip from './Tooltip' import { withRouter } from 'react-router-dom' @@ -253,15 +253,15 @@ class CompareEnvironments extends Component { > {p.projectFlagLeft.name} - + } > diff --git a/frontend/web/components/CompareIdentities.tsx b/frontend/web/components/CompareIdentities.tsx index 588c29b087ce..6fd6d2c72f8e 100644 --- a/frontend/web/components/CompareIdentities.tsx +++ b/frontend/web/components/CompareIdentities.tsx @@ -17,6 +17,7 @@ import { sortBy } from 'lodash' import { useHasPermission } from 'common/providers/Permission' import Constants from 'common/constants' import Button from './base/forms/Button' +import IconButton from './base/IconButton' import ProjectStore from 'common/stores/project-store' import SegmentOverridesIcon from './icons/SegmentOverridesIcon' import IdentityOverridesIcon from './icons/IdentityOverridesIcon' @@ -307,13 +308,14 @@ const CompareIdentities: FC = ({ {description} - + diff --git a/frontend/web/components/EditIdentity.tsx b/frontend/web/components/EditIdentity.tsx index f9e3272bdaa0..26664ae3246b 100644 --- a/frontend/web/components/EditIdentity.tsx +++ b/frontend/web/components/EditIdentity.tsx @@ -64,7 +64,8 @@ const EditIdentity: FC = ({ data, environmentId }) => { + + ) diff --git a/frontend/web/components/GithubRepositoriesTable.tsx b/frontend/web/components/GithubRepositoriesTable.tsx index 393093467f01..1c3f20194cd5 100644 --- a/frontend/web/components/GithubRepositoriesTable.tsx +++ b/frontend/web/components/GithubRepositoriesTable.tsx @@ -3,7 +3,7 @@ import { useDeleteGithubRepositoryMutation, useUpdateGithubRepositoryMutation, } from 'common/services/useGithubRepository' -import Button from './base/forms/Button' +import IconButton from './base/IconButton' import Icon from './icons/Icon' import PanelSearch from './PanelSearch' import { GithubRepository } from 'common/types/responses' @@ -107,7 +107,9 @@ const TableRow: FC<{ />
- + +
) diff --git a/frontend/web/components/IdentityTraits.tsx b/frontend/web/components/IdentityTraits.tsx index 9ecfee353b3d..e59f11f205b1 100644 --- a/frontend/web/components/IdentityTraits.tsx +++ b/frontend/web/components/IdentityTraits.tsx @@ -3,6 +3,7 @@ import PanelSearch from './PanelSearch' import Utils from 'common/utils/utils' import Constants from 'common/constants' import Button from './base/forms/Button' +import IconButton from './base/IconButton' import FeatureValue from './feature-summary/FeatureValue' import Icon from './icons/Icon' import Panel from './base/grid/Panel' @@ -184,16 +185,16 @@ const IdentityTraits: FC = ({ Constants.environmentPermissions( EnvironmentPermission.MANAGE_IDENTITIES, ), - , + + , )} diff --git a/frontend/web/components/RolesTable.tsx b/frontend/web/components/RolesTable.tsx index c5d09e55fd08..ca5a07dcaed7 100644 --- a/frontend/web/components/RolesTable.tsx +++ b/frontend/web/components/RolesTable.tsx @@ -4,6 +4,7 @@ import { useGetRolesQuery } from 'common/services/useRole' import { User, Role } from 'common/types/responses' import PanelSearch from './PanelSearch' import Button from './base/forms/Button' +import IconButton from './base/IconButton' import ConfirmDeleteRole from './modals/ConfirmDeleteRole' import Icon from './icons/Icon' import Panel from './base/grid/Panel' @@ -158,16 +159,16 @@ const RolesTable: FC = ({ organisationId, users }) => { }} className='table-column text-center' > - + + )} diff --git a/frontend/web/components/base/IconButton/IconButton.scss b/frontend/web/components/base/IconButton/IconButton.scss new file mode 100644 index 000000000000..6a11260d01d1 --- /dev/null +++ b/frontend/web/components/base/IconButton/IconButton.scss @@ -0,0 +1,81 @@ +// Reset, focus-visible ring and disabled pointer-events come from BareButton +// (.bare-btn). IconButton only adds icon-specific layout, sizing and surface. +.icon-button { + justify-content: center; + transition: background-color var(--duration-fast) var(--easing-standard); + + &__icon { + display: inline-flex; + } + + path { + fill: var(--color-icon-secondary); + transition: fill var(--duration-fast) var(--easing-standard); + } + + &:disabled { + opacity: 0.5; + } + + // Shape modifiers + &--rounded { + border-radius: var(--radius-md); + } + + &--square { + border-radius: var(--radius-sm); + } + + // Size modifiers + &--small { + width: 32px; + height: 32px; + svg { + width: 18px; + height: 18px; + } + } + + &--medium { + width: 40px; + height: 40px; + svg { + width: 20px; + height: 20px; + } + } + + &--large { + width: 44px; + height: 44px; + svg { + width: 22px; + height: 22px; + } + } + + // Variant modifiers + &--ghost { + background: transparent; + + &:hover { + background-color: var(--color-surface-hover); + + path { + fill: var(--color-icon-default); + } + } + } + + &--filled { + background-color: var(--color-surface-muted); + + &:hover { + background-color: var(--color-surface-emphasis); + + path { + fill: var(--color-icon-default); + } + } + } +} diff --git a/frontend/web/components/base/IconButton/IconButton.tsx b/frontend/web/components/base/IconButton/IconButton.tsx new file mode 100644 index 000000000000..33e7f16a5b96 --- /dev/null +++ b/frontend/web/components/base/IconButton/IconButton.tsx @@ -0,0 +1,66 @@ +import React, { ButtonHTMLAttributes, forwardRef } from 'react' +import cn from 'classnames' +import BareButton from 'components/base/forms/BareButton' +import Tooltip from 'components/Tooltip' +import './IconButton.scss' + +export type IconButtonVariant = 'ghost' | 'filled' +export type IconButtonSize = 'small' | 'medium' | 'large' +export type IconButtonShape = 'rounded' | 'square' + +export type IconButtonProps = Omit< + ButtonHTMLAttributes, + 'aria-label' +> & { + // Required: an icon-only button has no text, so it needs an explicit + // accessible name. + 'aria-label': string + variant?: IconButtonVariant + size?: IconButtonSize + shape?: IconButtonShape + // Optional visible label shown on hover/focus. + tooltip?: string +} + +// Built on BareButton, so the reset, focus-visible ring and disabled +// semantics are shared; IconButton only adds the icon-specific sizing, +// shape and surface styling. +const IconButton = forwardRef( + ( + { + children, + className, + shape = 'rounded', + size = 'medium', + tooltip, + variant = 'ghost', + ...rest + }, + ref, + ) => { + const button = ( + + {/* aria-hidden so the accessible name comes only from aria-label */} + + {children} + + + ) + + return tooltip ? {tooltip} : button + }, +) + +IconButton.displayName = 'IconButton' + +export default IconButton diff --git a/frontend/web/components/base/IconButton/index.ts b/frontend/web/components/base/IconButton/index.ts new file mode 100644 index 000000000000..529107379aa7 --- /dev/null +++ b/frontend/web/components/base/IconButton/index.ts @@ -0,0 +1,7 @@ +export { default } from './IconButton' +export type { + IconButtonProps, + IconButtonVariant, + IconButtonSize, + IconButtonShape, +} from './IconButton' diff --git a/frontend/web/components/base/forms/Button.tsx b/frontend/web/components/base/forms/Button.tsx index 611348647e22..7bc4dd394154 100644 --- a/frontend/web/components/base/forms/Button.tsx +++ b/frontend/web/components/base/forms/Button.tsx @@ -4,7 +4,6 @@ import { ButtonHTMLAttributes, HTMLAttributeAnchorTarget } from 'react' export const themeClassNames = { danger: 'btn-danger', - icon: 'btn-icon', outline: 'btn--outline', primary: 'btn-primary', secondary: 'btn-secondary', @@ -52,6 +51,14 @@ export const Button = React.forwardRef< themeClassNames[theme], sizeClassNames[size], ) + const content = + React.Children.count(children) > 1 ? ( + + {children} + + ) : ( + children + ) return href ? ( } > - {children} + {content} ) : ( ) }, diff --git a/frontend/web/components/feature-override/FeatureOverrideRow.tsx b/frontend/web/components/feature-override/FeatureOverrideRow.tsx index d6f8c87a615d..a49e660f49ce 100644 --- a/frontend/web/components/feature-override/FeatureOverrideRow.tsx +++ b/frontend/web/components/feature-override/FeatureOverrideRow.tsx @@ -21,7 +21,7 @@ import { getViewMode } from 'common/useViewMode' import { useHasPermission } from 'common/providers/Permission' import API from 'project/api' import Constants from 'common/constants' -import Button from 'components/base/forms/Button' +import IconButton from 'components/base/IconButton' import Icon from 'components/icons/Icon' import CreateFlagModal from 'components/modals/create-feature' import { useHistory } from 'react-router-dom' @@ -127,15 +127,16 @@ const FeatureOverrideRow: FC = ({ Edit User Feature:{' '} {projectFlag.name} - + , = ({ name }) => { }} > {name} - + ) } diff --git a/frontend/web/components/feature-summary/FeatureRow.tsx b/frontend/web/components/feature-summary/FeatureRow.tsx index 37f7c094313c..7f10b6acbd63 100644 --- a/frontend/web/components/feature-summary/FeatureRow.tsx +++ b/frontend/web/components/feature-summary/FeatureRow.tsx @@ -10,7 +10,7 @@ import Icon from 'components/icons/Icon' import FeatureValue from './FeatureValue' import FeatureAction, { FeatureActionProps } from './FeatureAction' import classNames from 'classnames' -import Button from 'components/base/forms/Button' +import IconButton from 'components/base/IconButton' import { Environment, FeatureListProviderData, @@ -250,15 +250,16 @@ const FeatureRow: FC = (props) => { {permission ? 'Edit Feature' : 'Feature'}: {projectFlag.name} - + {ownerChips.length > 0 && (
diff --git a/frontend/web/components/pages/FlagEnvironmentsPage.tsx b/frontend/web/components/pages/FlagEnvironmentsPage.tsx index 807daea4e3c7..90fac5b544ad 100644 --- a/frontend/web/components/pages/FlagEnvironmentsPage.tsx +++ b/frontend/web/components/pages/FlagEnvironmentsPage.tsx @@ -6,7 +6,7 @@ import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' import { useGetFeatureStatesQuery } from 'common/services/useFeatureState' import { useGetProjectQuery } from 'common/services/useProject' import Panel from 'components/base/grid/Panel' -import Icon from 'components/icons/Icon' +import { Icon } from 'components/icons' import TagValues from 'components/tags/TagValues' import Switch from 'components/Switch' import Button from 'components/base/forms/Button' diff --git a/frontend/web/components/pages/IdentitiesPage.tsx b/frontend/web/components/pages/IdentitiesPage.tsx index a2bab29895e3..76a1e743f13b 100644 --- a/frontend/web/components/pages/IdentitiesPage.tsx +++ b/frontend/web/components/pages/IdentitiesPage.tsx @@ -13,6 +13,7 @@ import { Req } from 'common/types/requests' import CreateUserModal from 'components/modals/CreateUser' import PanelSearch from 'components/PanelSearch' import Button from 'components/base/forms/Button' +import IconButton from 'components/base/IconButton' import JSONReference from 'components/JSONReference' import Utils from 'common/utils/utils' import Icon from 'components/icons/Icon' @@ -264,10 +265,10 @@ const IdentitiesPage: FC<{ props: any }> = (props) => {
- + +
) : ( diff --git a/frontend/web/components/pages/organisation-settings/tabs/sso/saml/SAMLAttributeMappingTable.tsx b/frontend/web/components/pages/organisation-settings/tabs/sso/saml/SAMLAttributeMappingTable.tsx index 68c4aaff1d4b..883cc1f665d1 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/sso/saml/SAMLAttributeMappingTable.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/sso/saml/SAMLAttributeMappingTable.tsx @@ -8,6 +8,7 @@ import { useDeleteSamlAttributeMappingMutation, useGetSamlAttributeMappingQuery, } from 'common/services/useSamlAttributeMapping' +import IconButton from 'components/base/IconButton' type SAMLAttributeMappingTableType = { samlConfigurationId: number @@ -77,10 +78,11 @@ const SAMLAttributeMappingTable: FC = ({
- + +
)} diff --git a/frontend/web/components/pages/project-settings/tabs/EditHealthProvider.tsx b/frontend/web/components/pages/project-settings/tabs/EditHealthProvider.tsx index e8917caa9a8d..797cc2470132 100644 --- a/frontend/web/components/pages/project-settings/tabs/EditHealthProvider.tsx +++ b/frontend/web/components/pages/project-settings/tabs/EditHealthProvider.tsx @@ -2,6 +2,7 @@ import React, { FC, useEffect } from 'react' import { HealthProvider } from 'common/types/responses' import PanelSearch from 'components/PanelSearch' import Button from 'components/base/forms/Button' +import IconButton from 'components/base/IconButton' import Icon from 'components/icons/Icon' @@ -205,15 +206,16 @@ const EditHealthProvider: FC = ({ > {webhook} - + )} diff --git a/frontend/web/components/release-pipelines/StageArrow.tsx b/frontend/web/components/release-pipelines/StageArrow.tsx index 9c23721d117c..82ea7d4f18e7 100644 --- a/frontend/web/components/release-pipelines/StageArrow.tsx +++ b/frontend/web/components/release-pipelines/StageArrow.tsx @@ -1,4 +1,3 @@ -import Button from 'components/base/forms/Button' import Icon from 'components/icons/Icon' type StageArrowProps = { @@ -12,13 +11,14 @@ const StageArrow = ({ onAddStage, showAddStageButton }: StageArrowProps) => {
{showAddStageButton && ( - + + )}
diff --git a/frontend/web/styles/components/_release-pipelines.scss b/frontend/web/styles/components/_release-pipelines.scss index 67848ae49c21..401559394a61 100644 --- a/frontend/web/styles/components/_release-pipelines.scss +++ b/frontend/web/styles/components/_release-pipelines.scss @@ -24,3 +24,36 @@ } } } + +.stage-add-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--color-border-action); + border-radius: var(--radius-full); + background: transparent; + cursor: pointer; + color: var(--color-icon-action); + transition: background-color var(--duration-fast) var(--easing-standard); + + svg { + width: 20px; + height: 20px; + } + + path { + fill: var(--color-icon-action); + } + + &:hover { + background-color: var(--color-surface-action-subtle); + } + + &:focus-visible { + outline: 2px solid var(--color-border-action); + outline-offset: 2px; + } +} diff --git a/frontend/web/styles/project/_buttons.scss b/frontend/web/styles/project/_buttons.scss index 5885e9d75dbe..97b52e0cd7ae 100644 --- a/frontend/web/styles/project/_buttons.scss +++ b/frontend/web/styles/project/_buttons.scss @@ -141,28 +141,6 @@ button.btn { } } - &.btn-icon { - padding:0; - height: 32px; - width: 32px; - display: flex; - align-items: center; - justify-content: center; - color: $body-color; - svg { - width: 18px; - height: 18px; - } - path { - fill: var(--color-icon-secondary); - } - &:hover,&:focus { - background-color: $bg-light300; - path { - fill: var(--color-icon-default); - } - } - } &-with-icon { padding: 0 14px; background-color: $basic-alpha-8; @@ -350,12 +328,6 @@ $add-btn-size: 34px; .dark { .btn { - &.btn-icon { - color: $body-color-dark; - &:hover,&:focus { - background-color: $bg-dark300; - } - } &.btn-secondary { color: white; background-color: $btn-secondary-bg-dark !important; From 9c5eec4a33f25613917133b76750c016a45623cf Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 18 Jun 2026 12:15:07 -0300 Subject: [PATCH 2/2] refactor(button): drop redundant content wrapper, rely on .btn flex+gap The icon+text spacing the wrapper added is already provided by .btn (inline-flex; align-items: center; justify-content: center; gap: 0.5rem), so the React.Children.count heuristic and the inner span were redundant. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/web/components/base/forms/Button.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend/web/components/base/forms/Button.tsx b/frontend/web/components/base/forms/Button.tsx index 7bc4dd394154..3e381d500e8c 100644 --- a/frontend/web/components/base/forms/Button.tsx +++ b/frontend/web/components/base/forms/Button.tsx @@ -51,14 +51,6 @@ export const Button = React.forwardRef< themeClassNames[theme], sizeClassNames[size], ) - const content = - React.Children.count(children) > 1 ? ( - - {children} - - ) : ( - children - ) return href ? ( } > - {content} + {children} ) : ( ) },