Improve types for both Button and IconButton

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-18 12:16:21 +01:00
parent 3c1752b839
commit be519bc5ff
10 changed files with 200 additions and 265 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ Here's a view when buttons are responsive.
### As Link
Use the `href` prop to make a button act as a link.
You can use the `as` prop to make a button act as a link.
<Snippet
align="center"
@@ -62,6 +62,12 @@ export const buttonResponsiveSnippet = `<Button variant={{ initial: 'primary', l
Responsive Button
</Button>`;
export const buttonAsLinkSnippet = `<Button href="https://canon.backstage.io" target="_blank">
export const buttonAsLinkSnippet = `// Using the \`as\` prop
<Button as="a" href="https://canon.backstage.io" target="_blank">
I am a link
</Button>
// Using a custom component
<Button as={Link} to="/">
I am a using a custom component
</Button>`;
+51 -65
View File
@@ -3,10 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnchorHTMLAttributes } from 'react';
import { Avatar as Avatar_2 } from '@base-ui-components/react/avatar';
import { Breakpoint as Breakpoint_2 } from '@backstage/canon';
import { ButtonHTMLAttributes } from 'react';
import { ChangeEvent } from 'react';
import { Collapsible as Collapsible_2 } from '@base-ui-components/react/collapsible';
import { ComponentProps } from 'react';
@@ -44,6 +42,11 @@ export type ArbitraryStylingPropDef = {
parseValue?: (value: string) => string | undefined;
};
// @public (undocumented)
export type AsProp<C extends React.ElementType> = {
as?: C;
};
// @public (undocumented)
export type AsProps =
| 'div'
@@ -153,40 +156,26 @@ export type Breakpoint = 'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export const breakpoints: Breakpoint[];
// @public (undocumented)
export const Button: ForwardRefExoticComponent<
ButtonProps & RefAttributes<HTMLElement>
>;
// @public (undocumented)
export type ButtonAnchorProps = ButtonCommonProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'type' | 'onClick'> & {
href: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
// @public (undocumented)
export type ButtonCommonProps = {
size?: 'small' | 'medium' | Partial<Record<Breakpoint_2, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint_2, 'primary' | 'secondary'>>;
iconStart?: ReactElement;
iconEnd?: ReactElement;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
// @public (undocumented)
export type ButtonNativeProps = ButtonCommonProps &
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'href'> & {
href?: undefined;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
export const Button: <C extends React.ElementType = 'button'>(
props: ButtonProps<C>,
) => JSX_2.Element;
// @public
export type ButtonProps = ButtonAnchorProps | ButtonNativeProps;
export type ButtonProps<C extends React.ElementType> = PolymorphicComponentProp<
C,
{
size?:
| 'small'
| 'medium'
| Partial<Record<Breakpoint_2, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint_2, 'primary' | 'secondary'>>;
iconStart?: ReactElement;
iconEnd?: ReactElement;
}
>;
// @public (undocumented)
export const Checkbox: ForwardRefExoticComponent<
@@ -696,39 +685,26 @@ export type HeightProps = GetPropDefTypes<typeof heightPropDefs>;
export const Icon: (props: IconProps) => JSX_2.Element | null;
// @public (undocumented)
export const IconButton: ForwardRefExoticComponent<
IconButtonProps & RefAttributes<HTMLElement>
>;
// @public (undocumented)
export type IconButtonAnchorProps = IconButtonCommonProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'type' | 'onClick'> & {
href: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
// @public (undocumented)
export type IconButtonCommonProps = {
size?: 'small' | 'medium' | Partial<Record<Breakpoint_2, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint_2, 'primary' | 'secondary'>>;
icon?: ReactElement;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
// @public (undocumented)
export type IconButtonNativeProps = IconButtonCommonProps &
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'href'> & {
href?: undefined;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
export const IconButton: <C extends React.ElementType = 'button'>(
props: IconButtonProps<C>,
) => JSX_2.Element;
// @public
export type IconButtonProps = IconButtonAnchorProps | IconButtonNativeProps;
export type IconButtonProps<C extends React.ElementType> =
PolymorphicComponentProp<
C,
{
size?:
| 'small'
| 'medium'
| Partial<Record<Breakpoint_2, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint_2, 'primary' | 'secondary'>>;
icon?: ReactElement;
}
>;
// @public (undocumented)
export const IconContext: Context<IconContextProps>;
@@ -1025,6 +1001,13 @@ export const paddingPropDefs: (spacingValues: string[]) => {
// @public (undocumented)
export type PaddingProps = GetPropDefTypes<typeof paddingPropDefs>;
// @public
export type PolymorphicComponentProp<
C extends React.ElementType,
Props = {},
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
// @public (undocumented)
export const positionPropDefs: {
position: {
@@ -1041,6 +1024,9 @@ export type PositionProps = GetPropDefTypes<typeof positionPropDefs>;
// @public (undocumented)
export type PropDef<T = any> = RegularPropDef<T> | ResponsivePropDef<T>;
// @public (undocumented)
export type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
// @public (undocumented)
export type ReactNodePropDef = {
type: 'ReactNode';
@@ -133,12 +133,26 @@ export const Disabled: Story = {
export const AsLink: Story = {
args: {
children: 'I am a link',
children: 'Button',
href: 'https://canon.backstage.io',
target: '_blank',
},
};
export const AsComponent: Story = {
render: () => {
const Link = (props: { children: React.ReactNode; to: string }) => (
<a {...props} />
);
return (
<Button as={Link} to="/">
I am a using a custom component
</Button>
);
},
};
export const Responsive: Story = {
args: {
children: 'Button',
@@ -167,22 +181,22 @@ export const Playground: Story = {
{['small', 'medium'].map(size => (
<Flex align="center" key={size}>
<Button
variant={variant as ButtonProps['variant']}
size={size as ButtonProps['size']}
variant={variant as ButtonProps<any>['variant']}
size={size as ButtonProps<any>['size']}
>
Button
</Button>
<Button
iconStart={<Icon name="cloud" />}
variant={variant as ButtonProps['variant']}
size={size as ButtonProps['size']}
variant={variant as ButtonProps<any>['variant']}
size={size as ButtonProps<any>['size']}
>
Button
</Button>
<Button
iconEnd={<Icon name="chevron-right" />}
variant={variant as ButtonProps['variant']}
size={size as ButtonProps['size']}
variant={variant as ButtonProps<any>['variant']}
size={size as ButtonProps<any>['size']}
>
Button
</Button>
@@ -190,30 +204,30 @@ export const Playground: Story = {
iconStart={<Icon name="cloud" />}
iconEnd={<Icon name="chevron-right" />}
style={{ width: '200px' }}
variant={variant as ButtonProps['variant']}
size={size as ButtonProps['size']}
variant={variant as ButtonProps<any>['variant']}
size={size as ButtonProps<any>['size']}
>
Button
</Button>
<Button
variant={variant as ButtonProps['variant']}
size={size as ButtonProps['size']}
variant={variant as ButtonProps<any>['variant']}
size={size as ButtonProps<any>['size']}
disabled
>
Button
</Button>
<Button
iconStart={<Icon name="cloud" />}
variant={variant as ButtonProps['variant']}
size={size as ButtonProps['size']}
variant={variant as ButtonProps<any>['variant']}
size={size as ButtonProps<any>['size']}
disabled
>
Button
</Button>
<Button
iconEnd={<Icon name="chevron-right" />}
variant={variant as ButtonProps['variant']}
size={size as ButtonProps['size']}
variant={variant as ButtonProps<any>['variant']}
size={size as ButtonProps<any>['size']}
disabled
>
Button
+14 -48
View File
@@ -14,32 +14,36 @@
* limitations under the License.
*/
import { AnchorHTMLAttributes, ButtonHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import type { ButtonProps } from './types';
/** @public */
export const Button = forwardRef<HTMLElement, ButtonProps>((props, ref) => {
export const Button = <C extends React.ElementType = 'button'>(
props: ButtonProps<C>,
) => {
const {
as,
size = 'small',
variant = 'primary',
iconStart,
iconEnd,
children,
href,
className,
style,
...rest
} = props;
const isAnchor = typeof props.href === 'string';
const Component = as || 'button';
const responsiveSize = useResponsiveValue(size);
const responsiveVariant = useResponsiveValue(variant);
const content = (
<>
return (
<Component
className={clsx('canon-Button', className)}
data-variant={responsiveVariant}
data-size={responsiveSize}
{...rest}
>
{iconStart && (
<span
className="canon-ButtonIcon"
@@ -59,44 +63,6 @@ export const Button = forwardRef<HTMLElement, ButtonProps>((props, ref) => {
{iconEnd}
</span>
)}
</>
</Component>
);
if (isAnchor) {
const { onClick, ...anchorRest } =
rest as AnchorHTMLAttributes<HTMLAnchorElement>;
return (
<a
href={href}
className={clsx('canon-Button', className)}
data-variant={responsiveVariant}
data-size={responsiveSize}
style={style}
ref={ref as React.Ref<HTMLAnchorElement>}
onClick={onClick}
{...anchorRest}
>
{content}
</a>
);
} else {
const { onClick, ...buttonRest } =
rest as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button
type="button"
className={clsx('canon-Button', className)}
data-variant={responsiveVariant}
data-size={responsiveSize}
style={style}
ref={ref as React.Ref<HTMLButtonElement>}
onClick={onClick}
{...buttonRest}
>
{content}
</button>
);
}
});
Button.displayName = 'Button';
};
+14 -34
View File
@@ -15,43 +15,23 @@
*/
import { Breakpoint } from '@backstage/canon';
import {
ReactElement,
AnchorHTMLAttributes,
ButtonHTMLAttributes,
} from 'react';
/** @public */
export type ButtonCommonProps = {
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint, 'primary' | 'secondary'>>;
iconStart?: ReactElement;
iconEnd?: ReactElement;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
/** @public */
export type ButtonAnchorProps = ButtonCommonProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'type' | 'onClick'> & {
href: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
/** @public */
export type ButtonNativeProps = ButtonCommonProps &
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'href'> & {
href?: undefined;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
import { ReactElement } from 'react';
import { PolymorphicComponentProp } from '../../types';
/**
* Properties for {@link Button}
*
* @public
*/
export type ButtonProps = ButtonAnchorProps | ButtonNativeProps;
export type ButtonProps<C extends React.ElementType> = PolymorphicComponentProp<
C,
{
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint, 'primary' | 'secondary'>>;
iconStart?: ReactElement;
iconEnd?: ReactElement;
}
>;
@@ -84,11 +84,23 @@ export const Disabled: Story = {
};
export const AsLink: Story = {
args: {
icon: <Icon name="cloud" />,
href: 'https://canon.backstage.io',
target: '_blank',
'aria-label': 'Cloud icon button',
render: () => (
<IconButton
as="a"
href="https://canon.backstage.io"
target="_blank"
icon={<Icon name="cloud" />}
/>
),
};
export const AsComponent: Story = {
render: () => {
const Link = (props: { children?: React.ReactNode; to: string }) => (
<a {...props} />
);
return <IconButton as={Link} to="/" icon={<Icon name="cloud" />} />;
},
};
@@ -123,22 +135,22 @@ export const Playground: Story = {
<Flex align="center" key={size}>
<IconButton
{...args}
variant={variant as IconButtonProps['variant']}
size={size as IconButtonProps['size']}
variant={variant as IconButtonProps<any>['variant']}
size={size as IconButtonProps<any>['size']}
/>
<IconButton
{...args}
icon={<Icon name="chevron-right" />}
aria-label="Chevron right icon button"
variant={variant as IconButtonProps['variant']}
size={size as IconButtonProps['size']}
variant={variant as IconButtonProps<any>['variant']}
size={size as IconButtonProps<any>['size']}
/>
<IconButton
{...args}
icon={<Icon name="chevron-right" />}
aria-label="Chevron right icon button"
variant={variant as IconButtonProps['variant']}
size={size as IconButtonProps['size']}
variant={variant as IconButtonProps<any>['variant']}
size={size as IconButtonProps<any>['size']}
/>
</Flex>
))}
@@ -14,29 +14,36 @@
* limitations under the License.
*/
import { AnchorHTMLAttributes, ButtonHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import type { IconButtonProps } from './types';
/** @public */
export const IconButton = forwardRef<HTMLElement, IconButtonProps>(
(props, ref) => {
const {
size = 'small',
variant = 'primary',
icon,
className,
href,
style,
...rest
} = props;
export const IconButton = <C extends React.ElementType = 'button'>(
props: IconButtonProps<C>,
) => {
const {
as,
size = 'small',
variant = 'primary',
icon,
className,
href,
style,
...rest
} = props;
const isAnchor = typeof props.href === 'string';
const responsiveSize = useResponsiveValue(size);
const responsiveVariant = useResponsiveValue(variant);
const Component = as || 'button';
const responsiveSize = useResponsiveValue(size);
const responsiveVariant = useResponsiveValue(variant);
const content = (
return (
<Component
className={clsx('canon-IconButton', className)}
data-variant={responsiveVariant}
data-size={responsiveSize}
{...rest}
>
<span
className="canon-IconButtonIcon"
aria-hidden="true"
@@ -44,44 +51,6 @@ export const IconButton = forwardRef<HTMLElement, IconButtonProps>(
>
{icon}
</span>
);
if (isAnchor) {
const { onClick, ...anchorRest } =
rest as AnchorHTMLAttributes<HTMLAnchorElement>;
return (
<a
href={href}
className={clsx('canon-IconButton', className)}
data-variant={responsiveVariant}
data-size={responsiveSize}
style={style}
ref={ref as React.Ref<HTMLAnchorElement>}
onClick={onClick}
{...anchorRest}
>
{content}
</a>
);
} else {
const { onClick, ...buttonRest } =
rest as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<button
type="button"
className={clsx('canon-Button', className)}
data-variant={responsiveVariant}
data-size={responsiveSize}
style={style}
ref={ref as React.Ref<HTMLButtonElement>}
onClick={onClick}
{...buttonRest}
>
{content}
</button>
);
}
},
);
IconButton.displayName = 'IconButton';
</Component>
);
};
@@ -14,43 +14,27 @@
* limitations under the License.
*/
import {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ReactElement,
} from 'react';
import { Breakpoint } from '@backstage/canon';
/** @public */
export type IconButtonCommonProps = {
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint, 'primary' | 'secondary'>>;
icon?: ReactElement;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
/** @public */
export type IconButtonAnchorProps = IconButtonCommonProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'type' | 'onClick'> & {
href: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
/** @public */
export type IconButtonNativeProps = IconButtonCommonProps &
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'href'> & {
href?: undefined;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
import { ReactElement } from 'react';
import { PolymorphicComponentProp } from '../../types';
/**
* Properties for {@link IconButton}
*
* @public
*/
export type IconButtonProps = IconButtonAnchorProps | IconButtonNativeProps;
export type IconButtonProps<C extends React.ElementType> =
PolymorphicComponentProp<
C,
{
size?:
| 'small'
| 'medium'
| Partial<Record<Breakpoint, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint, 'primary' | 'secondary'>>;
icon?: ReactElement;
}
>;
+18
View File
@@ -131,3 +131,21 @@ export interface UtilityProps extends SpaceProps {
justifyContent?: Responsive<JustifyContent>;
rowSpan?: Responsive<Columns | 'full'>;
}
/** @public */
export type AsProp<C extends React.ElementType> = {
as?: C;
};
/** @public */
export type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
/**
* This is the first reusable type utility we built
* @public
*/
export type PolymorphicComponentProp<
C extends React.ElementType,
Props = {},
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;