diff --git a/canon-docs/src/content/components/button.mdx b/canon-docs/src/content/components/button.mdx index dceabde154..6690a90e23 100644 --- a/canon-docs/src/content/components/button.mdx +++ b/canon-docs/src/content/components/button.mdx @@ -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. `; -export const buttonAsLinkSnippet = ` + +// Using a custom component +`; diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 872719f8c8..b648025251 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -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 = { + 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 ->; - -// @public (undocumented) -export type ButtonAnchorProps = ButtonCommonProps & - Omit, 'type' | 'onClick'> & { - href: string; - onClick?: React.MouseEventHandler; - }; - -// @public (undocumented) -export type ButtonCommonProps = { - size?: 'small' | 'medium' | Partial>; - variant?: - | 'primary' - | 'secondary' - | Partial>; - iconStart?: ReactElement; - iconEnd?: ReactElement; - className?: string; - style?: React.CSSProperties; - children?: React.ReactNode; -}; - -// @public (undocumented) -export type ButtonNativeProps = ButtonCommonProps & - Omit, 'href'> & { - href?: undefined; - onClick?: React.MouseEventHandler; - }; +export const Button: ( + props: ButtonProps, +) => JSX_2.Element; // @public -export type ButtonProps = ButtonAnchorProps | ButtonNativeProps; +export type ButtonProps = PolymorphicComponentProp< + C, + { + size?: + | 'small' + | 'medium' + | Partial>; + variant?: + | 'primary' + | 'secondary' + | Partial>; + iconStart?: ReactElement; + iconEnd?: ReactElement; + } +>; // @public (undocumented) export const Checkbox: ForwardRefExoticComponent< @@ -696,39 +685,26 @@ export type HeightProps = GetPropDefTypes; export const Icon: (props: IconProps) => JSX_2.Element | null; // @public (undocumented) -export const IconButton: ForwardRefExoticComponent< - IconButtonProps & RefAttributes ->; - -// @public (undocumented) -export type IconButtonAnchorProps = IconButtonCommonProps & - Omit, 'type' | 'onClick'> & { - href: string; - onClick?: React.MouseEventHandler; - }; - -// @public (undocumented) -export type IconButtonCommonProps = { - size?: 'small' | 'medium' | Partial>; - variant?: - | 'primary' - | 'secondary' - | Partial>; - icon?: ReactElement; - className?: string; - style?: React.CSSProperties; - children?: React.ReactNode; -}; - -// @public (undocumented) -export type IconButtonNativeProps = IconButtonCommonProps & - Omit, 'href'> & { - href?: undefined; - onClick?: React.MouseEventHandler; - }; +export const IconButton: ( + props: IconButtonProps, +) => JSX_2.Element; // @public -export type IconButtonProps = IconButtonAnchorProps | IconButtonNativeProps; +export type IconButtonProps = + PolymorphicComponentProp< + C, + { + size?: + | 'small' + | 'medium' + | Partial>; + variant?: + | 'primary' + | 'secondary' + | Partial>; + icon?: ReactElement; + } + >; // @public (undocumented) export const IconContext: Context; @@ -1025,6 +1001,13 @@ export const paddingPropDefs: (spacingValues: string[]) => { // @public (undocumented) export type PaddingProps = GetPropDefTypes; +// @public +export type PolymorphicComponentProp< + C extends React.ElementType, + Props = {}, +> = React.PropsWithChildren> & + Omit, PropsToOmit>; + // @public (undocumented) export const positionPropDefs: { position: { @@ -1041,6 +1024,9 @@ export type PositionProps = GetPropDefTypes; // @public (undocumented) export type PropDef = RegularPropDef | ResponsivePropDef; +// @public (undocumented) +export type PropsToOmit = keyof (AsProp & P); + // @public (undocumented) export type ReactNodePropDef = { type: 'ReactNode'; diff --git a/packages/canon/src/components/Button/Button.stories.tsx b/packages/canon/src/components/Button/Button.stories.tsx index 8c9cc521f0..30559d2698 100644 --- a/packages/canon/src/components/Button/Button.stories.tsx +++ b/packages/canon/src/components/Button/Button.stories.tsx @@ -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 }) => ( + + ); + + return ( + + ); + }, +}; + export const Responsive: Story = { args: { children: 'Button', @@ -167,22 +181,22 @@ export const Playground: Story = { {['small', 'medium'].map(size => ( @@ -190,30 +204,30 @@ export const Playground: Story = { iconStart={} iconEnd={} style={{ width: '200px' }} - variant={variant as ButtonProps['variant']} - size={size as ButtonProps['size']} + variant={variant as ButtonProps['variant']} + size={size as ButtonProps['size']} > Button - ); - } -}); - -Button.displayName = 'Button'; +}; diff --git a/packages/canon/src/components/Button/types.ts b/packages/canon/src/components/Button/types.ts index b4f1df2fad..1ff4e19cb0 100644 --- a/packages/canon/src/components/Button/types.ts +++ b/packages/canon/src/components/Button/types.ts @@ -15,43 +15,23 @@ */ import { Breakpoint } from '@backstage/canon'; -import { - ReactElement, - AnchorHTMLAttributes, - ButtonHTMLAttributes, -} from 'react'; - -/** @public */ -export type ButtonCommonProps = { - size?: 'small' | 'medium' | Partial>; - variant?: - | 'primary' - | 'secondary' - | Partial>; - iconStart?: ReactElement; - iconEnd?: ReactElement; - className?: string; - style?: React.CSSProperties; - children?: React.ReactNode; -}; - -/** @public */ -export type ButtonAnchorProps = ButtonCommonProps & - Omit, 'type' | 'onClick'> & { - href: string; - onClick?: React.MouseEventHandler; - }; - -/** @public */ -export type ButtonNativeProps = ButtonCommonProps & - Omit, 'href'> & { - href?: undefined; - onClick?: React.MouseEventHandler; - }; +import { ReactElement } from 'react'; +import { PolymorphicComponentProp } from '../../types'; /** * Properties for {@link Button} * * @public */ -export type ButtonProps = ButtonAnchorProps | ButtonNativeProps; +export type ButtonProps = PolymorphicComponentProp< + C, + { + size?: 'small' | 'medium' | Partial>; + variant?: + | 'primary' + | 'secondary' + | Partial>; + iconStart?: ReactElement; + iconEnd?: ReactElement; + } +>; diff --git a/packages/canon/src/components/IconButton/IconButton.stories.tsx b/packages/canon/src/components/IconButton/IconButton.stories.tsx index 97778e56a2..d74e7ebf1a 100644 --- a/packages/canon/src/components/IconButton/IconButton.stories.tsx +++ b/packages/canon/src/components/IconButton/IconButton.stories.tsx @@ -84,11 +84,23 @@ export const Disabled: Story = { }; export const AsLink: Story = { - args: { - icon: , - href: 'https://canon.backstage.io', - target: '_blank', - 'aria-label': 'Cloud icon button', + render: () => ( + } + /> + ), +}; + +export const AsComponent: Story = { + render: () => { + const Link = (props: { children?: React.ReactNode; to: string }) => ( + + ); + + return } />; }, }; @@ -123,22 +135,22 @@ export const Playground: Story = { ['variant']} + size={size as IconButtonProps['size']} /> } aria-label="Chevron right icon button" - variant={variant as IconButtonProps['variant']} - size={size as IconButtonProps['size']} + variant={variant as IconButtonProps['variant']} + size={size as IconButtonProps['size']} /> } aria-label="Chevron right icon button" - variant={variant as IconButtonProps['variant']} - size={size as IconButtonProps['size']} + variant={variant as IconButtonProps['variant']} + size={size as IconButtonProps['size']} /> ))} diff --git a/packages/canon/src/components/IconButton/IconButton.tsx b/packages/canon/src/components/IconButton/IconButton.tsx index e84599874c..079aa2e6f4 100644 --- a/packages/canon/src/components/IconButton/IconButton.tsx +++ b/packages/canon/src/components/IconButton/IconButton.tsx @@ -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( - (props, ref) => { - const { - size = 'small', - variant = 'primary', - icon, - className, - href, - style, - ...rest - } = props; +export const IconButton = ( + props: IconButtonProps, +) => { + 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 ( + - ); - - if (isAnchor) { - const { onClick, ...anchorRest } = - rest as AnchorHTMLAttributes; - return ( - } - onClick={onClick} - {...anchorRest} - > - {content} - - ); - } else { - const { onClick, ...buttonRest } = - rest as ButtonHTMLAttributes; - return ( - - ); - } - }, -); - -IconButton.displayName = 'IconButton'; + + ); +}; diff --git a/packages/canon/src/components/IconButton/types.ts b/packages/canon/src/components/IconButton/types.ts index 145893991e..f15831e4a4 100644 --- a/packages/canon/src/components/IconButton/types.ts +++ b/packages/canon/src/components/IconButton/types.ts @@ -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>; - variant?: - | 'primary' - | 'secondary' - | Partial>; - icon?: ReactElement; - className?: string; - style?: React.CSSProperties; - children?: React.ReactNode; -}; - -/** @public */ -export type IconButtonAnchorProps = IconButtonCommonProps & - Omit, 'type' | 'onClick'> & { - href: string; - onClick?: React.MouseEventHandler; - }; - -/** @public */ -export type IconButtonNativeProps = IconButtonCommonProps & - Omit, 'href'> & { - href?: undefined; - onClick?: React.MouseEventHandler; - }; +import { ReactElement } from 'react'; +import { PolymorphicComponentProp } from '../../types'; /** * Properties for {@link IconButton} * * @public */ -export type IconButtonProps = IconButtonAnchorProps | IconButtonNativeProps; +export type IconButtonProps = + PolymorphicComponentProp< + C, + { + size?: + | 'small' + | 'medium' + | Partial>; + variant?: + | 'primary' + | 'secondary' + | Partial>; + icon?: ReactElement; + } + >; diff --git a/packages/canon/src/types.ts b/packages/canon/src/types.ts index ee5d954576..df3959e368 100644 --- a/packages/canon/src/types.ts +++ b/packages/canon/src/types.ts @@ -131,3 +131,21 @@ export interface UtilityProps extends SpaceProps { justifyContent?: Responsive; rowSpan?: Responsive; } + +/** @public */ +export type AsProp = { + as?: C; +}; + +/** @public */ +export type PropsToOmit = keyof (AsProp & P); + +/** + * This is the first reusable type utility we built + * @public + */ +export type PolymorphicComponentProp< + C extends React.ElementType, + Props = {}, +> = React.PropsWithChildren> & + Omit, PropsToOmit>;