diff --git a/packages/css/src/components/button/button.scss b/packages/css/src/components/button/button.scss index 7b878d016d..89d2e4ce04 100644 --- a/packages/css/src/components/button/button.scss +++ b/packages/css/src/components/button/button.scss @@ -95,3 +95,8 @@ color: var(--ams-button-tertiary-hover-color); } } + +.ams-button--icon-position-only { + padding-block: var(--ams-button-icon-position-only-padding-block); + padding-inline: var(--ams-button-icon-position-only-padding-inline); +} diff --git a/packages/react/src/Button/Button.test.tsx b/packages/react/src/Button/Button.test.tsx index d4870120e9..60f1f8bdf0 100644 --- a/packages/react/src/Button/Button.test.tsx +++ b/packages/react/src/Button/Button.test.tsx @@ -1,3 +1,4 @@ +import { ShareIcon } from '@amsterdam/design-system-react-icons' import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { createRef } from 'react' @@ -104,10 +105,59 @@ describe('Button', () => { it('is able to pass a React ref', () => { const ref = createRef() - const { container } = render() const button = container.querySelector(':only-child') expect(ref.current).toBe(button) }) + + it('renders a button with an icon at the end', () => { + render( + , + ) + + const button = screen.getByRole('button', { + name: 'Share', + }) + + expect(button).toBeInTheDocument() + const icon = button.querySelector('.ams-icon:last-child') + expect(icon).toBeInTheDocument() + }) + + it('renders a button with an icon at the start', () => { + render( + , + ) + + const button = screen.getByRole('button', { + name: 'Share', + }) + + expect(button).toBeInTheDocument() + const icon = button.querySelector('.ams-icon:first-child') + expect(icon).toBeInTheDocument() + }) + + it('renders a button with an icon only', () => { + render( + , + ) + + const button = screen.getByRole('button', { + name: 'Share', + }) + + expect(button).toBeInTheDocument() + expect(button).toHaveClass('ams-button--icon-position-only') + const label = button.querySelector('.ams-visually-hidden') + expect(label).toHaveTextContent('Share') + }) }) diff --git a/packages/react/src/Button/Button.tsx b/packages/react/src/Button/Button.tsx index 79e42466b5..08a44f2d43 100644 --- a/packages/react/src/Button/Button.tsx +++ b/packages/react/src/Button/Button.tsx @@ -6,15 +6,30 @@ import clsx from 'clsx' import { forwardRef } from 'react' import type { ButtonHTMLAttributes, ForwardedRef, PropsWithChildren } from 'react' +import { Icon } from '../Icon' +import type { IconProps } from '../Icon' + +type IconButtonProps = { + /** An icon to add to the button. */ + icon: IconProps['svg'] + /** The position of the icon. The default is after the label. */ + iconPosition?: 'start' | 'only' +} + +type TextButtonProps = { + icon?: never + iconPosition?: never +} export type ButtonProps = { /** The level of prominence. Use a primary button only once per page or section. */ variant?: 'primary' | 'secondary' | 'tertiary' -} & PropsWithChildren> +} & (IconButtonProps | TextButtonProps) & + PropsWithChildren> export const Button = forwardRef( ( - { children, className, type, disabled, variant = 'primary', ...restProps }: ButtonProps, + { children, className, disabled, icon, iconPosition, type, variant = 'primary', ...restProps }: ButtonProps, ref: ForwardedRef, ) => { return ( @@ -22,10 +37,19 @@ export const Button = forwardRef( {...restProps} ref={ref} disabled={disabled} - className={clsx('ams-button', `ams-button--${variant}`, className)} + className={clsx( + 'ams-button', + `ams-button--${variant}`, + icon && iconPosition === 'only' && `ams-button--icon-position-only`, + className, + )} type={type || 'button'} > - {children} + {icon && (iconPosition === 'start' || iconPosition === 'only') && ( + + )} + {icon && iconPosition === 'only' ? {children} : children} + {icon && !iconPosition && } ) }, diff --git a/proprietary/tokens/src/components/ams/button.tokens.json b/proprietary/tokens/src/components/ams/button.tokens.json index 539e76c228..58cd0e7929 100644 --- a/proprietary/tokens/src/components/ams/button.tokens.json +++ b/proprietary/tokens/src/components/ams/button.tokens.json @@ -56,6 +56,10 @@ "background-color": { "value": "transparent" }, "color": { "value": "{ams.color.neutral-grey2}" } } + }, + "icon-position-only": { + "padding-block": { "value": "{ams.space.sm}" }, + "padding-inline": { "value": "{ams.space.sm}" } } } } diff --git a/storybook/src/components/Button/Button.docs.mdx b/storybook/src/components/Button/Button.docs.mdx index 4a3e258996..633c6b8ed9 100644 --- a/storybook/src/components/Button/Button.docs.mdx +++ b/storybook/src/components/Button/Button.docs.mdx @@ -16,28 +16,45 @@ import README from "../../../../packages/css/src/components/button/README.md?raw ### Primary -The most important call-to-action. -One primary Button may be used per screen. +A primary button draws attention to the most important call to action. +Only one primary Button should be used per screen. ### Secondary -Use the secondary Button for less prominent calls to action. -It is possible to use more than 1 secondary Button. +Use a secondary button for other actions. ### Tertiary -Use tertiary Buttons for unimportant calls to action – as many as necessary. +Tertiary buttons can be used to distinguish their importance from secondary buttons. +They are also a good choice for buttons with an icon only. -### With icon +### With an icon + +Add an icon if it makes it easier or faster to understand its purpose. +The icon will appear after the label. +### With an icon at the start + +The icon can also appear before the label. + + + +### With an icon only + +A button can even consist of an icon only. +Do this only for icons that are universally recognized. +You must still provide a label – it will be used to make the button accessible. + + + ### Text wrapping Keep in mind that the label may occasionally wrap over multiple lines: diff --git a/storybook/src/components/Button/Button.stories.tsx b/storybook/src/components/Button/Button.stories.tsx index 57255852da..15039cec67 100644 --- a/storybook/src/components/Button/Button.stories.tsx +++ b/storybook/src/components/Button/Button.stories.tsx @@ -3,27 +3,36 @@ * Copyright Gemeente Amsterdam */ -import { Icon } from '@amsterdam/design-system-react' import { Button } from '@amsterdam/design-system-react/src' -import { ShareIcon } from '@amsterdam/design-system-react-icons' +import * as Icons from '@amsterdam/design-system-react-icons' import { Meta, StoryObj } from '@storybook/react' const meta = { title: 'Components/Buttons/Button', component: Button, args: { - children: 'Button label', - variant: 'primary', + children: 'Versturen', disabled: false, + variant: 'primary', }, argTypes: { - children: { - description: 'The text for the label and/or an icon.', - table: { disable: false }, - }, disabled: { description: 'Prevents interaction. Avoid if possible.', }, + icon: { + control: { + type: 'select', + }, + options: Object.keys(Icons), + mapping: Icons, + }, + iconPosition: { + control: { + type: 'inline-radio', + labels: { undefined: 'end', start: 'start', only: 'only' }, + }, + options: [undefined, 'start', 'only'], + }, }, } satisfies Meta @@ -35,24 +44,39 @@ export const Primary: Story = {} export const Secondary: Story = { args: { + children: 'Annuleren', variant: 'secondary', }, } export const Tertiary: Story = { args: { + children: 'Terug', variant: 'tertiary', }, } export const WithIcon: Story = { args: { - children: ['Button label', ], + children: 'Sluiten', + icon: Icons.CloseIcon, }, - argTypes: { - children: { - table: { disable: true }, - }, +} + +export const WithIconAtStart: Story = { + args: { + children: 'Sluiten', + icon: Icons.CloseIcon, + iconPosition: 'start', + }, +} + +export const WithIconOnly: Story = { + args: { + children: 'Sluiten', + icon: Icons.CloseIcon, + iconPosition: 'only', + variant: 'tertiary', }, }