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
7 changes: 7 additions & 0 deletions apps/docs/docs/components/inputs/IconButton/_mobileStyles.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable';

import mobileStylesData from ':docgen/mobile/buttons/IconButton/styles-data';

## Selectors

<ComponentStylesTable componentName="IconButton" styles={mobileStylesData} />
21 changes: 21 additions & 0 deletions apps/docs/docs/components/inputs/IconButton/_webStyles.mdx
Original file line number Diff line number Diff line change
@@ -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

<StylesExplorer selectors={webStylesData.selectors}>
{(classNames) => (
<IconButton
accessibilityLabel="Horizontal arrows"
classNames={classNames}
name="arrowsHorizontal"
/>
)}
</StylesExplorer>

## Selectors

<ComponentStylesTable componentName="IconButton" styles={webStylesData} />
8 changes: 7 additions & 1 deletion apps/docs/docs/components/inputs/IconButton/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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={<IconButtonBanner />}
/>

<ComponentTabsContainer
webPropsTable={<WebPropsTable />}
webStyles={<WebStyles />}
webExamples={<WebExamples />}
mobilePropsTable={<MobilePropsTable />}
mobileStyles={<MobileStyles />}
mobileExamples={<MobileExamples />}
webExamplesToc={webExamplesToc}
mobileExamplesToc={mobileExamplesToc}
webPropsToc={webPropsToc}
webStylesToc={webStylesToc}
mobilePropsToc={mobilePropsToc}
mobileStylesToc={mobileStylesToc}
/>
</VStack>
15 changes: 14 additions & 1 deletion packages/mobile/src/buttons/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<TextStyle>;
};
};

export type IconButtonProps = IconButtonBaseProps;
Expand All @@ -65,6 +76,7 @@ export const IconButton = memo(
loading,
progressCircleSize,
style,
styles,
accessibilityHint,
accessibilityLabel,
...props
Expand Down Expand Up @@ -134,6 +146,7 @@ export const IconButton = memo(
name={name}
size={iconSize}
style={sizingStyle}
styles={{ icon: styles?.icon }}
/>
)}
</Pressable>
Expand Down
21 changes: 21 additions & 0 deletions packages/mobile/src/buttons/__stories__/IconButton.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,27 @@ const IconButtonScreen = () => {
</Box>
</Example>

<Example inline title="Icon Glyph Styles">
<VStack gap={2}>
<Box alignItems="center" flexDirection="row" gap={2}>
<IconButton
accessibilityLabel="Custom color via styles.icon"
name={iconName}
styles={{ icon: { color: 'dodgerblue' } }}
/>
<Text font="body">Custom color via styles.icon</Text>
</Box>
<Box alignItems="center" flexDirection="row" gap={2}>
<IconButton
accessibilityLabel="Rotated icon via styles.icon"
name={iconName}
styles={{ icon: { transform: [{ rotate: '45deg' }] } }}
/>
<Text font="body">Rotated icon via styles.icon</Text>
</Box>
</VStack>
</Example>

<Example inline title="Loading">
<VStack gap={3}>
<Box>
Expand Down
13 changes: 13 additions & 0 deletions packages/mobile/src/buttons/__tests__/IconButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DefaultThemeProvider>
<IconButton name={name} styles={{ icon: customIconStyle }} />
</DefaultThemeProvider>,
);

const [iconText] = UNSAFE_getAllByType(Text);
// Mobile Icon builds iconStyle as [baseStyles, styles?.icon]
expect(iconText.props.style[1]).toEqual(customIconStyle);
});
});
29 changes: 25 additions & 4 deletions packages/web/src/buttons/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -42,7 +52,8 @@ export type IconButtonBaseProps = Polymorphic.ExtendableProps<
export type IconButtonProps<AsComponent extends React.ElementType> = Polymorphic.Props<
AsComponent,
IconButtonBaseProps
>;
> &
StylesAndClassNames<typeof iconButtonClassNames>;

type IconButtonComponent = (<AsComponent extends React.ElementType = IconButtonDefaultElement>(
props: IconButtonProps<AsComponent>,
Expand Down Expand Up @@ -94,6 +105,8 @@ export const IconButton: IconButtonComponent = memo(
progressCircleSize,
accessibilityLabel,
accessibilityHint,
styles,
classNames,
...props
} = mergedProps;
const Component = (as ?? iconButtonDefaultElement) satisfies React.ElementType;
Expand All @@ -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}
Expand All @@ -148,7 +162,14 @@ export const IconButton: IconButtonComponent = memo(
weight="thin"
/>
) : (
<Icon active={active} color="currentColor" name={name} size={iconSize} />
<Icon
active={active}
classNames={{ icon: cx(iconButtonClassNames.icon, classNames?.icon) }}
color="currentColor"
name={name}
size={iconSize}
styles={{ icon: styles?.icon }}
/>
)}
</Pressable>
);
Expand Down
25 changes: 25 additions & 0 deletions packages/web/src/buttons/__stories__/IconButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -91,6 +97,25 @@ export const Default = () => (
style={{ backgroundColor: 'red', transform: 'scale(0.5)' }}
/>
</VStack>
<VStack gap={2}>
<Text font="title3">Icon Glyph Styles</Text>
<HStack alignItems="center" gap={4}>
<IconButton
accessibilityLabel="Custom color via styles.icon"
name={iconName}
styles={{ icon: { color: 'dodgerblue' } }}
/>
<Text font="body">Custom color via styles.icon</Text>
</HStack>
<HStack alignItems="center" gap={4}>
<IconButton
accessibilityLabel="Rotated icon via classNames.icon"
classNames={{ icon: rotatedIconCss }}
name={iconName}
/>
<Text font="body">Rotated icon via classNames.icon</Text>
</HStack>
</VStack>
<VStack gap={2}>
<Text font="title3">Variants</Text>
{variants.map((variant, index) => (
Expand Down
38 changes: 37 additions & 1 deletion packages/web/src/buttons/__tests__/IconButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
<DefaultThemeProvider>
<IconButton name={name} />
</DefaultThemeProvider>,
);

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(
<DefaultThemeProvider>
<IconButton name={name} styles={{ icon: { fontSize: '99px' } }} />
</DefaultThemeProvider>,
);

expect(screen.getByTestId('icon-base-glyph')).toHaveStyle({ fontSize: '99px' });
});

it('applies classNames.icon to the inner icon glyph element', () => {
render(
<DefaultThemeProvider>
<IconButton classNames={{ icon: 'custom-icon-class' }} name={name} />
</DefaultThemeProvider>,
);

expect(screen.getByTestId('icon-base-glyph')).toHaveClass('custom-icon-class');
});
});
});
Loading