Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,13 +467,13 @@ module.exports = {
extends: [ 'plugin:ssr-friendly/recommended' ],
},
{
files: [ 'packages/components/src/**' ],
files: [ 'packages/components/src/**', 'packages/ui/src/**' ],
rules: {
'no-restricted-imports': [
'error',
// The `ariakit` and `framer-motion` APIs are meant to be consumed via
// the `@wordpress/components` package, hence why importing those
// dependencies should be allowed in the components package.
// the `@wordpress/components` and @wordpress/ui` packages, hence why
// importing those imports should be allowed only in those packages.
{
paths: restrictedImports.filter(
( { name } ) =>
Expand Down
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
- Add `Field` primitives ([#74190](https://github.com/WordPress/gutenberg/pull/74190)).
- Add `Fieldset` primitives ([#74296](https://github.com/WordPress/gutenberg/pull/74296)).
- Add `Icon` component ([#74311](https://github.com/WordPress/gutenberg/pull/74311)).
- Add `Button` component ([#74415](https://github.com/WordPress/gutenberg/pull/74415)).
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
"types": "build-types",
"sideEffects": false,
"dependencies": {
"@ariakit/react": "^0.4.15",
Copy link
Contributor Author

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

"@base-ui/react": "^1.0.0",
"@wordpress/a11y": "file:../a11y",
"@wordpress/element": "file:../element",
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
Expand Down
7 changes: 0 additions & 7 deletions packages/ui/src/badge/badge.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
/**
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';

/**
* Internal dependencies
*/
import { Box } from '../box';
import { type BoxProps } from '../box/types';
import { type BadgeProps } from './types';
Expand Down
11 changes: 0 additions & 11 deletions packages/ui/src/badge/stories/index.story.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
/**
* External dependencies
*/
import type { Meta, StoryObj } from '@storybook/react-webpack5';

/**
* WordPress dependencies
*/
import { Fragment } from '@wordpress/element';

/**
* Internal dependencies
*/
import { Badge } from '../index';

const meta: Meta< typeof Badge > = {
Expand Down
3 changes: 0 additions & 3 deletions packages/ui/src/badge/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
/**
* Internal dependencies
*/
import { type ComponentProps } from '../utils/types';

export interface BadgeProps extends ComponentProps< 'span' > {
Expand Down
57 changes: 57 additions & 0 deletions packages/ui/src/button/button.tsx
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' ],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just FYI I'm thinking of trying composes to restructure these focus styles, but this is fine for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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>
);
}
);
24 changes: 24 additions & 0 deletions packages/ui/src/button/icon.tsx
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 }
/>
);
}
);
16 changes: 16 additions & 0 deletions packages/ui/src/button/index.ts
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;
};
208 changes: 208 additions & 0 deletions packages/ui/src/button/stories/index.story.tsx
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>
);
},
};
Loading
Loading