diff --git a/apps/docs/docs/components/inputs/IconButton/_mobileStyles.mdx b/apps/docs/docs/components/inputs/IconButton/_mobileStyles.mdx new file mode 100644 index 0000000000..bad1aed150 --- /dev/null +++ b/apps/docs/docs/components/inputs/IconButton/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/buttons/IconButton/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/IconButton/_webStyles.mdx b/apps/docs/docs/components/inputs/IconButton/_webStyles.mdx new file mode 100644 index 0000000000..448a432203 --- /dev/null +++ b/apps/docs/docs/components/inputs/IconButton/_webStyles.mdx @@ -0,0 +1,21 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { IconButton } from '@coinbase/cds-web/buttons'; + +import webStylesData from ':docgen/web/buttons/IconButton/styles-data'; + +## Explorer + + + {(classNames) => ( + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/IconButton/index.mdx b/apps/docs/docs/components/inputs/IconButton/index.mdx index af5a7562fd..3970a9d62a 100644 --- a/apps/docs/docs/components/inputs/IconButton/index.mdx +++ b/apps/docs/docs/components/inputs/IconButton/index.mdx @@ -16,6 +16,8 @@ import mobilePropsToc from ':docgen/mobile/buttons/IconButton/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; @@ -27,18 +29,22 @@ import mobileMetadata from './mobileMetadata.json'; title="IconButton" description="A button that renders an official icon as content instead of text." webMetadata={webMetadata} - mobileMetadata={mobileMetadata} + mobileMetadata={mobileMetadata} banner={} /> } + webStyles={} webExamples={} mobilePropsTable={} + mobileStyles={} mobileExamples={} webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} webPropsToc={webPropsToc} + webStylesToc={webStylesToc} mobilePropsToc={mobilePropsToc} + mobileStylesToc={mobileStylesToc} /> diff --git a/packages/mobile/src/buttons/IconButton.tsx b/packages/mobile/src/buttons/IconButton.tsx index df85653647..d15a201f2f 100644 --- a/packages/mobile/src/buttons/IconButton.tsx +++ b/packages/mobile/src/buttons/IconButton.tsx @@ -1,5 +1,11 @@ import { forwardRef, memo, useCallback, useMemo } from 'react'; -import { type PressableStateCallbackType, type View, type ViewStyle } from 'react-native'; +import { + type PressableStateCallbackType, + type StyleProp, + type TextStyle, + type View, + type ViewStyle, +} from 'react-native'; import { transparentVariants, variants } from '@coinbase/cds-common/tokens/button'; import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; import type { @@ -39,6 +45,11 @@ export type IconButtonBaseProps = SharedProps & * @default primary */ variant?: IconButtonVariant; + /** Custom styles for individual elements of the IconButton component */ + styles?: { + /** Inner icon glyph Text element */ + icon?: StyleProp; + }; }; export type IconButtonProps = IconButtonBaseProps; @@ -65,6 +76,7 @@ export const IconButton = memo( loading, progressCircleSize, style, + styles, accessibilityHint, accessibilityLabel, ...props @@ -134,6 +146,7 @@ export const IconButton = memo( name={name} size={iconSize} style={sizingStyle} + styles={{ icon: styles?.icon }} /> )} diff --git a/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx b/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx index d54c910e73..1864759b37 100644 --- a/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx +++ b/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx @@ -129,6 +129,27 @@ const IconButtonScreen = () => { + + + + + Custom color via styles.icon + + + + Rotated icon via styles.icon + + + + diff --git a/packages/mobile/src/buttons/__tests__/IconButton.test.tsx b/packages/mobile/src/buttons/__tests__/IconButton.test.tsx index 8ed8bf4abf..9ac869fe85 100644 --- a/packages/mobile/src/buttons/__tests__/IconButton.test.tsx +++ b/packages/mobile/src/buttons/__tests__/IconButton.test.tsx @@ -142,4 +142,17 @@ describe('IconButton', () => { // Should be "loading" when no accessibility label is provided expect(button.props.accessibilityLabel).toBe(', loading'); }); + + it('applies styles.icon to the inner icon glyph', () => { + const customIconStyle = { fontSize: 99 }; + const { UNSAFE_getAllByType } = render( + + + , + ); + + const [iconText] = UNSAFE_getAllByType(Text); + // Mobile Icon builds iconStyle as [baseStyles, styles?.icon] + expect(iconText.props.style[1]).toEqual(customIconStyle); + }); }); diff --git a/packages/web/src/buttons/IconButton.tsx b/packages/web/src/buttons/IconButton.tsx index 81f54021db..f2ebc855e1 100644 --- a/packages/web/src/buttons/IconButton.tsx +++ b/packages/web/src/buttons/IconButton.tsx @@ -9,11 +9,21 @@ import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; +import type { StylesAndClassNames } from '../types'; import { ProgressCircle } from '../visualizations/ProgressCircle'; import { type ButtonBaseProps } from './Button'; -const COMPONENT_STATIC_CLASSNAME = 'cds-IconButton'; +/** + * Static class names for IconButton component parts. + * Use these selectors to target specific elements with CSS. + */ +export const iconButtonClassNames = { + /** Root button element */ + root: 'cds-IconButton', + /** Inner icon glyph element */ + icon: 'cds-IconButton-icon', +} as const; export const iconButtonDefaultElement = 'button'; @@ -42,7 +52,8 @@ export type IconButtonBaseProps = Polymorphic.ExtendableProps< export type IconButtonProps = Polymorphic.Props< AsComponent, IconButtonBaseProps ->; +> & + StylesAndClassNames; type IconButtonComponent = (( props: IconButtonProps, @@ -94,6 +105,8 @@ export const IconButton: IconButtonComponent = memo( progressCircleSize, accessibilityLabel, accessibilityHint, + styles, + classNames, ...props } = mergedProps; const Component = (as ?? iconButtonDefaultElement) satisfies React.ElementType; @@ -120,10 +133,11 @@ export const IconButton: IconButtonComponent = memo( borderRadius={borderRadius} borderWidth={borderWidth} className={cx( - COMPONENT_STATIC_CLASSNAME, + iconButtonClassNames.root, flush && flushSpaceCss, flush === 'start' && flushStartCss, flush === 'end' && flushEndCss, + classNames?.root, className, )} color={colorValue} @@ -148,7 +162,14 @@ export const IconButton: IconButtonComponent = memo( weight="thin" /> ) : ( - + )} ); diff --git a/packages/web/src/buttons/__stories__/IconButton.stories.tsx b/packages/web/src/buttons/__stories__/IconButton.stories.tsx index ecf888ac70..81289d64fd 100644 --- a/packages/web/src/buttons/__stories__/IconButton.stories.tsx +++ b/packages/web/src/buttons/__stories__/IconButton.stories.tsx @@ -1,10 +1,16 @@ import React from 'react'; import { names } from '@coinbase/cds-icons/names'; +import { css } from '@linaria/core'; import { HStack, VStack } from '../../layout'; import { Text } from '../../typography/Text'; import { IconButton, type IconButtonBaseProps } from '../IconButton'; +const rotatedIconCss = css` + transform: rotate(45deg); + transition: transform 200ms ease-in-out; +`; + const iconName = 'arrowsHorizontal'; const accessibilityLabel = 'Horizontal arrows'; @@ -91,6 +97,25 @@ export const Default = () => ( style={{ backgroundColor: 'red', transform: 'scale(0.5)' }} /> + + Icon Glyph Styles + + + Custom color via styles.icon + + + + Rotated icon via classNames.icon + + Variants {variants.map((variant, index) => ( diff --git a/packages/web/src/buttons/__tests__/IconButton.test.tsx b/packages/web/src/buttons/__tests__/IconButton.test.tsx index 4b6992441a..3c26a7d0c7 100644 --- a/packages/web/src/buttons/__tests__/IconButton.test.tsx +++ b/packages/web/src/buttons/__tests__/IconButton.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { defaultTheme } from '../../themes/defaultTheme'; import { DefaultThemeProvider } from '../../utils/test'; -import { IconButton } from '../IconButton'; +import { IconButton, iconButtonClassNames } from '../IconButton'; const name = 'arrowsHorizontal'; @@ -223,4 +223,40 @@ describe('IconButton', () => { expect(button).toHaveAttribute('data-variant', 'secondary'); expect(button).toHaveAttribute('data-compact', 'true'); }); + + describe('static classNames', () => { + it('applies static class names to component elements', () => { + render( + + + , + ); + + const button = screen.getByRole('button'); + expect(button).toHaveClass(iconButtonClassNames.root); + expect(screen.getByTestId('icon-base-glyph')).toHaveClass(iconButtonClassNames.icon); + }); + }); + + describe('styles and classNames', () => { + it('applies styles.icon to the inner icon glyph element', () => { + render( + + + , + ); + + expect(screen.getByTestId('icon-base-glyph')).toHaveStyle({ fontSize: '99px' }); + }); + + it('applies classNames.icon to the inner icon glyph element', () => { + render( + + + , + ); + + expect(screen.getByTestId('icon-base-glyph')).toHaveClass('custom-icon-class'); + }); + }); });