diff options
Diffstat (limited to 'src/components/atoms/buttons')
| -rw-r--r-- | src/components/atoms/buttons/button-link.stories.tsx | 95 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button-link.test.tsx | 9 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button-link.tsx | 69 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button.stories.tsx | 148 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button.test.tsx | 18 | ||||
| -rw-r--r-- | src/components/atoms/buttons/button.tsx | 64 | ||||
| -rw-r--r-- | src/components/atoms/buttons/buttons.module.scss | 178 |
7 files changed, 581 insertions, 0 deletions
diff --git a/src/components/atoms/buttons/button-link.stories.tsx b/src/components/atoms/buttons/button-link.stories.tsx new file mode 100644 index 0000000..92b7521 --- /dev/null +++ b/src/components/atoms/buttons/button-link.stories.tsx @@ -0,0 +1,95 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ButtonLinkComponent from './button-link'; + +export default { + title: 'Atoms/Buttons', + component: ButtonLinkComponent, + argTypes: { + 'aria-label': { + control: { + type: 'text', + }, + description: 'An accessible label.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + children: { + control: { + type: 'text', + }, + description: 'The link body.', + type: { + name: 'string', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the button link.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'The link kind.', + options: ['primary', 'secondary'], + table: { + category: 'Options', + defaultValue: { summary: 'secondary' }, + }, + type: { + name: 'string', + required: false, + }, + }, + shape: { + control: { + type: 'select', + }, + description: 'The link shape.', + options: ['rectangle', 'square'], + table: { + category: 'Options', + defaultValue: { summary: 'rectangle' }, + }, + type: { + name: 'string', + required: false, + }, + }, + target: { + control: { + type: null, + }, + description: 'The link target.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof ButtonLinkComponent>; + +const Template: ComponentStory<typeof ButtonLinkComponent> = (args) => ( + <ButtonLinkComponent {...args} /> +); + +export const ButtonLink = Template.bind({}); +ButtonLink.args = { + children: 'Link', + target: '#', +}; diff --git a/src/components/atoms/buttons/button-link.test.tsx b/src/components/atoms/buttons/button-link.test.tsx new file mode 100644 index 0000000..52ccdc7 --- /dev/null +++ b/src/components/atoms/buttons/button-link.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import ButtonLink from './button-link'; + +describe('ButtonLink', () => { + it('renders a ButtonLink component', () => { + render(<ButtonLink target="#">Button Link</ButtonLink>); + expect(screen.getByRole('link')).toHaveTextContent('Button Link'); + }); +}); diff --git a/src/components/atoms/buttons/button-link.tsx b/src/components/atoms/buttons/button-link.tsx new file mode 100644 index 0000000..77a7f7b --- /dev/null +++ b/src/components/atoms/buttons/button-link.tsx @@ -0,0 +1,69 @@ +import Link from 'next/link'; +import { FC } from 'react'; +import styles from './buttons.module.scss'; + +export type ButtonLinkProps = { + /** + * ButtonLink accessible label. + */ + 'aria-label'?: string; + /** + * Set additional classnames to the button link. + */ + className?: string; + /** + * True if it is an external link. Default: false. + */ + external?: boolean; + /** + * ButtonLink kind. Default: secondary. + */ + kind?: 'primary' | 'secondary'; + /** + * ButtonLink shape. Default: rectangle. + */ + shape?: 'circle' | 'rectangle' | 'square'; + /** + * Define an URL as target. + */ + target: string; +}; + +/** + * ButtonLink component + * + * Use a button-like link as call to action. + */ +const ButtonLink: FC<ButtonLinkProps> = ({ + children, + className, + target, + kind = 'secondary', + shape = 'rectangle', + external = false, + ...props +}) => { + const kindClass = styles[`btn--${kind}`]; + const shapeClass = styles[`btn--${shape}`]; + + return external ? ( + <a + href={target} + className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`} + {...props} + > + {children} + </a> + ) : ( + <Link href={target}> + <a + className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`} + {...props} + > + {children} + </a> + </Link> + ); +}; + +export default ButtonLink; diff --git a/src/components/atoms/buttons/button.stories.tsx b/src/components/atoms/buttons/button.stories.tsx new file mode 100644 index 0000000..d47a1ea --- /dev/null +++ b/src/components/atoms/buttons/button.stories.tsx @@ -0,0 +1,148 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ButtonComponent from './button'; + +export default { + title: 'Atoms/Buttons', + component: ButtonComponent, + args: { + disabled: false, + kind: 'secondary', + type: 'button', + }, + argTypes: { + 'aria-label': { + control: { + type: 'text', + }, + description: 'An accessible label.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + children: { + control: { + type: 'text', + }, + description: 'The button body.', + type: { + name: 'string', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the button wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + disabled: { + control: { + type: 'boolean', + }, + description: 'Render button as disabled.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'Button kind.', + options: ['primary', 'secondary', 'tertiary', 'neutral'], + table: { + category: 'Options', + defaultValue: { summary: 'secondary' }, + }, + type: { + name: 'string', + required: false, + }, + }, + onClick: { + control: { + type: null, + }, + description: 'A callback function to handle click.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: false, + }, + }, + shape: { + control: { + type: 'select', + }, + description: 'The link shape.', + options: ['circle', 'rectangle', 'square', 'initial'], + table: { + category: 'Options', + defaultValue: { summary: 'rectangle' }, + }, + type: { + name: 'string', + required: false, + }, + }, + type: { + control: { + type: 'select', + }, + description: 'Button type attribute.', + options: ['button', 'reset', 'submit'], + table: { + category: 'Options', + defaultValue: { summary: 'button' }, + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof ButtonComponent>; + +const Template: ComponentStory<typeof ButtonComponent> = (args) => { + const { children, type, ...props } = args; + + const getBody = () => { + if (children) return children; + + switch (type) { + case 'reset': + return 'Reset'; + case 'submit': + return 'Submit'; + case 'button': + default: + return 'Button'; + } + }; + + return ( + <ButtonComponent type={type} {...props}> + {getBody()} + </ButtonComponent> + ); +}; + +export const Button = Template.bind({}); diff --git a/src/components/atoms/buttons/button.test.tsx b/src/components/atoms/buttons/button.test.tsx new file mode 100644 index 0000000..57c79c6 --- /dev/null +++ b/src/components/atoms/buttons/button.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@test-utils'; +import Button from './button'; + +describe('Button', () => { + it('renders the Button component', () => { + render(<Button onClick={() => null}>Button</Button>); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('renders the Button component with disabled state', () => { + render( + <Button onClick={() => null} disabled={true}> + Disabled Button + </Button> + ); + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/src/components/atoms/buttons/button.tsx b/src/components/atoms/buttons/button.tsx new file mode 100644 index 0000000..545c5c5 --- /dev/null +++ b/src/components/atoms/buttons/button.tsx @@ -0,0 +1,64 @@ +import { FC, MouseEventHandler } from 'react'; +import styles from './buttons.module.scss'; + +export type ButtonProps = { + /** + * Set additional classnames to the button wrapper. + */ + className?: string; + /** + * Button accessible label. + */ + 'aria-label'?: string; + /** + * Button state. Default: false. + */ + disabled?: boolean; + /** + * Button kind. Default: secondary. + */ + kind?: 'primary' | 'secondary' | 'tertiary' | 'neutral'; + /** + * A callback function to handle click. + */ + onClick?: MouseEventHandler<HTMLButtonElement>; + /** + * Button shape. Default: rectangle. + */ + shape?: 'circle' | 'rectangle' | 'square' | 'initial'; + /** + * Button type attribute. Default: button. + */ + type?: 'button' | 'reset' | 'submit'; +}; + +/** + * Button component + * + * Use a button as call to action. + */ +const Button: FC<ButtonProps> = ({ + className = '', + children, + disabled = false, + kind = 'secondary', + shape = 'rectangle', + type = 'button', + ...props +}) => { + const kindClass = styles[`btn--${kind}`]; + const shapeClass = styles[`btn--${shape}`]; + + return ( + <button + type={type} + disabled={disabled} + className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`} + {...props} + > + {children} + </button> + ); +}; + +export default Button; diff --git a/src/components/atoms/buttons/buttons.module.scss b/src/components/atoms/buttons/buttons.module.scss new file mode 100644 index 0000000..8e3e196 --- /dev/null +++ b/src/components/atoms/buttons/buttons.module.scss @@ -0,0 +1,178 @@ +@use "@styles/abstracts/functions" as fun; + +.btn { + display: inline-flex; + place-content: center; + align-items: center; + border: none; + border-radius: fun.convert-px(5); + font-size: var(--font-size-md); + font-weight: 600; + transition: all 0.3s ease-in-out 0s; + + &--initial { + border-radius: 0; + } + + &--rectangle { + padding: var(--spacing-2xs) var(--spacing-md); + } + + &--square, + &--circle { + padding: var(--spacing-xs); + aspect-ratio: 1 / 1; + } + + &--circle { + border-radius: 50%; + } + + &:disabled { + cursor: wait; + } + + &--neutral { + background: inherit; + } + + &--primary { + background: var(--color-primary); + border: fun.convert-px(2) solid var(--color-bg); + box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary), + 0 0 0 fun.convert-px(3) var(--color-primary-darker), + fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(3) + var(--color-primary-dark); + color: var(--color-fg-inverted); + text-decoration: none; + text-shadow: fun.convert-px(2) fun.convert-px(2) 0 var(--color-shadow); + + &:disabled { + background: var(--color-primary-darker); + } + + &:not(:disabled) { + &:hover, + &:focus { + background: var(--color-primary-light); + box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary-light), + 0 0 0 fun.convert-px(3) var(--color-primary-darker), + fun.convert-px(7) fun.convert-px(7) 0 fun.convert-px(2) + var(--color-primary-dark); + color: var(--color-fg-inverted); + transform: translateX(#{fun.convert-px(-4)}) + translateY(#{fun.convert-px(-4)}); + } + + &:focus { + text-decoration: underline solid var(--color-fg-inverted) + fun.convert-px(2); + } + + &:active { + background: var(--color-primary-dark); + box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary), + 0 0 0 fun.convert-px(3) var(--color-primary-darker), + 0 0 0 0 var(--color-primary-dark); + text-decoration: none; + transform: translateX(#{fun.convert-px(4)}) + translateY(#{fun.convert-px(4)}); + } + } + } + + &--secondary { + background: var(--color-bg); + border: fun.convert-px(3) solid var(--color-primary); + box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) + var(--color-shadow), + fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) + var(--color-shadow), + fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) + var(--color-shadow); + color: var(--color-primary); + text-decoration: underline transparent 0; + + &:disabled { + border-color: var(--color-border-dark); + color: var(--color-fg-light); + } + + &:not(:disabled) { + &:hover, + &:focus { + border-color: var(--color-primary-light); + color: var(--color-primary-light); + box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) + var(--color-shadow-light), + fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) + fun.convert-px(-2) var(--color-shadow-light), + fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) + fun.convert-px(-4) var(--color-shadow-light), + fun.convert-px(7) fun.convert-px(10) fun.convert-px(12) + fun.convert-px(-3) var(--color-shadow-light); + transform: scale(var(--scale-up, 1.1)); + } + + &:focus { + text-decoration: underline var(--color-primary-light) fun.convert-px(3); + } + + &:active { + border-color: var(--color-primary-dark); + box-shadow: 0 0 0 0 var(--color-shadow); + color: var(--color-primary-dark); + text-decoration: underline transparent 0; + transform: scale(var(--scale-down, 0.94)); + } + } + } + + &--tertiary { + background: var(--color-bg); + border: fun.convert-px(3) solid var(--color-primary); + box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg), + fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-dark), + fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg), + fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-dark); + color: var(--color-primary); + + &:disabled { + color: var(--color-fg-light); + border-color: var(--color-border-dark); + box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg), + fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-darker), + fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg), + fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-darker); + } + + &:not(:disabled) { + &:hover, + &:focus { + border-color: var(--color-primary-light); + box-shadow: fun.convert-px(2) fun.convert-px(3) 0 0 var(--color-bg), + fun.convert-px(4) fun.convert-px(5) 0 0 var(--color-primary), + fun.convert-px(6) fun.convert-px(8) 0 0 var(--color-bg), + fun.convert-px(8) fun.convert-px(10) 0 0 var(--color-primary), + fun.convert-px(10) fun.convert-px(12) fun.convert-px(1) 0 + var(--color-shadow-light), + fun.convert-px(10) fun.convert-px(12) fun.convert-px(5) + fun.convert-px(1) var(--color-shadow-light); + color: var(--color-primary-light); + transform: translateX(#{fun.convert-px(-3)}) + translateY(#{fun.convert-px(-5)}); + } + + &:focus { + text-decoration: underline var(--color-primary) fun.convert-px(2); + } + + &:active { + box-shadow: 0 0 0 0 var(--color-shadow); + text-decoration: none; + transform: translateX(#{fun.convert-px(5)}) + translateY(#{fun.convert-px(6)}); + } + } + } +} |
