Improve support for button links

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-17 14:22:54 +01:00
parent 7004640576
commit 4c6d891e5a
9 changed files with 160 additions and 156 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
**Breaking** We are removing the render prop on the Button component. If you want to use Button as a link, simply add the href prop and we will automaticlly use it as an anchor tag underneath.
@@ -11,6 +11,7 @@ import {
buttonFullWidthSnippet,
buttonDisabledSnippet,
buttonResponsiveSnippet,
buttonAsLinkSnippet,
} from './button.props';
import { ComponentInfos } from '@/components/ComponentInfos';
@@ -102,3 +103,15 @@ Here's a view when buttons are disabled.
Here's a view when buttons are responsive.
<CodeBlock code={buttonResponsiveSnippet} />
### As Link
Use the `href` prop to make a button act as a link.
<Snippet
align="center"
py={4}
open
preview={<ButtonSnippet story="AsLink" />}
code={buttonAsLinkSnippet}
/>
@@ -61,3 +61,7 @@ export const buttonDisabledSnippet = `<Flex gap="4">
export const buttonResponsiveSnippet = `<Button variant={{ initial: 'primary', lg: 'secondary' }}>
Responsive Button
</Button>`;
export const buttonAsLinkSnippet = `<Button href="https://canon.backstage.io" target="_blank">
I am a link
</Button>`;
+24 -23
View File
@@ -3,8 +3,10 @@
> 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';
@@ -152,37 +154,36 @@ export const breakpoints: Breakpoint[];
// @public (undocumented)
export const Button: ForwardRefExoticComponent<
Omit<ButtonProps, 'ref'> & RefAttributes<HTMLButtonElement>
ButtonProps & RefAttributes<HTMLElement>
>;
// @public (undocumented)
export type ButtonOwnProps = GetPropDefTypes<typeof buttonPropDefs>;
export type ButtonAnchorProps = ButtonCommonProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'type' | 'onClick'> & {
href: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
// @public (undocumented)
export const buttonPropDefs: {
variant: {
type: 'enum';
values: ('primary' | 'secondary')[];
className: string;
default: 'primary';
responsive: true;
};
size: {
type: 'enum';
values: ('small' | 'medium')[];
className: string;
default: 'medium';
responsive: true;
};
export type ButtonCommonProps = {
size?: 'small' | 'medium';
variant?: '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>;
};
// @public
export interface ButtonProps extends useRender.ComponentProps<'button'> {
iconEnd?: ReactElement;
iconStart?: ReactElement;
size?: ButtonOwnProps['size'];
variant?: ButtonOwnProps['variant'];
}
export type ButtonProps = ButtonAnchorProps | ButtonNativeProps;
// @public (undocumented)
export const Checkbox: ForwardRefExoticComponent<
@@ -1,41 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { PropDef, GetPropDefTypes } from '../../props/prop-def';
/** @public */
export const buttonPropDefs = {
variant: {
type: 'enum',
values: ['primary', 'secondary'],
className: 'canon-Button--variant',
default: 'primary',
responsive: true,
},
size: {
type: 'enum',
values: ['small', 'medium'],
className: 'canon-Button--size',
default: 'medium',
responsive: true,
},
} satisfies {
variant: PropDef<'primary' | 'secondary'>;
size: PropDef<'small' | 'medium'>;
};
/** @public */
export type ButtonOwnProps = GetPropDefTypes<typeof buttonPropDefs>;
@@ -134,13 +134,9 @@ export const Disabled: Story = {
export const AsLink: Story = {
args: {
children: 'I am a link',
href: 'https://canon.backstage.io',
target: '_blank',
},
render: args => (
<Button
{...args}
render={<a href="https://canon.backstage.io" target="_blank" />}
/>
),
};
export const Responsive: Story = {
+78 -56
View File
@@ -14,69 +14,91 @@
* limitations under the License.
*/
import { forwardRef, useRef } from 'react';
import { AnchorHTMLAttributes, ButtonHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import { useRender } from '@base-ui-components/react/use-render';
import type { ButtonProps } from './types';
/** @public */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props: ButtonProps, ref) => {
const {
size = 'small',
variant = 'primary',
iconStart,
iconEnd,
children,
render = <button />,
className,
style,
...rest
} = props;
export const Button = forwardRef<HTMLElement, ButtonProps>((props, ref) => {
const {
size = 'small',
variant = 'primary',
iconStart,
iconEnd,
children,
href,
className,
style,
...rest
} = props;
// Get the responsive value for the variant
const responsiveSize = useResponsiveValue(size);
const responsiveVariant = useResponsiveValue(variant);
const internalRef = useRef<HTMLElement | null>(null);
function isAnchor(props: { href?: unknown }): props is { href: string } {
return typeof props.href === 'string';
}
const { renderElement } = useRender({
render,
props: {
className: clsx('canon-Button', className),
['data-variant']: responsiveVariant,
['data-size']: responsiveSize,
...rest,
children: (
<>
{iconStart && (
<span
className="canon-ButtonIcon"
aria-hidden="true"
data-size={responsiveSize}
>
{iconStart}
</span>
)}
{children}
{iconEnd && (
<span
className="canon-ButtonIcon"
aria-hidden="true"
data-size={responsiveSize}
>
{iconEnd}
</span>
)}
</>
),
},
refs: [ref, internalRef],
});
const responsiveSize = useResponsiveValue(size);
const responsiveVariant = useResponsiveValue(variant);
return renderElement();
},
);
const content = (
<>
{iconStart && (
<span
className="canon-ButtonIcon"
aria-hidden="true"
data-size={responsiveSize}
>
{iconStart}
</span>
)}
{children}
{iconEnd && (
<span
className="canon-ButtonIcon"
aria-hidden="true"
data-size={responsiveSize}
>
{iconEnd}
</span>
)}
</>
);
if (isAnchor(props)) {
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>
);
}
});
export default Button;
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Button } from './Button';
export type { ButtonProps } from './types';
export { buttonPropDefs } from './Button.props';
export type { ButtonOwnProps } from './Button.props';
export * from './Button';
export * from './types';
+31 -26
View File
@@ -14,35 +14,40 @@
* limitations under the License.
*/
import type { ButtonOwnProps } from './Button.props';
import { ReactElement } from 'react';
import type { useRender } from '@base-ui-components/react/use-render';
import {
ReactElement,
AnchorHTMLAttributes,
ButtonHTMLAttributes,
} from 'react';
/** @public */
export type ButtonCommonProps = {
size?: 'small' | 'medium';
variant?: '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>;
};
/**
* Properties for {@link Button}
*
* @public
*/
export interface ButtonProps extends useRender.ComponentProps<'button'> {
/**
* The size of the button
* @defaultValue 'medium'
*/
size?: ButtonOwnProps['size'];
/**
* The visual variant of the button
* @defaultValue 'primary'
*/
variant?: ButtonOwnProps['variant'];
/**
* Optional icon to display at the start of the button
*/
iconStart?: ReactElement;
/**
* Optional icon to display at the end of the button
*/
iconEnd?: ReactElement;
}
export type ButtonProps = ButtonAnchorProps | ButtonNativeProps;