From d7d453f7333def28007b94b9c9d872f89224fc91 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Thu, 31 Mar 2022 14:25:12 +0200 Subject: chore: add a button component --- src/components/atoms/buttons/button.stories.tsx | 120 ++++++++++++++++++ src/components/atoms/buttons/button.test.tsx | 18 +++ src/components/atoms/buttons/button.tsx | 53 ++++++++ src/components/atoms/buttons/buttons.module.scss | 154 +++++++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 src/components/atoms/buttons/button.stories.tsx create mode 100644 src/components/atoms/buttons/button.test.tsx create mode 100644 src/components/atoms/buttons/button.tsx create mode 100644 src/components/atoms/buttons/buttons.module.scss (limited to 'src') diff --git a/src/components/atoms/buttons/button.stories.tsx b/src/components/atoms/buttons/button.stories.tsx new file mode 100644 index 0000000..5af61bd --- /dev/null +++ b/src/components/atoms/buttons/button.stories.tsx @@ -0,0 +1,120 @@ +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, + }, + }, + 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'], + 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, + }, + }, + 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; + +const Template: ComponentStory = (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 ( + + {getBody()} + + ); +}; + +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(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('renders the Button component with disabled state', () => { + render( + + ); + 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..420ee74 --- /dev/null +++ b/src/components/atoms/buttons/button.tsx @@ -0,0 +1,53 @@ +import { FC, MouseEventHandler } from 'react'; +import styles from './buttons.module.scss'; + +export type ButtonProps = { + /** + * Button accessible label. + */ + 'aria-label'?: string; + /** + * Button state. Default: false. + */ + disabled?: boolean; + /** + * Button kind. Default: secondary. + */ + kind?: 'primary' | 'secondary' | 'tertiary'; + /** + * A callback function to handle click. + */ + onClick?: MouseEventHandler; + /** + * Button type attribute. Default: button. + */ + type?: 'button' | 'reset' | 'submit'; +}; + +/** + * Button component + * + * Use a button as call to action. + */ +const Button: FC = ({ + children, + disabled = false, + kind = 'secondary', + type = 'button', + ...props +}) => { + const kindClass = styles[`btn--${kind}`]; + + return ( + + ); +}; + +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..d6a488d --- /dev/null +++ b/src/components/atoms/buttons/buttons.module.scss @@ -0,0 +1,154 @@ +@use "@styles/abstracts/functions" as fun; + +.btn { + display: block; + max-width: max-content; + padding: var(--spacing-2xs) var(--spacing-md); + 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; + + &:disabled { + cursor: wait; + } + + &--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-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); + 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(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(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)}); + } + } + } +} -- cgit v1.2.3 From 351532e9906e32c862bf6810a3871993cde13ba2 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Thu, 31 Mar 2022 15:30:07 +0200 Subject: chore: add a button link component --- .../atoms/buttons/button-link.stories.tsx | 67 ++++++++++++++++++++++ src/components/atoms/buttons/button-link.test.tsx | 9 +++ src/components/atoms/buttons/button-link.tsx | 49 ++++++++++++++++ src/components/atoms/buttons/buttons.module.scss | 2 + 4 files changed, 127 insertions(+) create mode 100644 src/components/atoms/buttons/button-link.stories.tsx create mode 100644 src/components/atoms/buttons/button-link.test.tsx create mode 100644 src/components/atoms/buttons/button-link.tsx (limited to 'src') 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..d4df676 --- /dev/null +++ b/src/components/atoms/buttons/button-link.stories.tsx @@ -0,0 +1,67 @@ +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, + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'The link kind.', + options: ['primary', 'secondary'], + table: { + category: 'Options', + defaultValue: { summary: 'secondary' }, + }, + type: { + name: 'string', + required: false, + }, + }, + target: { + control: { + type: null, + }, + description: 'The link target.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (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(Button Link); + 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..c33a4b7 --- /dev/null +++ b/src/components/atoms/buttons/button-link.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; +import { FC } from 'react'; +import styles from './buttons.module.scss'; + +type ButtonLinkProps = { + /** + * ButtonLink accessible label. + */ + 'aria-label'?: string; + /** + * True if it is an external link. Default: false. + */ + external?: boolean; + /** + * ButtonLink kind. Default: secondary. + */ + kind?: 'primary' | 'secondary'; + /** + * Define an URL as target. + */ + target: string; +}; + +/** + * ButtonLink component + * + * Use a button-like link as call to action. + */ +const ButtonLink: FC = ({ + children, + target, + kind = 'secondary', + external = false, + ...props +}) => { + const kindClass = styles[`btn--${kind}`]; + + return external ? ( + + {children} + + ) : ( + + {children} + + ); +}; + +export default ButtonLink; diff --git a/src/components/atoms/buttons/buttons.module.scss b/src/components/atoms/buttons/buttons.module.scss index d6a488d..9dddf48 100644 --- a/src/components/atoms/buttons/buttons.module.scss +++ b/src/components/atoms/buttons/buttons.module.scss @@ -22,6 +22,7 @@ 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 { @@ -36,6 +37,7 @@ 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)}); } -- cgit v1.2.3 From 8370602f37ad6aa02485d85e5b179b76c3f15701 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Thu, 31 Mar 2022 15:39:55 +0200 Subject: chore: add a Heading component --- src/components/atoms/headings/heading.stories.tsx | 37 ++++++++++++++++++ src/components/atoms/headings/heading.test.tsx | 46 +++++++++++++++++++++++ src/components/atoms/headings/heading.tsx | 21 +++++++++++ 3 files changed, 104 insertions(+) create mode 100644 src/components/atoms/headings/heading.stories.tsx create mode 100644 src/components/atoms/headings/heading.test.tsx create mode 100644 src/components/atoms/headings/heading.tsx (limited to 'src') diff --git a/src/components/atoms/headings/heading.stories.tsx b/src/components/atoms/headings/heading.stories.tsx new file mode 100644 index 0000000..9958af9 --- /dev/null +++ b/src/components/atoms/headings/heading.stories.tsx @@ -0,0 +1,37 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import HeadingComponent from './heading'; + +export default { + title: 'Atoms/Headings', + component: HeadingComponent, + argTypes: { + children: { + description: 'Heading body.', + type: { + name: 'string', + required: true, + }, + }, + level: { + control: { + type: 'select', + }, + description: 'Heading level.', + options: [1, 2, 3, 4, 5, 6], + type: { + name: 'number', + required: true, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => { + const { level, ...props } = args; + return ; +}; + +export const Heading = Template.bind({}); +Heading.args = { + children: 'Your title', +}; diff --git a/src/components/atoms/headings/heading.test.tsx b/src/components/atoms/headings/heading.test.tsx new file mode 100644 index 0000000..b83f7cd --- /dev/null +++ b/src/components/atoms/headings/heading.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@test-utils'; +import Heading from './heading'; + +describe('Heading', () => { + it('renders a h1', () => { + render(Level 1); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'Level 1' + ); + }); + + it('renders a h2', () => { + render(Level 2); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + 'Level 2' + ); + }); + + it('renders a h3', () => { + render(Level 3); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent( + 'Level 3' + ); + }); + + it('renders a h4', () => { + render(Level 4); + expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent( + 'Level 4' + ); + }); + + it('renders a h5', () => { + render(Level 5); + expect(screen.getByRole('heading', { level: 5 })).toHaveTextContent( + 'Level 5' + ); + }); + + it('renders a h6', () => { + render(Level 6); + expect(screen.getByRole('heading', { level: 6 })).toHaveTextContent( + 'Level 6' + ); + }); +}); diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx new file mode 100644 index 0000000..1535140 --- /dev/null +++ b/src/components/atoms/headings/heading.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; + +type HeadingProps = { + /** + * HTML heading level: 'h1', 'h2', 'h3', 'h4', 'h5' or 'h6'. + */ + level: 1 | 2 | 3 | 4 | 5 | 6; +}; + +/** + * Heading component. + * + * Render an HTML heading element. + */ +const Heading: FC = ({ children, level }) => { + const TitleTag = `h${level}` as keyof JSX.IntrinsicElements; + + return {children}; +}; + +export default Heading; -- cgit v1.2.3 From b145ed4492de834f5cea9437e9772c4f7fbe90ec Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Thu, 31 Mar 2022 17:57:39 +0200 Subject: chore: add a Field component --- src/components/atoms/forms/field.stories.tsx | 176 +++++++++++++++++++++++++++ src/components/atoms/forms/field.test.tsx | 14 +++ src/components/atoms/forms/field.tsx | 94 ++++++++++++++ src/components/atoms/forms/forms.module.scss | 39 ++++++ 4 files changed, 323 insertions(+) create mode 100644 src/components/atoms/forms/field.stories.tsx create mode 100644 src/components/atoms/forms/field.test.tsx create mode 100644 src/components/atoms/forms/field.tsx create mode 100644 src/components/atoms/forms/forms.module.scss (limited to 'src') diff --git a/src/components/atoms/forms/field.stories.tsx b/src/components/atoms/forms/field.stories.tsx new file mode 100644 index 0000000..0406f10 --- /dev/null +++ b/src/components/atoms/forms/field.stories.tsx @@ -0,0 +1,176 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import FieldComponent from './field'; + +export default { + title: 'Atoms/Forms', + component: FieldComponent, + args: { + disabled: false, + required: false, + setValue: () => null, + type: 'text', + value: '', + }, + argTypes: { + disabled: { + control: { + type: 'boolean', + }, + description: 'Field state: either enabled or disabled.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'Field id.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + max: { + control: { + type: 'number', + }, + description: 'Maximum value.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + min: { + control: { + type: 'number', + }, + description: 'Minimum value.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'Field name.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + placeholder: { + control: { + type: 'text', + }, + description: 'A placeholder value.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + required: { + control: { + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + setValue: { + control: { + type: null, + }, + description: 'Callback function to set field value.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: true, + }, + }, + step: { + control: { + type: 'number', + }, + description: 'Field incremental values that are valid.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + type: { + control: { + type: 'select', + }, + description: 'Field type: input type or textarea.', + options: [ + 'datetime-local', + 'email', + 'number', + 'search', + 'tel', + 'text', + 'textarea', + 'time', + 'url', + ], + type: { + name: 'string', + required: true, + }, + }, + value: { + control: { + type: 'text', + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +export const Field = Template.bind({}); +Field.args = { + setValue: () => null, + value: '', +}; diff --git a/src/components/atoms/forms/field.test.tsx b/src/components/atoms/forms/field.test.tsx new file mode 100644 index 0000000..5488220 --- /dev/null +++ b/src/components/atoms/forms/field.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@test-utils'; +import Field from './field'; + +describe('Field', () => { + it('renders a text input', () => { + render( null} />); + expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text'); + }); + + it('renders a search input', () => { + render( null} />); + expect(screen.getByRole('searchbox')).toHaveAttribute('type', 'search'); + }); +}); diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx new file mode 100644 index 0000000..7d1ac93 --- /dev/null +++ b/src/components/atoms/forms/field.tsx @@ -0,0 +1,94 @@ +import { ChangeEvent, FC, SetStateAction } from 'react'; +import styles from './forms.module.scss'; + +type FieldType = + | 'datetime-local' + | 'email' + | 'number' + | 'search' + | 'tel' + | 'text' + | 'textarea' + | 'time' + | 'url'; + +type FieldProps = { + /** + * Field state. Either enabled (false) or disabled (true). + */ + disabled?: boolean; + /** + * Field id attribute. + */ + id?: string; + /** + * Field maximum value. + */ + max?: number | string; + /** + * Field minimum value. + */ + min?: number | string; + /** + * Field name attribute. + */ + name?: string; + /** + * Placeholder value. + */ + placeholder?: string; + /** + * True if the field is required. Default: false. + */ + required?: boolean; + /** + * Callback function to set field value. + */ + setValue: (value: SetStateAction) => void; + /** + * Field incremental values that are valid. + */ + step?: number | string; + /** + * Field type. Default: text. + */ + type: FieldType; + /** + * Field value. + */ + value: string; +}; + +/** + * Field component. + * + * Render either an input or a textarea. + */ +const Field: FC = ({ setValue, type, ...props }) => { + /** + * Update select value when an option is selected. + * @param e - The option change event. + */ + const updateValue = ( + e: ChangeEvent + ) => { + setValue(e.target.value); + }; + + return type === 'textarea' ? ( +