-
Notifications
You must be signed in to change notification settings - Fork 4.7k
UI: add Button
#74415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
UI: add Button
#74415
Changes from all commits
c2ae086
341dcfb
496fbe3
13a5020
d4bd206
77da8f3
29d18d5
d884973
7cd2558
341db35
4a5c963
7d0bccc
f5bff89
89fb48a
fdfa5ce
cae0a19
5988f7d
a204395
86948ae
2460f89
22a6986
6f34156
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { Button as AriakitButton } from '@ariakit/react'; | ||
| import clsx from 'clsx'; | ||
| import { speak } from '@wordpress/a11y'; | ||
| import { forwardRef, useEffect } from '@wordpress/element'; | ||
| import { __ } from '@wordpress/i18n'; | ||
| import { type ButtonProps } from './types'; | ||
| import styles from './style.module.css'; | ||
| import resetStyles from '../utils/css/resets.module.css'; | ||
| import focusStyles from '../utils/css/focus.module.css'; | ||
|
|
||
| export const Button = forwardRef< HTMLButtonElement, ButtonProps >( | ||
| function Button( | ||
| { | ||
| tone = 'brand', | ||
| variant = 'solid', | ||
| size = 'default', | ||
| className, | ||
| accessibleWhenDisabled = true, | ||
| disabled, | ||
| loading, | ||
| loadingAnnouncement = __( 'Loading' ), | ||
| children, | ||
| ...props | ||
| }, | ||
| ref | ||
| ) { | ||
| const mergedClassName = clsx( | ||
| resetStyles[ 'box-sizing' ], | ||
| focusStyles[ 'outset-ring--focus-except-active' ], | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just FYI I'm thinking of trying
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Curious to see how that goes |
||
| variant !== 'unstyled' && styles.button, | ||
| styles[ `is-${ tone }` ], | ||
| styles[ `is-${ variant }` ], | ||
| styles[ `is-${ size }` ], | ||
| loading && styles[ 'is-loading' ], | ||
| className | ||
| ); | ||
|
|
||
| // Announce loading state to assistive technology | ||
| useEffect( () => { | ||
| if ( loading && loadingAnnouncement ) { | ||
| speak( loadingAnnouncement ); | ||
| } | ||
| }, [ loading, loadingAnnouncement ] ); | ||
|
|
||
| return ( | ||
| <AriakitButton | ||
| ref={ ref } | ||
| className={ mergedClassName } | ||
| accessibleWhenDisabled={ accessibleWhenDisabled } | ||
| disabled={ disabled ?? loading } | ||
| { ...props } | ||
| > | ||
| { children } | ||
| </AriakitButton> | ||
| ); | ||
| } | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { forwardRef } from '@wordpress/element'; | ||
| import { type IconProps } from '../icon/types'; | ||
| import { Icon } from '../icon'; | ||
|
|
||
| interface ButtonIconProps extends IconProps { | ||
| /** | ||
| * The icon to display, from the `@wordpress/icons` package. | ||
| */ | ||
| icon: IconProps[ 'icon' ]; | ||
| } | ||
|
|
||
| export const ButtonIcon = forwardRef< SVGSVGElement, ButtonIconProps >( | ||
| function ButtonIcon( { icon, ...props }, ref ) { | ||
| return ( | ||
| <Icon | ||
| ref={ ref } | ||
| icon={ icon } | ||
| viewBox="4 4 16 16" | ||
| size={ 16 } | ||
| { ...props } | ||
| /> | ||
| ); | ||
| } | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { Button as ButtonButton } from './button'; | ||
| import { ButtonIcon } from './icon'; | ||
|
|
||
| /** | ||
| * A versatile button component with multiple variants, tones, and sizes. | ||
| * Built on design tokens for consistent theming and accessibility. | ||
| */ | ||
| export const Button = Object.assign( ButtonButton, { | ||
| /** | ||
| * An icon component specifically designed to work well when rendered inside | ||
| * a `Button` component. | ||
| */ | ||
| Icon: ButtonIcon, | ||
| } ) as typeof ButtonButton & { | ||
| Icon: typeof ButtonIcon; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| import { Fragment, useState } from '@wordpress/element'; | ||
| import type { Meta, StoryObj } from '@storybook/react-webpack5'; | ||
| import { cog } from '@wordpress/icons'; | ||
| import { Button } from '../index'; | ||
|
|
||
| const meta: Meta< typeof Button > = { | ||
| title: 'Design System/Components/Button', | ||
| component: Button, | ||
| argTypes: { | ||
| 'aria-pressed': { | ||
| control: { type: 'boolean' }, | ||
| }, | ||
| }, | ||
| }; | ||
| export default meta; | ||
|
|
||
| type Story = StoryObj< typeof Button >; | ||
|
|
||
| export const Default: Story = { | ||
| args: { | ||
| children: 'Button', | ||
| }, | ||
| }; | ||
|
|
||
| export const Outline: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| variant: 'outline', | ||
| }, | ||
| }; | ||
|
|
||
| export const Minimal: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| variant: 'minimal', | ||
| }, | ||
| }; | ||
|
|
||
| export const Compact: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| size: 'compact', | ||
| }, | ||
| }; | ||
|
|
||
| export const Small: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| size: 'small', | ||
| }, | ||
| }; | ||
|
|
||
| export const Neutral: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| tone: 'neutral', | ||
| }, | ||
| }; | ||
|
|
||
| export const NeutralOutline: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| tone: 'neutral', | ||
| variant: 'outline', | ||
| }, | ||
| }; | ||
|
|
||
| export const Unstyled: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| variant: 'unstyled', | ||
| }, | ||
| }; | ||
|
|
||
| export const AllTonesAndVariants: Story = { | ||
| ...Default, | ||
| render: ( args ) => ( | ||
| <div | ||
| style={ { | ||
| display: 'grid', | ||
| gridTemplateColumns: 'max-content repeat(2, min-content)', | ||
| color: 'var(--wpds-color-fg-content-neutral)', | ||
| } } | ||
| > | ||
| <div></div> | ||
| <div style={ { textAlign: 'center' } }>Resting</div> | ||
| <div style={ { textAlign: 'center' } }>Disabled</div> | ||
| { ( [ 'brand', 'neutral' ] as const ).map( ( tone ) => ( | ||
| <Fragment key={ tone }> | ||
| { ( | ||
| [ 'solid', 'outline', 'minimal', 'unstyled' ] as const | ||
| ).map( ( variant ) => ( | ||
| <Fragment key={ variant }> | ||
| <div | ||
| style={ { | ||
| paddingInlineEnd: '1rem', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| } } | ||
| > | ||
| { variant }, { tone } | ||
| </div> | ||
| <div | ||
| style={ { | ||
| padding: '0.5rem 1rem', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| } } | ||
| > | ||
| <Button | ||
| { ...args } | ||
| tone={ tone } | ||
| variant={ variant } | ||
| /> | ||
| </div> | ||
| <div | ||
| style={ { | ||
| padding: '0.5rem 1rem', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| } } | ||
| > | ||
| <Button | ||
| { ...args } | ||
| tone={ tone } | ||
| variant={ variant } | ||
| // Disabling because this lint rule was meant for the | ||
| // `@wordpress/components` Button, but is being applied here. | ||
| // TODO: rework the lint rule so that it checks the package | ||
| // where the Button comes from. | ||
| // eslint-disable-next-line no-restricted-syntax | ||
| disabled | ||
| /> | ||
| </div> | ||
| </Fragment> | ||
| ) ) } | ||
| </Fragment> | ||
| ) ) } | ||
| </div> | ||
| ), | ||
| }; | ||
|
|
||
| export const LinkStyledAsButton: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| // Link content passed through `children` | ||
| // eslint-disable-next-line jsx-a11y/anchor-has-content | ||
| render: <a href="https://example.com" />, | ||
| children: 'Link', | ||
| }, | ||
| }; | ||
|
|
||
| export const WithIcon: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| children: ( | ||
| <> | ||
| <Button.Icon icon={ cog } /> | ||
| Button | ||
| </> | ||
| ), | ||
| }, | ||
| }; | ||
|
|
||
| export const Loading: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| loading: true, | ||
| loadingAnnouncement: 'Saving data', | ||
| }, | ||
| }; | ||
|
|
||
| /** | ||
| * The pressed state is only available for buttons with `tone="neutral"` and | ||
| * `variant="minimal"`. This represents a toggle button that is currently in an | ||
| * active/pressed state. | ||
| */ | ||
| export const Pressed: Story = { | ||
| ...Default, | ||
| args: { | ||
| ...Default.args, | ||
| tone: 'neutral', | ||
| variant: 'minimal', | ||
| }, | ||
| render: ( args ) => { | ||
| const [ isPressed, setIsPressed ] = useState( true ); | ||
|
|
||
| return ( | ||
| <Button | ||
| { ...args } | ||
| aria-pressed={ isPressed } | ||
| onClick={ () => setIsPressed( ! isPressed ) } | ||
| > | ||
| Button | ||
| </Button> | ||
| ); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using ariakit for now, although I've already prepared a PR for the refactor to base ui: #74416