Skip to content

Commit

Permalink
feat(Button)!: allow HTML attributes as "top-level" props (#2015)
Browse files Browse the repository at this point in the history
  • Loading branch information
amje committed Jan 31, 2025
1 parent 7be5c5a commit e92ad8e
Show file tree
Hide file tree
Showing 37 changed files with 199 additions and 237 deletions.
4 changes: 1 addition & 3 deletions src/components/ActionsPanel/ActionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ export const ActionsPanel = ({
size="m"
onClick={onClose}
className={b('button-close')}
extraProps={{
'aria-label': i18n('label_close'),
}}
aria-label={i18n('label_close')}
>
<Icon key="icon" data={Xmark} />
</Button>
Expand Down
4 changes: 1 addition & 3 deletions src/components/ActionsPanel/components/CollapseActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ export const CollapseActions = ({actions, maxRowActions}: Props) => {
<Button
view="flat-contrast"
size="m"
extraProps={{
'aria-label': i18n('label_more'),
}}
aria-label={i18n('label_more')}
onClick={onClick}
>
<Icon data={Ellipsis} />
Expand Down
4 changes: 1 addition & 3 deletions src/components/Alert/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ export const Alert = (props: AlertProps) => {
view="flat"
className={bAlert('close-btn')}
onClick={onClose}
extraProps={{
'aria-label': i18n('label_close'),
}}
aria-label={i18n('label_close')}
>
<Icon
data={Xmark}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Alert/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export interface AlertActionsProps {
items?: AlertAction[];
children?: React.ReactNode | React.ReactNode[];
}
export interface AlertActionProps extends ButtonProps {}
export type AlertActionProps = ButtonProps;
export interface AlertTitleProps {
className?: string;
text: string;
Expand Down
170 changes: 92 additions & 78 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import * as React from 'react';

import type {DOMProps, QAProps} from '../types';
import type {QAProps} from '../types';
import {block} from '../utils/cn';
import {isIcon, isSvg} from '../utils/common';
import {eventBroker} from '../utils/event-broker';
Expand Down Expand Up @@ -35,38 +35,56 @@ export type ButtonPin =

export type ButtonWidth = 'auto' | 'max';

export interface ButtonProps extends DOMProps, QAProps {
/** Button appearance */
interface ButtonCommonProps extends QAProps {
view?: ButtonView;
size?: ButtonSize;
pin?: ButtonPin;
selected?: boolean;
disabled?: boolean;
loading?: boolean;
width?: ButtonWidth;
title?: string;
tabIndex?: number;
id?: string;
type?: 'button' | 'submit' | 'reset';
component?: React.ElementType;
href?: string;
target?: string;
rel?: string;
extraProps?:
| React.ButtonHTMLAttributes<HTMLButtonElement>
| React.AnchorHTMLAttributes<HTMLAnchorElement>;
onClick?: React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
onMouseEnter?: React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
onMouseLeave?: React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
onFocus?: React.FocusEventHandler<HTMLButtonElement | HTMLAnchorElement>;
onBlur?: React.FocusEventHandler<HTMLButtonElement | HTMLAnchorElement>;
/** Button content. You can mix button text with `<Icon/>` component */
children?: React.ReactNode;
}

export interface ButtonButtonProps
extends ButtonCommonProps,
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'disabled'> {
component?: never;
href?: never;
/**
* @deprecated Use additional props at the root
*/
extraProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
}

export interface ButtonLinkProps
extends ButtonCommonProps,
React.AnchorHTMLAttributes<HTMLAnchorElement> {
component?: never;
href: string;
/**
* @deprecated Use additional props at the root
*/
extraProps?: React.AnchorHTMLAttributes<HTMLAnchorElement>;
}

export type ButtonComponentProps<T extends React.ElementType = 'button'> = ButtonCommonProps &
React.ComponentPropsWithoutRef<T> & {
component: T;
/**
* @deprecated Use additional props at the root
*/
extraProps?: React.ComponentPropsWithoutRef<T>;
};

export type ButtonProps<T extends React.ElementType = 'button'> =
| ButtonLinkProps
| ButtonButtonProps
| ButtonComponentProps<T>;

const b = block('button');

const ButtonWithHandlers = React.forwardRef<HTMLElement, ButtonProps>(function Button(
const _Button = React.forwardRef(function Button(
{
view = 'normal',
size = 'm',
Expand All @@ -75,104 +93,100 @@ const ButtonWithHandlers = React.forwardRef<HTMLElement, ButtonProps>(function B
disabled = false,
loading = false,
width,
title,
tabIndex,
type = 'button',
component,
href,
target,
rel,
extraProps,
onClick,
onMouseEnter,
onMouseLeave,
onFocus,
onBlur,
children,
id,
style,
className,
extraProps,
qa,
},
...props
}: ButtonProps,
ref,
) {
const handleClickCapture = React.useCallback(
(event: React.SyntheticEvent) => {
(event: React.MouseEvent<any>) => {
eventBroker.publish({
componentId: 'Button',
eventId: 'click',
domEvent: event,
meta: {
content: event.currentTarget.textContent,
view,
view: view,
},
});

if (props.onClickCapture) {
props.onClickCapture(event);
}
},
[view],
[view, props.onClickCapture],
);

const commonProps = {
title,
tabIndex,
onClick,
onClickCapture: handleClickCapture,
onMouseEnter,
onMouseLeave,
onFocus,
onBlur,
id,
style,
className: b(
{
view,
size,
pin,
selected,
view: view,
size: size,
pin: pin,
selected: selected,
disabled: disabled || loading,
loading,
width,
loading: loading,
width: width,
},
className,
props.className,
),
'data-qa': qa,
};

if (typeof href === 'string' || component) {
const linkProps = {
href,
target,
rel: target === '_blank' && !rel ? 'noopener noreferrer' : rel,
};
if (props.component) {
return React.createElement(
component || 'a',
props.component,
{
...props,
...extraProps,
...commonProps,
...(component ? {} : linkProps),
ref: ref as React.Ref<HTMLAnchorElement>,
'aria-disabled': disabled || loading,
ref: ref,
'aria-disabled': disabled ?? undefined,
},
prepareChildren(children),
);
} else {
}

if (typeof props.href !== 'undefined') {
return (
<button
{...(extraProps as React.ButtonHTMLAttributes<HTMLButtonElement>)}
<a
{...props}
{...(extraProps as ButtonLinkProps['extraProps'])}
{...commonProps}
ref={ref as React.Ref<HTMLButtonElement>}
type={type}
disabled={disabled || loading}
aria-pressed={selected}
ref={ref as React.Ref<HTMLAnchorElement>}
rel={props.target === '_blank' && !props.rel ? 'noopener noreferrer' : props.rel}
aria-disabled={disabled ?? undefined}
>
{prepareChildren(children)}
</button>
</a>
);
}
});

ButtonWithHandlers.displayName = 'Button';
return (
<button
{...props}
{...(extraProps as ButtonButtonProps['extraProps'])}
{...commonProps}
ref={ref as React.Ref<HTMLButtonElement>}
type={props.type || 'button'}
disabled={disabled || loading}
aria-pressed={selected}
>
{prepareChildren(children)}
</button>
);
}) as <T extends React.ElementType, P extends ButtonProps<T>>(
props: P extends {component: T}
? ButtonComponentProps<T> & {ref?: any} // TODO: Add ref inference
: P extends {href: string}
? ButtonLinkProps & {ref?: React.Ref<HTMLAnchorElement>}
: ButtonButtonProps & {ref?: React.Ref<HTMLButtonElement>},
) => React.ReactElement;

export const Button = Object.assign(ButtonWithHandlers, {Icon: ButtonIcon});
export const Button = Object.assign(_Button, {Icon: ButtonIcon});

const isButtonIconComponent = isOfType(ButtonIcon);
const isSpan = isOfType<{className?: string}>('span');
Expand Down
42 changes: 15 additions & 27 deletions src/components/Button/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,33 +448,21 @@ LANDING_BLOCK-->

## Properties

| Name | Description | Type | Default |
| :----------- | :----------------------------------------------------------------- | :-----------------------------: | :-------------: |
| children | Button content. You can use both text and the `<Icon/>` component. | `ReactNode` | |
| className | `class` HTML attribute | `string` | |
| component | Overrides the root component | `ElementType<any>` | `"button"` |
| disabled | Toggles the `disabled` state | `false` | `false` |
| extraProps | Additional properties | `Record` | |
| href | `href` HTML attribute | `string` | |
| id | `id` HTML attribute | `string` | |
| loading | Toggles the `loading` state | `false` | `false` |
| onBlur | `blur` event handler | `Function` | |
| onClick | `click` event handler | `Function` | |
| onFocus | `focus` event handler | `Function` | |
| onMouseEnter | `mouseenter` event handler | `Function` | |
| onMouseLeave | `mouseleave` event handler | `Function` | |
| pin | Sets the button edge style | `string` | `"round-round"` |
| qa | `data-qa` HTML attribute, used for testing | `string` | |
| rel | `rel` HTML attribute | `string` | |
| selected | Toggles the `selected` state | | |
| size | Sets the button size | `string` | `"m"` |
| style | `style` HTML attribute | `React.CSSProperties` | |
| tabIndex | `tabIndex` HTML attribute | `number` | |
| target | `target` HTML attribute | `string` | |
| title | `title` HTML attribute | `string` | |
| type | `type` HTML attribute | `"button"` `"submit"` `"reset"` | `"button"` |
| view | Sets the button appearance | `string` | `"normal"` |
| width | `"auto"` `"max"` | `"auto"` `"max"` | |
`Buttont` accepts any valid `button` or `a` element props in addition to these:

| Name | Description | Type | Default |
| :-------- | :------------------------------------------------------------------- | :-----------------------------: | :-------------: |
| children | `Button` content. You can use both text and the `<Icon/>` component. | `React.ReactNode` | |
| component | Overrides the root component | `React.ElementType` | |
| disabled | Toggles the `disabled` state | `boolean` | `false` |
| href | Pass this to make the root component a link | `string` | |
| loading | Toggles the `loading` state | `boolean` | `false` |
| pin | Sets the `Button` edge style | `string` | `"round-round"` |
| qa | `data-qa` HTML attribute, used for testing | `string` | |
| selected | Toggles the `selected` state | `boolean` | |
| size | Sets the`Button` size | `"xs"` `"s"` `"m"` `"l"` `"xl"` | `"m"` |
| view | Sets the `Button` appearance | `ButtonView` | `"normal"` |
| width | Controls how `Button` uses parent's space | `"auto"` `"max"` | |

## CSS API

Expand Down
7 changes: 4 additions & 3 deletions src/components/Button/__stories__/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import {Showcase} from '../../../demo/Showcase';
import {Icon as IconComponent} from '../../Icon/Icon';
import {Button} from '../Button';
import type {ButtonButtonProps, ButtonLinkProps} from '../Button';

import {ButtonViewShowcase} from './ButtonViewShowcase';

Expand Down Expand Up @@ -54,7 +55,7 @@ export const Default: Story = {
};

export const View: Story = {
render: (args) => <ButtonViewShowcase {...args} />,
render: (args) => <ButtonViewShowcase {...(args as ButtonButtonProps)} />,
};

export const Size: Story = {
Expand Down Expand Up @@ -174,7 +175,7 @@ export const Link: Story = {
children: ['Link Button', <IconComponent key="icon" data={ArrowUpRightFromSquare} />],
href: 'https://gravity-ui.com',
target: '_blank',
},
} as ButtonLinkProps,
name: 'As Link',
};

Expand All @@ -190,7 +191,7 @@ export const InsideText: Story = {
<Button {...args} /> dolor
<br />
sit{' '}
<Button {...args} extraProps={{'aria-label': 'Icon button inside text'}}>
<Button {...args} aria-label="Icon button inside text">
<IconComponent data={Globe} />
</Button>{' '}
amet
Expand Down
6 changes: 3 additions & 3 deletions src/components/Button/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';

import {render, screen} from '../../../../test-utils/utils';
import {Button} from '../Button';
import type {ButtonPin, ButtonProps, ButtonSize, ButtonView} from '../Button';
import type {ButtonPin, ButtonSize, ButtonView} from '../Button';

const qaId = 'button-component';

Expand Down Expand Up @@ -105,7 +105,7 @@ describe('Button', () => {
test('should render custom component', () => {
const text = 'Button with custom component';

const ButtonComponent = (props: ButtonProps) => {
const ButtonComponent = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button {...props} style={{boxShadow: '2px 2px 2px 2px deepskyblue'}}>
{text}
Expand Down Expand Up @@ -261,7 +261,7 @@ describe('Button', () => {
test('use passed ref for component', () => {
const ref = React.createRef<HTMLLabelElement>();

render(<Button ref={ref} qa={qaId} />);
render(<Button ref={ref} component="label" qa={qaId} />);
const button = screen.getByTestId(qaId);

expect(ref.current).toBe(button);
Expand Down
Loading

0 comments on commit e92ad8e

Please sign in to comment.