diff --git a/UNRELEASED.md b/UNRELEASED.md index b800fc5c53c..bc780b02c15 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -9,6 +9,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f ### Enhancements - Updated `MediaCard` to accept ReactNode as title and make `primaryAction` optional ([#3552](https://github.com/Shopify/polaris-react/pull/3552)) +- **`UnstyledButton`:** Added `loading` prop to apply `role` and `aria-busy` attributes ([#3494](https://github.com/Shopify/polaris-react/pull/3494)) ### Bug fixes @@ -20,4 +21,6 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f ### Code quality +- **`Button`:** Reduced redundant code repeated within `UnstyledButton` ([#3494](https://github.com/Shopify/polaris-react/pull/3494)) + ### Deprecations diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 4331cf99d39..5e72b74e4c9 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,104 +1,94 @@ -import React, {useRef, useState, useCallback} from 'react'; +import React, {useCallback, useState} from 'react'; import {CaretDownMinor} from '@shopify/polaris-icons'; +import type {BaseButton, ConnectedDisclosure, IconSource} from '../../types'; import {classNames, variationName} from '../../utilities/css'; -import {handleMouseUpByBlurring} from '../../utilities/focus'; +import { + handleMouseUpByBlurring, + MouseUpBlurHandler, +} from '../../utilities/focus'; import {useFeatures} from '../../utilities/features'; import {useI18n} from '../../utilities/i18n'; import {Icon} from '../Icon'; -import type {IconProps, ConnectedDisclosure} from '../../types'; import {Spinner} from '../Spinner'; import {Popover} from '../Popover'; import {ActionList} from '../ActionList'; -import {UnstyledButton} from '../UnstyledButton'; +import {UnstyledButton, UnstyledButtonProps} from '../UnstyledButton'; import styles from './Button.scss'; -type Size = 'slim' | 'medium' | 'large'; -type TextAlign = 'left' | 'right' | 'center'; -type IconSource = IconProps['source']; - -export interface ButtonProps { +export interface ButtonProps extends BaseButton { /** The content to display inside the button */ children?: string | string[]; - /** A destination to link to, rendered in the href attribute of a link */ - url?: string; - /** A unique identifier for the button */ - id?: string; /** Provides extra visual weight and identifies the primary action in a set of buttons */ primary?: boolean; /** Indicates a dangerous or potentially negative action */ destructive?: boolean; - /** Disables the button, disallowing merchant interaction */ - disabled?: boolean; - /** Replaces button text with a spinner while a background action is being performed */ - loading?: boolean; /** * Changes the size of the button, giving it more or less padding * @default 'medium' */ - size?: Size; + size?: 'slim' | 'medium' | 'large'; /** Changes the inner text alignment of the button */ - textAlign?: TextAlign; + textAlign?: 'left' | 'right' | 'center'; /** Gives the button a subtle alternative to the default button styling, appropriate for certain backdrops */ outline?: boolean; - /** Gives the button the appearance of being pressed */ - pressed?: boolean; /** Allows the button to grow to the width of its container */ fullWidth?: boolean; /** Displays the button with a disclosure icon. Defaults to `down` when set to true */ disclosure?: 'down' | 'up' | boolean; - /** Allows the button to submit a form */ - submit?: boolean; /** Renders a button that looks like a link */ plain?: boolean; /** Makes `plain` and `outline` Button colors (text, borders, icons) the same as the current text color. Also adds an underline to `plain` Buttons */ monochrome?: boolean; - /** Forces url to open in a new tab */ - external?: boolean; - /** Tells the browser to download the url instead of opening it. Provides a hint for the downloaded filename if it is a string value */ - download?: string | boolean; /** Icon to display to the left of the button content */ icon?: React.ReactElement | IconSource; - /** Visually hidden text for screen readers */ - accessibilityLabel?: string; - /** Id of the element the button controls */ - ariaControls?: string; - /** Tells screen reader the controlled element is expanded */ - ariaExpanded?: boolean; - /** - * @deprecated As of release 4.7.0, replaced by {@link https://polaris.shopify.com/components/structure/page#props-pressed} - * Tells screen reader the element is pressed - */ - ariaPressed?: boolean; /** Disclosure button connected right of the button. Toggles a popover action list. */ connectedDisclosure?: ConnectedDisclosure; - /** Callback when clicked */ - onClick?(): void; - /** Callback when button becomes focussed */ - onFocus?(): void; - /** Callback when focus leaves button */ - onBlur?(): void; - /** Callback when a keypress event is registered on the button */ - onKeyPress?(event: React.KeyboardEvent): void; - /** Callback when a keyup event is registered on the button */ - onKeyUp?(event: React.KeyboardEvent): void; - /** Callback when a keydown event is registered on the button */ - onKeyDown?(event: React.KeyboardEvent): void; - /** Callback when mouse enter */ - onMouseEnter?(): void; - /** Callback when element is touched */ - onTouchStart?(): void; } +interface CommonButtonProps + extends Pick< + ButtonProps, + | 'id' + | 'accessibilityLabel' + | 'onClick' + | 'onFocus' + | 'onBlur' + | 'onMouseEnter' + | 'onTouchStart' + > { + className: UnstyledButtonProps['className']; + onMouseUp: MouseUpBlurHandler; +} + +type LinkButtonProps = Pick; + +type ActionButtonProps = Pick< + ButtonProps, + | 'submit' + | 'disabled' + | 'loading' + | 'ariaControls' + | 'ariaExpanded' + | 'ariaPressed' + | 'onKeyDown' + | 'onKeyUp' + | 'onKeyPress' +>; + const DEFAULT_SIZE = 'medium'; export function Button({ id, + children, url, disabled, + external, + download, + submit, loading, - children, + pressed, accessibilityLabel, ariaControls, ariaExpanded, @@ -111,8 +101,6 @@ export function Button({ onKeyUp, onMouseEnter, onTouchStart, - external, - download, icon, primary, outline, @@ -120,24 +108,12 @@ export function Button({ disclosure, plain, monochrome, - submit, size = DEFAULT_SIZE, textAlign, fullWidth, - pressed, connectedDisclosure, }: ButtonProps) { const {newDesignLanguage} = useFeatures(); - const hasGivenDeprecationWarning = useRef(false); - - if (ariaPressed && !hasGivenDeprecationWarning.current) { - // eslint-disable-next-line no-console - console.warn( - 'Deprecation: The ariaPressed prop has been replaced with pressed', - ); - hasGivenDeprecationWarning.current = true; - } - const i18n = useI18n(); const isDisabled = disabled || loading; @@ -226,7 +202,6 @@ export function Button({ ); - const type = submit ? 'submit' : 'button'; const ariaPressedStatus = pressed !== undefined ? pressed : ariaPressed; const [disclosureActive, setDisclosureActive] = useState(false); @@ -289,61 +264,39 @@ export function Button({ ); } - let buttonMarkup; + const commonProps: CommonButtonProps = { + id, + className, + accessibilityLabel, + onClick, + onFocus, + onBlur, + onMouseUp: handleMouseUpByBlurring, + onMouseEnter, + onTouchStart, + }; + const linkProps: LinkButtonProps = { + url, + external, + download, + }; + const actionProps: ActionButtonProps = { + submit, + disabled: isDisabled, + loading, + ariaControls, + ariaExpanded, + ariaPressed: ariaPressedStatus, + onKeyDown, + onKeyUp, + onKeyPress, + }; - if (url) { - buttonMarkup = isDisabled ? ( - // Render an `` so toggling disabled/enabled state changes only the - // `href` attribute instead of replacing the whole element. - // eslint-disable-next-line jsx-a11y/anchor-is-valid - - {content} - - ) : ( - - {content} - - ); - } else { - buttonMarkup = ( - - {content} - - ); - } + const buttonMarkup = ( + + {content} + + ); return connectedDisclosureMarkup ? (
diff --git a/src/components/Button/tests/Button.test.tsx b/src/components/Button/tests/Button.test.tsx index 570edfa0034..31acec20a2c 100644 --- a/src/components/Button/tests/Button.test.tsx +++ b/src/components/Button/tests/Button.test.tsx @@ -3,156 +3,102 @@ import {PlusMinor, CaretDownMinor} from '@shopify/polaris-icons'; // eslint-disable-next-line no-restricted-imports import {mountWithAppProvider, trigger} from 'test-utilities/legacy'; import {mountWithApp} from 'test-utilities'; -import {UnstyledLink, Icon, Spinner, ActionList, Popover} from 'components'; +import {ActionList, Icon, Popover, Spinner, UnstyledButton} from 'components'; import {Button} from '../Button'; import en from '../../../../locales/en.json'; describe('); + expect(button.find(UnstyledButton).text()).toContain(mockChildren); }); + }); - it('renders a button when not present', () => { - const button = mountWithAppProvider(); - expect(button.text()).toContain(label); + describe('url', () => { + it('passes prop', () => { + const mockUrl = 'https://google.com'; + const button = mountWithAppProvider(, - ); - expect(button.text()).toContain(label); + describe('external', () => { + it('passes prop', () => { + const button = mountWithAppProvider(, ); expect(button.find(Spinner).exists()).toBeTruthy(); }); - it('sets an alert role on the button', () => { - const button = mountWithAppProvider(, - ).find('button'); + ).find(UnstyledButton); trigger(button, 'onKeyPress'); expect(spy).toHaveBeenCalled(); @@ -442,7 +337,7 @@ describe(', - ).find('button'); + ).find(UnstyledButton); trigger(button, 'onKeyUp'); expect(spy).toHaveBeenCalled(); }); @@ -453,7 +348,7 @@ describe(', - ).find('button'); + ).find(UnstyledButton); trigger(button, 'onKeyDown'); expect(spy).toHaveBeenCalled(); }); @@ -464,21 +359,21 @@ describe('