diff options
Diffstat (limited to 'src/components/atoms')
138 files changed, 5899 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)}); + } + } + } +} diff --git a/src/components/atoms/forms/checkbox.stories.tsx b/src/components/atoms/forms/checkbox.stories.tsx new file mode 100644 index 0000000..7faf343 --- /dev/null +++ b/src/components/atoms/forms/checkbox.stories.tsx @@ -0,0 +1,96 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import CheckboxComponent from './checkbox'; + +export default { + title: 'Atoms/Forms', + component: CheckboxComponent, + argTypes: { + 'aria-labelledby': { + control: { + type: 'text', + }, + description: 'One or more ids that refers to the checkbox name.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the checkbox.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'The checkbox id.', + type: { + name: 'string', + required: true, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'The checkbox name.', + type: { + name: 'string', + required: true, + }, + }, + setValue: { + control: { + type: null, + }, + description: 'A callback function to handle checkbox state.', + type: { + name: 'function', + required: true, + }, + }, + value: { + control: { + type: null, + }, + description: + 'The checkbox state: either checked (true) or unchecked (false).', + type: { + name: 'boolean', + required: true, + }, + }, + }, +} as ComponentMeta<typeof CheckboxComponent>; + +const Template: ComponentStory<typeof CheckboxComponent> = ({ + value, + setValue: _setValue, + ...args +}) => { + const [isChecked, setIsChecked] = useState<boolean>(value); + + return ( + <CheckboxComponent value={isChecked} setValue={setIsChecked} {...args} /> + ); +}; + +export const Checkbox = Template.bind({}); +Checkbox.args = { + id: 'storybook-checkbox', + name: 'storybook-checkbox', + value: false, +}; diff --git a/src/components/atoms/forms/checkbox.test.tsx b/src/components/atoms/forms/checkbox.test.tsx new file mode 100644 index 0000000..3b54549 --- /dev/null +++ b/src/components/atoms/forms/checkbox.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@test-utils'; +import Checkbox from './checkbox'; + +describe('Checkbox', () => { + it('renders an unchecked checkbox', () => { + render( + <Checkbox + id="jest-checkbox" + name="jest-checkbox" + value={false} + setValue={() => null} + /> + ); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('renders a checked checkbox', () => { + render( + <Checkbox + id="jest-checkbox" + name="jest-checkbox" + value={true} + setValue={() => null} + /> + ); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); +}); diff --git a/src/components/atoms/forms/checkbox.tsx b/src/components/atoms/forms/checkbox.tsx new file mode 100644 index 0000000..8babcc8 --- /dev/null +++ b/src/components/atoms/forms/checkbox.tsx @@ -0,0 +1,46 @@ +import { SetStateAction, VFC } from 'react'; + +export type CheckboxProps = { + /** + * One or more ids that refers to the checkbox name. + */ + 'aria-labelledby'?: string; + /** + * Add classnames to the checkbox. + */ + className?: string; + /** + * Checkbox id attribute. + */ + id: string; + /** + * Checkbox name attribute. + */ + name: string; + /** + * Callback function to set checkbox value. + */ + setValue: (value: SetStateAction<boolean>) => void; + /** + * Checkbox value. + */ + value: boolean; +}; + +/** + * Checkbox component + * + * Render a checkbox type input. + */ +const Checkbox: VFC<CheckboxProps> = ({ value, setValue, ...props }) => { + return ( + <input + type="checkbox" + checked={value} + onChange={() => setValue(!value)} + {...props} + /> + ); +}; + +export default Checkbox; diff --git a/src/components/atoms/forms/field.stories.tsx b/src/components/atoms/forms/field.stories.tsx new file mode 100644 index 0000000..ec81922 --- /dev/null +++ b/src/components/atoms/forms/field.stories.tsx @@ -0,0 +1,201 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import FieldComponent from './field'; + +export default { + title: 'Atoms/Forms', + component: FieldComponent, + args: { + disabled: false, + required: false, + type: 'text', + }, + argTypes: { + 'aria-labelledby': { + control: { + type: 'text', + }, + description: 'One or more ids that refers to the field name.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Add classnames to the field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + 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.', + type: { + name: 'string', + required: true, + }, + }, + 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.', + type: { + name: 'string', + required: true, + }, + }, + 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: null, + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof FieldComponent>; + +const Template: ComponentStory<typeof FieldComponent> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [value, setValue] = useState<string>(''); + + return <FieldComponent value={value} setValue={setValue} {...args} />; +}; + +export const Field = Template.bind({}); +Field.args = { + id: 'field-storybook', + name: 'field-storybook', +}; diff --git a/src/components/atoms/forms/field.test.tsx b/src/components/atoms/forms/field.test.tsx new file mode 100644 index 0000000..a04a976 --- /dev/null +++ b/src/components/atoms/forms/field.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@test-utils'; +import Field from './field'; + +describe('Field', () => { + it('renders a text input', () => { + render( + <Field + id="text-field" + name="text-field" + type="text" + value="" + setValue={() => null} + /> + ); + expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text'); + }); + + it('renders a search input', () => { + render( + <Field + id="search-field" + name="search-field" + type="search" + value="" + setValue={() => 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..2e75d0f --- /dev/null +++ b/src/components/atoms/forms/field.tsx @@ -0,0 +1,107 @@ +import { ChangeEvent, SetStateAction, VFC } from 'react'; +import styles from './forms.module.scss'; + +export type FieldType = + | 'datetime-local' + | 'email' + | 'number' + | 'search' + | 'tel' + | 'text' + | 'textarea' + | 'time' + | 'url'; + +export type FieldProps = { + /** + * One or more ids that refers to the field name. + */ + 'aria-labelledby'?: string; + /** + * Add classnames to the field. + */ + className?: string; + /** + * 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<string>) => 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: VFC<FieldProps> = ({ + className = '', + setValue, + type, + ...props +}) => { + /** + * Update select value when an option is selected. + * @param e - The option change event. + */ + const updateValue = ( + e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> + ) => { + setValue(e.target.value); + }; + + return type === 'textarea' ? ( + <textarea + onChange={updateValue} + className={`${styles.field} ${styles['field--textarea']} ${className}`} + {...props} + /> + ) : ( + <input + type={type} + onChange={updateValue} + className={`${styles.field} ${className}`} + {...props} + /> + ); +}; + +export default Field; diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx new file mode 100644 index 0000000..9cd3c58 --- /dev/null +++ b/src/components/atoms/forms/form.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import Form from './form'; + +describe('Form', () => { + it('renders a form', () => { + render(<Form aria-label="Jest form" onSubmit={() => null}></Form>); + expect(screen.getByRole('form', { name: 'Jest form' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx new file mode 100644 index 0000000..8e80930 --- /dev/null +++ b/src/components/atoms/forms/form.tsx @@ -0,0 +1,73 @@ +import { Children, FC, FormEvent, Fragment } from 'react'; +import styles from './forms.module.scss'; + +export type FormProps = { + /** + * An accessible name. + */ + 'aria-label'?: string; + /** + * One or more ids that refers to the form name. + */ + 'aria-labelledby'?: string; + /** + * Set additional classnames to the form wrapper. + */ + className?: string; + /** + * Wrap each items with a div. Default: true. + */ + grouped?: boolean; + /** + * A callback function to execute on submit. + */ + onSubmit: () => void; +}; + +/** + * Form component. + * + * Render children wrapped in a form element. + */ +const Form: FC<FormProps> = ({ + children, + className = '', + grouped = true, + onSubmit, + ...props +}) => { + const arrayChildren = Children.toArray(children); + + /** + * Get the form items. + * @returns {JSX.Element[]} An array of child elements wrapped in a div. + */ + const getFormItems = (): JSX.Element[] => { + return arrayChildren.map((child, index) => + grouped ? ( + <div key={`item-${index}`} className={styles.item}> + {child} + </div> + ) : ( + <Fragment key={`item-${index}`}>{child}</Fragment> + ) + ); + }; + + /** + * Handle form submit. + * @param {FormEvent} e - The form event. + */ + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSubmit(); + }; + + return ( + <form onSubmit={handleSubmit} className={className} {...props}> + {getFormItems()} + </form> + ); +}; + +export default Form; diff --git a/src/components/atoms/forms/forms.module.scss b/src/components/atoms/forms/forms.module.scss new file mode 100644 index 0000000..19c7aee --- /dev/null +++ b/src/components/atoms/forms/forms.module.scss @@ -0,0 +1,53 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.item { + margin: var(--spacing-xs) 0; + width: 100%; + max-width: 45ch; +} + +.field { + padding: var(--spacing-2xs) var(--spacing-xs); + background: var(--color-bg-tertiary); + border: fun.convert-px(2) solid var(--color-border); + box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow); + transition: all 0.25s linear 0s; + + &--select { + cursor: pointer; + + @include mix.pointer("fine") { + padding: fun.convert-px(3) var(--spacing-xs); + } + } + + &--textarea { + min-height: fun.convert-px(200); + } + + &:disabled { + background: var(--color-bg-secondary); + border: fun.convert-px(2) solid var(--color-border-light); + box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 + var(--color-shadow-light); + cursor: not-allowed; + } + + &:not(:disabled) { + &:hover { + box-shadow: fun.convert-px(5) fun.convert-px(5) 0 fun.convert-px(1) + var(--color-shadow); + transform: translate(#{fun.convert-px(-3)}, #{fun.convert-px(-3)}); + } + + &:focus { + background: var(--color-bg); + border-color: var(--color-primary); + box-shadow: 0 0 0 0 var(--color-shadow); + transform: translate(#{fun.convert-px(3)}, #{fun.convert-px(3)}); + outline: none; + transition: all 0.2s ease-in-out 0s, transform 0.3s ease-out 0s; + } + } +} diff --git a/src/components/atoms/forms/label.module.scss b/src/components/atoms/forms/label.module.scss new file mode 100644 index 0000000..f900925 --- /dev/null +++ b/src/components/atoms/forms/label.module.scss @@ -0,0 +1,17 @@ +.label { + color: var(--color-primary-darker); + font-weight: 600; + + &--small { + font-size: var(--font-size-sm); + font-variant: small-caps; + } + + &--medium { + font-size: var(--font-size-md); + } +} + +.required { + color: var(--color-secondary); +} diff --git a/src/components/atoms/forms/label.stories.tsx b/src/components/atoms/forms/label.stories.tsx new file mode 100644 index 0000000..463e8ac --- /dev/null +++ b/src/components/atoms/forms/label.stories.tsx @@ -0,0 +1,85 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import LabelComponent from './label'; + +export default { + title: 'Atoms/Forms', + component: LabelComponent, + args: { + required: false, + size: 'small', + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Add classnames to the label.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + children: { + control: { + type: 'text', + }, + description: 'The label body.', + type: { + name: 'string', + required: true, + }, + }, + htmlFor: { + control: { + type: 'text', + }, + description: 'The field id.', + type: { + name: 'string', + required: true, + }, + }, + required: { + control: { + type: 'boolean', + }, + description: 'Set to true if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + size: { + control: { + type: 'select', + }, + description: 'The label size.', + options: ['medium', 'small'], + table: { + category: 'Options', + defaultValue: { summary: 'small' }, + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof LabelComponent>; + +const Template: ComponentStory<typeof LabelComponent> = ({ + children, + ...args +}) => <LabelComponent {...args}>{children}</LabelComponent>; + +export const Label = Template.bind({}); +Label.args = { + children: 'A label', +}; diff --git a/src/components/atoms/forms/label.test.tsx b/src/components/atoms/forms/label.test.tsx new file mode 100644 index 0000000..14257c3 --- /dev/null +++ b/src/components/atoms/forms/label.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import Label from './label'; + +describe('Label', () => { + it('renders a field label', () => { + render(<Label>A label</Label>); + expect(screen.getByText('A label')).toBeDefined(); + }); +}); diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx new file mode 100644 index 0000000..8d57ee2 --- /dev/null +++ b/src/components/atoms/forms/label.tsx @@ -0,0 +1,45 @@ +import { FC } from 'react'; +import styles from './label.module.scss'; + +export type LabelProps = { + /** + * Add classnames to the label. + */ + className?: string; + /** + * The field id. + */ + htmlFor?: string; + /** + * Is the field required? Default: false. + */ + required?: boolean; + /** + * The label size. Default: small. + */ + size?: 'medium' | 'small'; +}; + +/** + * Label Component + * + * Render a HTML label element. + */ +const Label: FC<LabelProps> = ({ + children, + className = '', + required = false, + size = 'small', + ...props +}) => { + const sizeClass = styles[`label--${size}`]; + + return ( + <label className={`${styles.label} ${sizeClass} ${className}`} {...props}> + {children} + {required && <span className={styles.required}> *</span>} + </label> + ); +}; + +export default Label; diff --git a/src/components/atoms/forms/select.stories.tsx b/src/components/atoms/forms/select.stories.tsx new file mode 100644 index 0000000..c2fb8c6 --- /dev/null +++ b/src/components/atoms/forms/select.stories.tsx @@ -0,0 +1,145 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import SelectComponent from './select'; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; + +export default { + title: 'Atoms/Forms', + component: SelectComponent, + args: { + disabled: false, + required: false, + }, + argTypes: { + 'aria-labelledby': { + control: { + type: 'text', + }, + description: 'One or more ids that refers to the select field name.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Add classnames to the select field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + 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.', + type: { + name: 'string', + required: true, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'Field name.', + type: { + name: 'string', + required: true, + }, + }, + options: { + description: 'Select options.', + type: { + name: 'array', + required: true, + value: { + name: 'string', + }, + }, + }, + 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, + }, + }, + value: { + control: { + type: null, + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof SelectComponent>; + +const Template: ComponentStory<typeof SelectComponent> = ({ + value, + setValue: _setValue, + ...args +}) => { + const [selected, setSelected] = useState<string>(value); + + return <SelectComponent value={selected} setValue={setSelected} {...args} />; +}; + +export const Select = Template.bind({}); +Select.args = { + id: 'storybook-select', + name: 'storybook-select', + options: selectOptions, + value: 'option2', +}; diff --git a/src/components/atoms/forms/select.test.tsx b/src/components/atoms/forms/select.test.tsx new file mode 100644 index 0000000..22efb86 --- /dev/null +++ b/src/components/atoms/forms/select.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@test-utils'; +import Select from './select'; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; +const selected = selectOptions[0]; + +describe('Select', () => { + it('should correctly set default option', () => { + render( + <Select + id="jest-select" + name="jest-select" + options={selectOptions} + value={selected.value} + setValue={() => null} + /> + ); + expect(screen.getByRole('combobox')).toHaveValue(selected.value); + expect(screen.queryByRole('combobox')).not.toHaveValue( + selectOptions[1].value + ); + expect(screen.queryByRole('combobox')).not.toHaveValue( + selectOptions[2].value + ); + }); +}); diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx new file mode 100644 index 0000000..25e86e0 --- /dev/null +++ b/src/components/atoms/forms/select.tsx @@ -0,0 +1,99 @@ +import { ChangeEvent, SetStateAction, VFC } from 'react'; +import styles from './forms.module.scss'; + +export type SelectOptions = { + /** + * The option id. + */ + id: string; + /** + * The option name. + */ + name: string; + /** + * The option value. + */ + value: string; +}; + +export type SelectProps = { + /** + * One or more ids that refers to the select field name. + */ + 'aria-labelledby'?: string; + /** + * Add classnames to the select field. + */ + className?: string; + /** + * Field state. Either enabled (false) or disabled (true). + */ + disabled?: boolean; + /** + * Field id attribute. + */ + id: string; + /** + * Field name attribute. + */ + name: string; + /** + * True if the field is required. Default: false. + */ + options: SelectOptions[]; + /** + * True if the field is required. Default: false. + */ + required?: boolean; + /** + * Callback function to set field value. + */ + setValue: (value: SetStateAction<string>) => void; + /** + * Field value. + */ + value: string; +}; + +/** + * Select component + * + * Render a HTML select element. + */ +const Select: VFC<SelectProps> = ({ + className = '', + options, + setValue, + ...props +}) => { + /** + * Update select value when an option is selected. + * @param e - The option change event. + */ + const updateValue = (e: ChangeEvent<HTMLSelectElement>) => { + setValue(e.target.value); + }; + + /** + * Get the option elements. + * @returns {JSX.Element[]} An array of HTML option elements. + */ + const getOptions = (): JSX.Element[] => + options.map((option) => ( + <option key={option.id} value={option.value}> + {option.name} + </option> + )); + + return ( + <select + className={`${styles.field} ${styles['field--select']} ${className}`} + onChange={updateValue} + {...props} + > + {getOptions()} + </select> + ); +}; + +export default Select; diff --git a/src/components/atoms/headings/heading.module.scss b/src/components/atoms/headings/heading.module.scss new file mode 100644 index 0000000..8620f6f --- /dev/null +++ b/src/components/atoms/headings/heading.module.scss @@ -0,0 +1,57 @@ +@use "@styles/abstracts/functions" as fun; + +.heading { + color: var(--color-primary-dark); + font-family: var(--font-family-secondary); + letter-spacing: 0.01ex; + + &--regular { + margin: 0; + } + + &--margin { + margin: 0 0 var(--spacing-sm); + + & + & { + margin-top: var(--spacing-md); + } + } + + &--1 { + font-size: var(--font-size-3xl); + font-weight: 500; + } + + &--2 { + padding-bottom: fun.convert-px(3); + background: linear-gradient( + to top, + var(--color-primary-dark) 0.3rem, + transparent 0.3rem + ) + 0 0 / 3rem 100% no-repeat; + font-size: var(--font-size-2xl); + font-weight: 500; + text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light); + } + + &--3 { + font-size: var(--font-size-xl); + font-weight: 500; + } + + &--4 { + font-size: var(--font-size-lg); + font-weight: 500; + } + + &--5 { + font-size: var(--font-size-md); + font-weight: 600; + } + + &--6 { + font-size: var(--font-size-md); + font-weight: 500; + } +} diff --git a/src/components/atoms/headings/heading.stories.tsx b/src/components/atoms/headings/heading.stories.tsx new file mode 100644 index 0000000..66a84dc --- /dev/null +++ b/src/components/atoms/headings/heading.stories.tsx @@ -0,0 +1,82 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import HeadingComponent from './heading'; + +export default { + title: 'Atoms/Headings', + component: HeadingComponent, + args: { + isFake: false, + withMargin: true, + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + children: { + description: 'Heading body.', + type: { + name: 'string', + required: true, + }, + }, + isFake: { + control: { + type: 'boolean', + }, + description: 'Use an heading element or only its styles.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + level: { + control: { + type: 'select', + }, + description: 'Heading level.', + options: [1, 2, 3, 4, 5, 6], + type: { + name: 'number', + required: true, + }, + }, + withMargin: { + control: { + type: 'boolean', + }, + description: 'Adds margin.', + table: { + category: 'Options', + defaultValue: { summary: true }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + }, +} as ComponentMeta<typeof HeadingComponent>; + +const Template: ComponentStory<typeof HeadingComponent> = (args) => ( + <HeadingComponent {...args} /> +); + +export const Heading = Template.bind({}); +Heading.args = { + children: 'Your title', + level: 1, +}; diff --git a/src/components/atoms/headings/heading.test.tsx b/src/components/atoms/headings/heading.test.tsx new file mode 100644 index 0000000..6b6789a --- /dev/null +++ b/src/components/atoms/headings/heading.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@test-utils'; +import Heading from './heading'; + +describe('Heading', () => { + it('renders a h1', () => { + render(<Heading level={1}>Level 1</Heading>); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'Level 1' + ); + }); + + it('renders a h2', () => { + render(<Heading level={2}>Level 2</Heading>); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + 'Level 2' + ); + }); + + it('renders a h3', () => { + render(<Heading level={3}>Level 3</Heading>); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent( + 'Level 3' + ); + }); + + it('renders a h4', () => { + render(<Heading level={4}>Level 4</Heading>); + expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent( + 'Level 4' + ); + }); + + it('renders a h5', () => { + render(<Heading level={5}>Level 5</Heading>); + expect(screen.getByRole('heading', { level: 5 })).toHaveTextContent( + 'Level 5' + ); + }); + + it('renders a h6', () => { + render(<Heading level={6}>Level 6</Heading>); + expect(screen.getByRole('heading', { level: 6 })).toHaveTextContent( + 'Level 6' + ); + }); + + it('renders a text with heading styles', () => { + render( + <Heading isFake={true} level={2}> + Fake heading + </Heading> + ); + expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument(); + expect(screen.getByText('Fake heading')).toHaveClass('heading'); + }); +}); diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx new file mode 100644 index 0000000..4703b5d --- /dev/null +++ b/src/components/atoms/headings/heading.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react'; +import styles from './heading.module.scss'; + +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +export type HeadingProps = { + /** + * Set additional classnames. + */ + className?: string; + /** + * The heading id. + */ + id?: string; + /** + * Use an heading element or only its styles. Default: false. + */ + isFake?: boolean; + /** + * HTML heading level. + */ + level: HeadingLevel; + /** + * Adds margin. Default: true. + */ + withMargin?: boolean; +}; + +/** + * Heading component. + * + * Render an HTML heading element or a paragraph with heading styles. + */ +const Heading: FC<HeadingProps> = ({ + children, + className, + id, + isFake = false, + level, + withMargin = true, +}) => { + const TitleTag = isFake ? `p` : (`h${level}` as keyof JSX.IntrinsicElements); + const levelClass = `heading--${level}`; + const marginClass = withMargin ? 'heading--margin' : 'heading--regular'; + + return ( + <TitleTag + className={`${styles.heading} ${styles[levelClass]} ${styles[marginClass]} ${className}`} + id={id} + > + {children} + </TitleTag> + ); +}; + +export default Heading; diff --git a/src/components/atoms/icons/arrow.module.scss b/src/components/atoms/icons/arrow.module.scss new file mode 100644 index 0000000..76e6aea --- /dev/null +++ b/src/components/atoms/icons/arrow.module.scss @@ -0,0 +1,16 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + fill: var(--color-primary); + transition: all 0.25s ease-in-out 0s; + + &--left, + &--right { + width: var(--icon-size, #{fun.convert-px(30)}); + } + + &--bottom, + &--top { + height: var(--icon-size, #{fun.convert-px(30)}); + } +} diff --git a/src/components/atoms/icons/arrow.stories.tsx b/src/components/atoms/icons/arrow.stories.tsx new file mode 100644 index 0000000..96ce1d8 --- /dev/null +++ b/src/components/atoms/icons/arrow.stories.tsx @@ -0,0 +1,42 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ArrowIcon from './arrow'; + +export default { + title: 'Atoms/Icons', + component: ArrowIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + direction: { + control: { + type: 'select', + }, + description: 'An arrow icon.', + options: ['bottom', 'left', 'right', 'top'], + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof ArrowIcon>; + +const Template: ComponentStory<typeof ArrowIcon> = (args) => ( + <ArrowIcon {...args} /> +); + +export const Arrow = Template.bind({}); +Arrow.args = { + direction: 'right', +}; diff --git a/src/components/atoms/icons/arrow.test.tsx b/src/components/atoms/icons/arrow.test.tsx new file mode 100644 index 0000000..502dcc1 --- /dev/null +++ b/src/components/atoms/icons/arrow.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Arrow from './arrow'; + +describe('Arrow', () => { + it('renders an arrow icon oriented to the right', () => { + const { container } = render(<Arrow direction="right" />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/arrow.tsx b/src/components/atoms/icons/arrow.tsx new file mode 100644 index 0000000..5f3c460 --- /dev/null +++ b/src/components/atoms/icons/arrow.tsx @@ -0,0 +1,101 @@ +import { VFC } from 'react'; +import styles from './arrow.module.scss'; + +export type ArrowDirection = 'top' | 'right' | 'bottom' | 'left'; + +export type ArrowProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; + /** + * The arrow direction. Default: right. + */ + direction: ArrowDirection; +}; + +/** + * Arrow component + * + * Render a svg arrow icon. + */ +const Arrow: VFC<ArrowProps> = ({ className = '', direction }) => { + const directionClass = styles[`icon--${direction}`]; + const classes = `${styles.icon} ${directionClass} ${className}`; + + if (direction === 'top') { + return ( + <svg + className={classes} + viewBox="0 0 23.476 64.644995" + xmlns="http://www.w3.org/2000/svg" + > + <path + className="arrow-head" + d="M 23.476001,24.637 11.715001,0 0,24.800001 Z" + /> + <path + className="arrow-bar" + d="m 15.441001,64.644997 -0.018,-40.007999 H 8.035 l 0.142,40.007999 z" + /> + </svg> + ); + } + + if (direction === 'bottom') { + return ( + <svg + className={classes} + viewBox="0 0 23.476 64.644995" + xmlns="http://www.w3.org/2000/svg" + > + <path + className="arrow-head" + d="m 23.476001,40.007997 -11.761,24.637 L 0,39.844996 Z" + /> + <path + className="arrow-bar" + d="m 15.441001,0 -0.018,40.007999 H 8.035 L 8.177,0 Z" + /> + </svg> + ); + } + + if (direction === 'left') { + return ( + <svg + className={classes} + viewBox="0 0 64.644997 23.476001" + xmlns="http://www.w3.org/2000/svg" + > + <path + className="arrow-head" + d="M 24.637,23.476 0,11.715 24.8,-8.3923343e-8 Z" + /> + <path + className="arrow-bar" + d="m 64.644997,15.441 -40.008,-0.018 V 8.0349999 l 40.008,0.142 z" + /> + </svg> + ); + } + + return ( + <svg + className={classes} + viewBox="0 0 64.644997 23.476001" + xmlns="http://www.w3.org/2000/svg" + > + <path + className="arrow-head" + d="M 40.007997,23.476 64.644997,11.715 39.844997,-8.3923343e-8 Z" + /> + <path + className="arrow-bar" + d="M 0,15.441 40.008,15.423 V 8.0349999 L 0,8.1769999 Z" + /> + </svg> + ); +}; + +export default Arrow; diff --git a/src/components/atoms/icons/career.module.scss b/src/components/atoms/icons/career.module.scss new file mode 100644 index 0000000..c5d65eb --- /dev/null +++ b/src/components/atoms/icons/career.module.scss @@ -0,0 +1,53 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + display: block; + width: var(--icon-size, #{fun.convert-px(40)}); +} + +.lock { + fill: var(--color-bg); + stroke: var(--color-primary-darker); + stroke-width: 3; +} + +.lines { + fill: var(--color-fg); + stroke-width: 4; +} + +.seal-top { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 2; +} + +.seal-bottom { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 2; +} + +.diploma { + fill: var(--color-bg); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.top { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.handle { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 3; +} + +.bottom { + fill: var(--color-primary); + stroke: var(--color-primary-darker); + stroke-width: 4; +} diff --git a/src/components/atoms/icons/career.stories.tsx b/src/components/atoms/icons/career.stories.tsx new file mode 100644 index 0000000..8575cb9 --- /dev/null +++ b/src/components/atoms/icons/career.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import CareerIcon from './career'; + +export default { + title: 'Atoms/Icons', + component: CareerIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof CareerIcon>; + +const Template: ComponentStory<typeof CareerIcon> = (args) => ( + <CareerIcon {...args} /> +); + +export const Career = Template.bind({}); diff --git a/src/components/atoms/icons/career.test.tsx b/src/components/atoms/icons/career.test.tsx new file mode 100644 index 0000000..62ffc14 --- /dev/null +++ b/src/components/atoms/icons/career.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Career from './career'; + +describe('Career', () => { + it('renders a Career icon', () => { + const { container } = render(<Career />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/career.tsx b/src/components/atoms/icons/career.tsx new file mode 100644 index 0000000..28edcc7 --- /dev/null +++ b/src/components/atoms/icons/career.tsx @@ -0,0 +1,71 @@ +import { VFC } from 'react'; +import styles from './career.module.scss'; + +export type CareerProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * Career Component + * + * Render a career svg icon. + */ +const Career: VFC<CareerProps> = ({ className = '' }) => { + return ( + <svg + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + className={`${styles.icon} ${className}`} + > + <path + className={styles.bottom} + d="M 0.72670447,19.813041 H 77.467597 v 54.36591 H 0.72670447 Z" + /> + <path + className={styles.handle} + d="m 22.263958,10.17849 c 12.6493,-1.81512 21.613185,-1.732794 33.666442,0 l 1.683339,10.99517 h -5.891624 v -5.474639 c -7.949741,-2.722434 -16.311959,-2.706359 -25.249837,0 v 5.474639 h -5.891625 z" + /> + <path + className={styles.top} + d="M 0.72670447,19.813041 H 77.467597 V 51.17622 H 0.72670447 Z" + /> + <path + className={styles.diploma} + d="M 44.217117,47.159906 H 98.921356 V 82.664122 H 44.217117 Z" + /> + <path + className={styles['seal-bottom']} + d="m 84.933665,80.775336 h 6.957554 V 90.992144 L 88.412426,87.2244 84.933665,90.992144 Z" + /> + <path + className={styles['seal-top']} + d="m 93.326919,76.83334 a 4.914472,4.9188584 0 0 1 -4.914493,4.918858 4.914472,4.9188584 0 0 1 -4.914461,-4.918858 4.914472,4.9188584 0 0 1 4.914461,-4.918858 4.914472,4.9188584 0 0 1 4.914493,4.918858 z" + /> + <path + className={styles.lines} + d="m 54.53557,60.491974 h 34.067282 v 1.515453 H 54.53557 Z" + /> + <path + className={styles.lines} + d="m 54.53557,67.437763 h 34.067282 v 1.515453 H 54.53557 Z" + /> + <path + className={styles.lines} + d="m 54.53557,74.383628 h 17.563315 v 1.515454 H 54.53557 Z" + /> + <path + className={styles.lines} + d="m 63.495911,53.546123 h 16.146628 v 1.515452 H 63.495911 Z" + /> + <path + className={styles.lock} + d="M 34.048314,42.893007 H 44.145988 V 57.849688 H 34.048314 Z" + /> + </svg> + ); +}; + +export default Career; diff --git a/src/components/atoms/icons/cc-by-sa.module.scss b/src/components/atoms/icons/cc-by-sa.module.scss new file mode 100644 index 0000000..e1b2100 --- /dev/null +++ b/src/components/atoms/icons/cc-by-sa.module.scss @@ -0,0 +1,7 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + display: block; + width: var(--icon-size, #{fun.convert-px(60)}); + fill: var(--color-fg); +} diff --git a/src/components/atoms/icons/cc-by-sa.stories.tsx b/src/components/atoms/icons/cc-by-sa.stories.tsx new file mode 100644 index 0000000..21d6cd5 --- /dev/null +++ b/src/components/atoms/icons/cc-by-sa.stories.tsx @@ -0,0 +1,31 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import CCBySAIcon from './cc-by-sa'; + +export default { + title: 'Atoms/Icons', + component: CCBySAIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof CCBySAIcon>; + +const Template: ComponentStory<typeof CCBySAIcon> = (args) => ( + <IntlProvider locale="en"> + <CCBySAIcon {...args} /> + </IntlProvider> +); + +export const CCBySA = Template.bind({}); diff --git a/src/components/atoms/icons/cc-by-sa.test.tsx b/src/components/atoms/icons/cc-by-sa.test.tsx new file mode 100644 index 0000000..adb03e4 --- /dev/null +++ b/src/components/atoms/icons/cc-by-sa.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import CCBySA from './cc-by-sa'; + +describe('CCBySA', () => { + it('renders a CC BY SA icon', () => { + render(<CCBySA />); + expect(screen.getByTitle('CC BY SA')).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/icons/cc-by-sa.tsx b/src/components/atoms/icons/cc-by-sa.tsx new file mode 100644 index 0000000..552504e --- /dev/null +++ b/src/components/atoms/icons/cc-by-sa.tsx @@ -0,0 +1,45 @@ +import { VFC } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './cc-by-sa.module.scss'; + +export type CCBySAProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * CCBySA component + * + * Render a CC BY SA svg icon. + */ +const CCBySA: VFC<CCBySAProps> = ({ className = '' }) => { + const intl = useIntl(); + + return ( + <svg + className={`${styles.icon} ${className}`} + viewBox="0 0 211.99811 63.999996" + xmlns="http://www.w3.org/2000/svg" + > + <title> + {intl.formatMessage({ + defaultMessage: 'CC BY SA', + description: 'CCBySA: icon title', + id: 'cl7YNU', + })} + </title> + <path d="m 175.53911,15.829498 c 0,-3.008 1.485,-4.514 4.458,-4.514 2.973,0 4.457,1.504 4.457,4.514 0,2.971 -1.486,4.457 -4.457,4.457 -2.971,0 -4.458,-1.486 -4.458,-4.457 z" /> + <path d="m 188.62611,24.057498 v 13.085 h -3.656 v 15.542 h -9.944 v -15.541 h -3.656 v -13.086 c 0,-0.572 0.2,-1.057 0.599,-1.457 0.401,-0.399 0.887,-0.6 1.457,-0.6 h 13.144 c 0.533,0 1.01,0.2 1.428,0.6 0.417,0.4 0.628,0.886 0.628,1.457 z" /> + <path d="m 179.94147,-1.9073486e-6 c -8.839,0 -16.34167,3.0848125073486 -22.51367,9.2578125073486 -6.285,6.4000004 -9.42969,13.9811874 -9.42969,22.7421874 0,8.762 3.14469,16.284312 9.42969,22.570312 6.361,6.286 13.86467,9.429688 22.51367,9.429688 8.799,0 16.43611,-3.181922 22.91211,-9.544922 6.096,-5.98 9.14453,-13.464078 9.14453,-22.455078 0,-8.952 -3.10646,-16.532188 -9.31446,-22.7421874 -6.172,-6.172 -13.75418,-9.2578125073486 -22.74218,-9.2578125073486 z M 180.05475,5.7714825 c 7.238,0 13.40967,2.55225 18.51367,7.6562495 5.103,5.106 7.65625,11.294313 7.65625,18.570313 0,7.391 -2.51397,13.50575 -7.54297,18.34375 -5.295,5.221 -11.50591,7.828125 -18.6289,7.828125 -7.162,0 -13.33268,-2.589484 -18.51368,-7.771484 -5.18,-5.178001 -7.76953,-11.310485 -7.76953,-18.396485 0,-7.047 2.60813,-13.238266 7.82813,-18.572265 5.029,-5.1040004 11.18103,-7.6582035 18.45703,-7.6582035 z" /> + <path d="m 91.998554,27.114498 c 0.609,-3.924 2.189,-6.962 4.742,-9.114 2.552,-2.152 5.655996,-3.228 9.313996,-3.228 5.027,0 9.029,1.62 12,4.856 2.971,3.238 4.457,7.391 4.457,12.457 0,4.915 -1.543,9 -4.627,12.256 -3.088,3.256 -7.086,4.886 -12.002,4.886 -3.619,0 -6.742996,-1.085 -9.370996,-3.257 -2.629,-2.172 -4.209,-5.257 -4.743,-9.257 h 8.059 c 0.189996,3.886 2.532996,5.829 7.028996,5.829 2.246,0 4.057,-0.972 5.428,-2.914 1.373,-1.942 2.059,-4.534 2.059,-7.771 0,-3.391 -0.629,-5.971 -1.885,-7.743 -1.258,-1.771 -3.066,-2.657 -5.43,-2.657 -4.268,0 -6.667,1.885 -7.199996,5.656 h 2.342996 l -6.341996,6.343 -6.343,-6.343 z" /> + <path d="m 105.94241,-1.8610229e-6 c -8.799996,0 -16.304676,3.1054062610229 -22.513666,9.3164061610229 -6.285,6.3999997 -9.42969,13.9625467 -9.42969,22.6855467 0,8.763 3.14469,16.28336 9.42969,22.568359 6.361,6.286001 13.86467,9.429688 22.513666,9.429688 8.836,0 16.47511,-3.162328 22.91211,-9.486328 6.096,-6.057 9.14453,-13.559672 9.14453,-22.513672 0,-8.952 -3.10646,-16.513547 -9.31446,-22.6855468 -6.211,-6.21 -13.79118,-9.3144530610229 -22.74218,-9.3144530610229 z M 106.05569,5.7714825 c 7.275,0 13.44667,2.5698437 18.51367,7.7148435 5.103,5.028 7.65625,11.200672 7.65625,18.513672 0,7.353 -2.51397,13.46775 -7.54297,18.34375 -5.295,5.219 -11.50591,7.828125 -18.6289,7.828125 -7.161996,0 -13.332676,-2.589484 -18.513676,-7.771484 -5.18,-5.143 -7.76953,-11.275391 -7.76953,-18.400391 0,-7.046 2.60813,-13.217672 7.82813,-18.513672 5.029,-5.1429998 11.18103,-7.7148435 18.457026,-7.7148435 z" /> + <path d="M 31.942383,5.9265138e-7 C 23.066111,5.9265138e-7 15.579851,3.1065496 9.484666,9.3147376 6.399571,12.400832 4.046856,15.896269 2.427808,19.801386 0.80876,23.706506 0,27.771846 0,32.000976 c 0,4.26713 0.800415,8.32413 2.400463,12.17225 1.600051,3.84811 3.933123,7.30532 7.000216,10.37141 3.067093,3.06609 6.534587,5.40951 10.400708,7.02756 3.867116,1.62105 7.914819,2.4278 12.142946,2.4278 4.22813,0 8.32441,-0.8171 12.28553,-2.45515 3.96313,-1.63805 7.50614,-4.00301 10.62923,-7.0881 3.0081,-2.93309 5.28529,-6.31477 6.82834,-10.14289 1.54104,-3.82712 2.31257,-7.93174 2.31257,-12.31288 0,-4.34313 -0.78277,-8.44771 -2.34382,-12.31483 C 60.094133,15.82003 57.808593,12.380471 54.800503,9.3713796 48.515313,3.1241896 40.893653,5.9265136e-7 31.942383,5.9265138e-7 Z M 32.057623,5.7716626 c 7.23822,0 13.42863,2.571923 18.57478,7.7150794 2.47408,2.478074 4.35948,5.297144 5.65252,8.459244 1.29504,3.16209 1.94342,6.51384 1.94342,10.05694 0,7.35423 -2.49445,13.46816 -7.4846,18.34432 -2.59208,2.51407 -5.49406,4.43661 -8.71316,5.77166 -3.2231,1.33404 -6.54486,1.9981 -9.97296,1.9981 -3.467107,0 -6.782568,-0.65672 -9.943661,-1.97076 -3.164098,-1.31604 -5.999858,-3.21894 -8.513933,-5.71502 -2.515077,-2.49507 -4.447918,-5.33279 -5.800959,-8.51588 -1.354042,-3.1791 -2.029358,-6.48331 -2.029358,-9.91242 0,-3.4671 0.675316,-6.79186 2.029358,-9.97295 1.352043,-3.1811 3.285882,-6.046798 5.800959,-8.599875 4.991151,-5.1041594 11.14337,-7.6584384 18.457594,-7.6584384 z" /> + <path d="m 50.114533,26.687816 -4.22913,2.22907 c -0.45702,-0.95103 -1.02003,-1.61905 -1.68605,-2.00006 -0.66802,-0.38001 -1.30704,-0.57102 -1.91406,-0.57102 -2.85709,0 -4.28713,1.88506 -4.28713,5.65717 0,1.71406 0.363,3.0841 1.08603,4.11313 0.72302,1.02903 1.78906,1.54405 3.2011,1.54405 1.86506,0 3.1801,-0.91503 3.94112,-2.74309 l 4.00012,2.00007 c -0.87502,1.56304 -2.05706,2.79108 -3.54111,3.68611 -1.48604,0.89602 -3.10509,1.34304 -4.85715,1.34304 -2.89608,0 -5.20915,-0.87503 -6.94121,-2.62908 -1.73605,-1.75205 -2.60207,-4.19013 -2.60207,-7.31323 0,-3.04809 0.88502,-5.46616 2.65808,-7.25722 1.77005,-1.79005 4.00812,-2.68608 6.7132,-2.68608 3.96212,-0.002 6.78321,1.54105 8.45826,4.62714 z" /> + <path d="m 31.656963,26.687816 -4.287128,2.22907 c -0.458013,-0.95103 -1.019029,-1.61905 -1.685048,-2.00006 -0.667024,-0.38001 -1.286042,-0.57102 -1.858057,-0.57102 -2.856087,0 -4.28613,1.88506 -4.28613,5.65717 0,1.71406 0.362014,3.0841 1.085029,4.11313 0.724025,1.02903 1.791056,1.54405 3.201101,1.54405 1.867057,0 3.181095,-0.91503 3.944118,-2.74309 l 3.942125,2.00007 c -0.83803,1.56304 -2.000065,2.79108 -3.486111,3.68611 -1.484043,0.89602 -3.123093,1.34304 -4.914149,1.34304 -2.857088,0 -5.163158,-0.87503 -6.915212,-2.62908 -1.752053,-1.75205 -2.62808,-4.19013 -2.62808,-7.31323 0,-3.04809 0.886028,-5.46616 2.657081,-7.25722 1.771054,-1.79005 4.009125,-2.68608 6.715205,-2.68608 3.963122,-0.002 6.800209,1.54105 8.515256,4.62714 z" /> + </svg> + ); +}; + +export default CCBySA; diff --git a/src/components/atoms/icons/close.module.scss b/src/components/atoms/icons/close.module.scss new file mode 100644 index 0000000..4a5d18d --- /dev/null +++ b/src/components/atoms/icons/close.module.scss @@ -0,0 +1,12 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + display: block; + width: var(--icon-size, #{fun.convert-px(40)}); +} + +.line { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 3; +} diff --git a/src/components/atoms/icons/close.stories.tsx b/src/components/atoms/icons/close.stories.tsx new file mode 100644 index 0000000..b1d88cd --- /dev/null +++ b/src/components/atoms/icons/close.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import CloseIcon from './close'; + +export default { + title: 'Atoms/Icons', + component: CloseIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof CloseIcon>; + +const Template: ComponentStory<typeof CloseIcon> = (args) => ( + <CloseIcon {...args} /> +); + +export const Close = Template.bind({}); diff --git a/src/components/atoms/icons/close.test.tsx b/src/components/atoms/icons/close.test.tsx new file mode 100644 index 0000000..0357bec --- /dev/null +++ b/src/components/atoms/icons/close.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Close from './close'; + +describe('Close', () => { + it('renders a Close icon', () => { + const { container } = render(<Close />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/close.tsx b/src/components/atoms/icons/close.tsx new file mode 100644 index 0000000..eb9ce7c --- /dev/null +++ b/src/components/atoms/icons/close.tsx @@ -0,0 +1,35 @@ +import { VFC } from 'react'; +import styles from './close.module.scss'; + +export type CloseProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * Close component + * + * Render a close svg icon. + */ +const Close: VFC<CloseProps> = ({ className = '' }) => { + return ( + <svg + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + className={`${styles.icon} ${className}`} + > + <path + className={styles.line} + d="m 3.6465461,3.6465455 c 2.8785908,-2.87859092 7.5134339,-2.87859092 10.3920249,0 L 96.353457,85.96143 c 2.878587,2.878591 2.878587,7.513434 0,10.392025 -2.878597,2.878591 -7.513432,2.878591 -10.392029,0 L 3.6465451,14.038571 C 0.76795421,11.15998 0.76795421,6.5251364 3.6465461,3.6465455 Z" + /> + <path + className={styles.line} + d="m 96.353453,3.646546 c 2.878592,2.8785909 2.878592,7.513435 0,10.392026 L 14.03857,96.353457 c -2.878589,2.878587 -7.5134337,2.878587 -10.3920246,0 -2.87859084,-2.878597 -2.87858985,-7.513442 -1e-6,-10.392029 L 85.961428,3.646546 c 2.878591,-2.87859097 7.513434,-2.87859097 10.392025,0 z" + /> + </svg> + ); +}; + +export default Close; diff --git a/src/components/atoms/icons/cog.module.scss b/src/components/atoms/icons/cog.module.scss new file mode 100644 index 0000000..5201598 --- /dev/null +++ b/src/components/atoms/icons/cog.module.scss @@ -0,0 +1,8 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + width: var(--icon-size, #{fun.convert-px(40)}); + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 4; +} diff --git a/src/components/atoms/icons/cog.stories.tsx b/src/components/atoms/icons/cog.stories.tsx new file mode 100644 index 0000000..ee883d8 --- /dev/null +++ b/src/components/atoms/icons/cog.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import CogIcon from './cog'; + +export default { + title: 'Atoms/Icons', + component: CogIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof CogIcon>; + +const Template: ComponentStory<typeof CogIcon> = (args) => ( + <CogIcon {...args} /> +); + +export const Cog = Template.bind({}); diff --git a/src/components/atoms/icons/cog.test.tsx b/src/components/atoms/icons/cog.test.tsx new file mode 100644 index 0000000..89090fa --- /dev/null +++ b/src/components/atoms/icons/cog.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Cog from './cog'; + +describe('Cog', () => { + it('renders a Cog icon', () => { + const { container } = render(<Cog />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/cog.tsx b/src/components/atoms/icons/cog.tsx new file mode 100644 index 0000000..df6d54d --- /dev/null +++ b/src/components/atoms/icons/cog.tsx @@ -0,0 +1,29 @@ +import { VFC } from 'react'; +import styles from './cog.module.scss'; + +export type CogProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * Cog component + * + * Render a cog svg icon. + */ +const Cog: VFC<CogProps> = ({ className = '' }) => { + return ( + <svg + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + className={`${styles.icon} ${className}`} + > + <path d="m 71.782287,3.1230469 c -1.164356,0 -2.3107,0.076326 -3.435131,0.2227895 L 66.33766,9.1021499 C 64.651951,9.5517047 63.049493,10.204637 61.558109,11.033725 L 56.112383,8.2889128 c -1.970928,1.4609237 -3.730521,3.1910632 -5.22513,5.1351362 l 2.648234,5.494014 c -0.855644,1.477262 -1.537042,3.067161 -2.016082,4.743334 l -5.791433,1.911821 c -0.188001,1.269731 -0.286444,2.568579 -0.286444,3.890587 0,1.164355 0.07633,2.310701 0.222789,3.435131 l 5.756315,2.009497 c 0.449555,1.685708 1.102486,3.288168 1.931575,4.779551 l -2.744813,5.445725 c 1.460924,1.970927 3.191063,3.730521 5.135137,5.22513 l 5.494014,-2.648233 c 1.477261,0.85564 3.067161,1.537039 4.743334,2.016081 L 67.8917,55.51812 c 1.26973,0.188002 2.568578,0.286444 3.890587,0.286444 1.16565,0 2.313889,-0.07601 3.43952,-0.222789 l 2.008399,-5.756314 c 1.684332,-0.449523 3.285984,-1.103103 4.776259,-1.931575 l 5.445725,2.744812 c 1.970928,-1.460924 3.730521,-3.191061 5.22513,-5.135136 l -2.648233,-5.494015 c 0.85564,-1.477262 1.537039,-3.067161 2.016082,-4.743334 l 5.79253,-1.91182 c 0.187995,-1.269731 0.285346,-2.56858 0.285346,-3.890588 0,-1.16565 -0.07601,-2.313889 -0.222789,-3.439521 L 92.143942,24.015886 C 91.694419,22.331554 91.04084,20.729903 90.212367,19.239628 l 2.744812,-5.445726 C 91.496255,11.822973 89.766118,10.063381 87.822043,8.5687715 L 82.328028,11.217006 C 80.850766,10.361361 79.260867,9.6799641 77.584694,9.2009234 L 75.672874,3.4094907 C 74.403143,3.2214898 73.104295,3.1230469 71.782287,3.1230469 Z m 0,15.0520191 a 11.288679,11.288679 0 0 1 11.288739,11.288739 11.288679,11.288679 0 0 1 -11.288739,11.28874 11.288679,11.288679 0 0 1 -11.28874,-11.28874 11.288679,11.288679 0 0 1 11.28874,-11.288739 z" /> + <path d="m 38.326115,25.84777 c -1.583642,0 -3.142788,0.103807 -4.672127,0.303016 l -2.73312,7.829173 c -2.292736,0.611441 -4.472242,1.499494 -6.500676,2.627139 L 17.01345,32.873874 c -2.680664,1.987004 -5.073889,4.340169 -7.1067095,6.984309 l 3.6018685,7.472418 c -1.163764,2.009226 -2.090533,4.171652 -2.742078,6.451418 l -7.8769382,2.60027 C 2.6338924,58.109252 2.5,59.875819 2.5,61.673885 c 0,1.583642 0.1038125,3.142788 0.3030165,4.672128 l 7.8291725,2.73312 c 0.611441,2.292734 1.499494,4.472243 2.627139,6.500673 L 9.5261037,82.98655 c 1.9870063,2.680661 4.3401703,5.07389 6.9843093,7.106709 l 7.472419,-3.601867 c 2.009226,1.16376 4.171651,2.090533 6.451418,2.742079 l 2.60027,7.876932 C 34.761483,97.366114 36.528049,97.5 38.326115,97.5 c 1.585404,0 3.147126,-0.103373 4.678099,-0.303015 l 2.731628,-7.829178 c 2.290862,-0.611397 4.469272,-1.500329 6.496197,-2.627132 l 7.406741,3.733224 c 2.680664,-1.987007 5.07389,-4.340171 7.10671,-6.984313 l -3.601866,-7.472415 c 1.163756,-2.00923 2.090529,-4.171655 2.742076,-6.45142 l 7.878431,-2.60027 c 0.255691,-1.726964 0.3881,-3.49353 0.3881,-5.291596 0,-1.585404 -0.103373,-3.147127 -0.303016,-4.678099 L 66.020041,54.264159 C 65.408645,51.973296 64.51971,49.794888 63.392903,47.767962 l 3.733224,-7.406742 c -1.987006,-2.680664 -4.340168,-5.073889 -6.984309,-7.10671 l -7.472419,3.601867 c -2.009228,-1.163762 -4.171651,-2.090533 -6.451418,-2.742076 l -2.60027,-7.876939 C 41.890748,25.981661 40.124181,25.84777 38.326115,25.84777 Z m 0,20.472278 A 15.353754,15.353754 0 0 1 53.679952,61.673885 15.353754,15.353754 0 0 1 38.326115,77.027724 15.353754,15.353754 0 0 1 22.972279,61.673885 15.353754,15.353754 0 0 1 38.326115,46.320048 Z" /> + </svg> + ); +}; + +export default Cog; diff --git a/src/components/atoms/icons/computer-screen.module.scss b/src/components/atoms/icons/computer-screen.module.scss new file mode 100644 index 0000000..6c8f701 --- /dev/null +++ b/src/components/atoms/icons/computer-screen.module.scss @@ -0,0 +1,39 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + display: block; + width: var(--icon-size, #{fun.convert-px(40)}); +} + +.root, +.separator, +.cursor, +.line, +.text { + fill: var(--color-fg); +} + +.stand { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-dark); + + &--top { + stroke-width: 3; + } + + &--bottom { + stroke-width: 2; + } +} + +.screen { + fill: var(--color-bg); + stroke: var(--color-primary-dark); + stroke-width: 3; +} + +.contour { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-dark); + stroke-width: 3; +} diff --git a/src/components/atoms/icons/computer-screen.stories.tsx b/src/components/atoms/icons/computer-screen.stories.tsx new file mode 100644 index 0000000..46e3ad4 --- /dev/null +++ b/src/components/atoms/icons/computer-screen.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ComputerScreenIcon from './computer-screen'; + +export default { + title: 'Atoms/Icons', + component: ComputerScreenIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof ComputerScreenIcon>; + +const Template: ComponentStory<typeof ComputerScreenIcon> = (args) => ( + <ComputerScreenIcon {...args} /> +); + +export const ComputerScreen = Template.bind({}); diff --git a/src/components/atoms/icons/computer-screen.test.tsx b/src/components/atoms/icons/computer-screen.test.tsx new file mode 100644 index 0000000..c0e53e0 --- /dev/null +++ b/src/components/atoms/icons/computer-screen.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import ComputerScreen from './computer-screen'; + +describe('ComputerScreen', () => { + it('renders a computer screen icon', () => { + const { container } = render(<ComputerScreen />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/computer-screen.tsx b/src/components/atoms/icons/computer-screen.tsx new file mode 100644 index 0000000..310836f --- /dev/null +++ b/src/components/atoms/icons/computer-screen.tsx @@ -0,0 +1,79 @@ +import { VFC } from 'react'; +import styles from './computer-screen.module.scss'; + +export type ComputerScreenProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * ComputerScreen component + * + * Render a computer screen svg icon. + */ +const ComputerScreen: VFC<ComputerScreenProps> = ({ className = '' }) => { + return ( + <svg + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + className={`${styles.icon} ${className}`} + > + <path + d="M 1.0206528,11.991149 H 98.979347 V 78.466748 H 1.0206528 Z" + className={styles.contour} + /> + <path + d="M 6.2503581,18.032451 H 93.563283 V 71.12731 H 6.2503581 Z" + className={styles.screen} + /> + <path + d="m 40.038268,78.939276 c 4.614714,2.7794 4.333151,10.099225 0,17.60572 H 50 59.961731 c -4.333151,-7.506495 -4.614715,-14.82632 0,-17.60572 H 50 Z" + className={`${styles.stand} ${styles['stand--top']}`} + /> + <path + d="m 31.084262,96.254656 h 37.831475 c 1.394769,0 2.517635,0.404907 2.517635,0.907864 v 1.179616 c 0,0.502956 -1.122866,0.907864 -2.517635,0.907864 H 31.084262 c -1.394769,0 -2.517635,-0.404908 -2.517635,-0.907864 V 97.16252 c 0,-0.502957 1.122866,-0.907864 2.517635,-0.907864 z" + className={`${styles.stand} ${styles['stand--bottom']}`} + /> + <path + d="m 13.259277,26.737199 h 29.132596 v 2.567314 H 13.259277 Z" + className={styles.line} + /> + <path + d="M 13.259277,36.439141 H 36.46805 v 2.567315 H 13.259277 Z" + className={styles.line} + /> + <path + d="m 13.259277,46.141084 h 26.586812 v 2.567314 H 13.259277 Z" + className={styles.line} + /> + <path + d="m 18.443194,65.930804 h 4.417548 v 1 h -4.417548 z" + className={styles.cursor} + /> + <path + d="m 77.586096,42.217577 v -1.680914 l 6.160884,-2.39919 -6.160884,-2.406595 v -1.68832 l 7.604842,2.89532 v 2.38438 z" + className={styles.text} + /> + <path + d="m 68.396606,43.291289 6.07943,-11.136982 h 1.688318 l -6.049809,11.136982 z" + className={styles.text} + /> + <path + d="m 59.384832,39.322258 v -2.38438 l 7.604841,-2.89532 v 1.68832 l -6.168289,2.406595 6.168289,2.399191 v 1.680915 z" + className={styles.text} + /> + <path + d="M 7.1079167,57.876372 H 92.892083 v 0.813634 H 7.1079167 Z" + className={styles.separator} + /> + <path + d="m 17.042456,64.960616 q 0,0.632276 -0.426175,0.9816 -0.422681,0.345831 -1.254074,0.37727 v 0.611318 h -0.380763 v -0.600838 q -0.751047,-0.02795 -1.170236,-0.352818 -0.419189,-0.328364 -0.551931,-1.002559 l 0.89427,-0.164183 q 0.06637,0.394736 0.261992,0.579878 0.199115,0.181648 0.565905,0.216581 v -1.365857 q -0.01048,-0.007 -0.0524,-0.01398 -0.04192,-0.01048 -0.05589,-0.01048 -0.562412,-0.129244 -0.848857,-0.303907 -0.286445,-0.178155 -0.443642,-0.447135 -0.153701,-0.272472 -0.153701,-0.663715 0,-0.579878 0.394736,-0.894269 0.394736,-0.317886 1.159755,-0.349325 v -0.468093 h 0.380763 v 0.468095 q 0.681183,0.02445 1.047973,0.303911 0.36679,0.275967 0.527479,0.918723 l -0.92222,0.136236 q -0.104797,-0.600837 -0.653236,-0.674195 v 1.22962 l 0.03843,0.007 q 0.101305,0 0.614811,0.167676 0.517,0.167676 0.772007,0.496041 0.255006,0.324871 0.255006,0.817418 z m -2.061012,-2.731715 q -0.639264,0.04891 -0.639264,0.558918 0,0.157196 0.0524,0.2585 0.0524,0.09781 0.157197,0.167676 0.104797,0.06986 0.429668,0.174662 z m 1.152769,2.745688 q 0,-0.174662 -0.06288,-0.282954 -0.06288,-0.111783 -0.185141,-0.181648 -0.118771,-0.06986 -0.523987,-0.185142 v 1.28202 q 0.772006,-0.0524 0.772006,-0.632276 z" + className={styles.root} + /> + </svg> + ); +}; + +export default ComputerScreen; diff --git a/src/components/atoms/icons/envelop.module.scss b/src/components/atoms/icons/envelop.module.scss new file mode 100644 index 0000000..202900b --- /dev/null +++ b/src/components/atoms/icons/envelop.module.scss @@ -0,0 +1,28 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + display: block; + width: var(--icon-size, #{fun.convert-px(40)}); +} + +.envelop { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.lines { + fill: var(--color-fg); +} + +.background { + fill: var(--color-shadow-dark); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.paper { + fill: var(--color-bg); + stroke: var(--color-primary-darker); + stroke-width: 4; +} diff --git a/src/components/atoms/icons/envelop.stories.tsx b/src/components/atoms/icons/envelop.stories.tsx new file mode 100644 index 0000000..9139b44 --- /dev/null +++ b/src/components/atoms/icons/envelop.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import EnvelopIcon from './envelop'; + +export default { + title: 'Atoms/Icons', + component: EnvelopIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof EnvelopIcon>; + +const Template: ComponentStory<typeof EnvelopIcon> = (args) => ( + <EnvelopIcon {...args} /> +); + +export const Envelop = Template.bind({}); diff --git a/src/components/atoms/icons/envelop.test.tsx b/src/components/atoms/icons/envelop.test.tsx new file mode 100644 index 0000000..072dc85 --- /dev/null +++ b/src/components/atoms/icons/envelop.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Envelop from './envelop'; + +describe('Envelop', () => { + it('renders an envelop icon', () => { + const { container } = render(<Envelop />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/envelop.tsx b/src/components/atoms/icons/envelop.tsx new file mode 100644 index 0000000..7b50d1d --- /dev/null +++ b/src/components/atoms/icons/envelop.tsx @@ -0,0 +1,67 @@ +import { VFC } from 'react'; +import styles from './envelop.module.scss'; + +export type EnvelopProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * Envelop Component + * + * Render an envelop svg icon. + */ +const Envelop: VFC<EnvelopProps> = ({ className = '' }) => { + return ( + <svg + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + className={`${styles.icon} ${className}`} + > + <path + className={styles.background} + d="M 1.5262527,42.535416 H 98.473747 V 98.371662 H 1.5262527 Z" + /> + <path + className={styles.envelop} + d="m 49.999985,1.6283075 c 2.855148,0 48.473753,40.8563885 48.473753,40.8563885 H 1.5262359 c 0,0 45.6186001,-40.8563885 48.4737491,-40.8563885 z" + /> + <path + className={styles.paper} + d="M 8.3434839,28.463842 H 91.656465 V 97.348661 H 8.3434839 Z" + /> + <path + className={styles.envelop} + d="M 49.999985,63.571925 98.473738,98.371692 H 1.5262359 Z" + /> + <path + className={styles.lines} + d="m 24.562439,37.640923 h 50.875053 v 1.5 H 24.562439 Z" + /> + <path + className={styles.lines} + d="m 24.562439,45.140923 h 50.875053 v 1.5 H 24.562439 Z" + /> + <path + className={styles.lines} + d="m 24.562443,52.640923 h 50.875053 v 1.5 H 24.562443 Z" + /> + <path + className={styles.lines} + d="M 24.562447,60.140923 H 75.4375 v 1.5 H 24.562447 Z" + /> + <path + className={styles.envelop} + d="M 39.93749,70.965004 1.5262559,43.55838 v 54.813242 z" + /> + <path + className={styles.envelop} + d="M 60.0625,70.965004 98.473738,43.55838 v 54.813242 z" + /> + </svg> + ); +}; + +export default Envelop; diff --git a/src/components/atoms/icons/hamburger.module.scss b/src/components/atoms/icons/hamburger.module.scss new file mode 100644 index 0000000..4fba4df --- /dev/null +++ b/src/components/atoms/icons/hamburger.module.scss @@ -0,0 +1,42 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { + display: flex; + align-items: center; + width: var(--icon-size, #{fun.convert-px(50)}); + height: var(--icon-size, #{fun.convert-px(50)}); + position: relative; +} + +.icon { + &, + &::before, + &::after { + display: block; + height: fun.convert-px(7); + width: 100%; + position: absolute; + background: var(--color-primary-lighter); + background-image: linear-gradient( + to right, + var(--color-primary-light) 0%, + var(--color-primary-lighter) 100% + ); + border: fun.convert-px(1) solid var(--color-primary-darker); + border-radius: fun.convert-px(3); + transition: all 0.25s ease-in-out 0s, transform 0.4s ease-in 0s; + } + + &::before, + &::after { + content: ""; + } + + &::before { + top: fun.convert-px(-15); + } + + &::after { + bottom: fun.convert-px(-15); + } +} diff --git a/src/components/atoms/icons/hamburger.stories.tsx b/src/components/atoms/icons/hamburger.stories.tsx new file mode 100644 index 0000000..c753e69 --- /dev/null +++ b/src/components/atoms/icons/hamburger.stories.tsx @@ -0,0 +1,41 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import HamburgerIcon from './hamburger'; + +export default { + title: 'Atoms/Icons', + component: HamburgerIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the icon wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + iconClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the icon.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof HamburgerIcon>; + +const Template: ComponentStory<typeof HamburgerIcon> = (args) => ( + <HamburgerIcon {...args} /> +); + +export const Hamburger = Template.bind({}); diff --git a/src/components/atoms/icons/hamburger.test.tsx b/src/components/atoms/icons/hamburger.test.tsx new file mode 100644 index 0000000..7173a23 --- /dev/null +++ b/src/components/atoms/icons/hamburger.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Hamburger from './hamburger'; + +describe('Hamburger', () => { + it('renders a Hamburger icon', () => { + const { container } = render(<Hamburger />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/hamburger.tsx b/src/components/atoms/icons/hamburger.tsx new file mode 100644 index 0000000..7e7c2c9 --- /dev/null +++ b/src/components/atoms/icons/hamburger.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; +import styles from './hamburger.module.scss'; + +type HamburgerProps = { + /** + * Set additional classnames to the icon wrapper. + */ + className?: string; + + /** + * Set additional classnames to the icon. + */ + iconClassName?: string; +}; + +/** + * Hamburger component + * + * Render a Hamburger icon. + */ +const Hamburger: FC<HamburgerProps> = ({ + className = '', + iconClassName = '', +}) => { + return ( + <span className={`${styles.wrapper} ${className}`}> + <span className={`${styles.icon} ${iconClassName}`}></span> + </span> + ); +}; + +export default Hamburger; diff --git a/src/components/atoms/icons/home.module.scss b/src/components/atoms/icons/home.module.scss new file mode 100644 index 0000000..48dcc6c --- /dev/null +++ b/src/components/atoms/icons/home.module.scss @@ -0,0 +1,41 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + display: block; + width: var(--icon-size, #{fun.convert-px(40)}); +} + +.wall { + fill: var(--color-bg); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.indoor { + fill: var(--color-shadow-dark); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.door { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.roof { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.chimney { + fill: var(--color-bg); + stroke: var(--color-primary-darker); + stroke-width: 4; +} + +.lines { + fill: var(--color-primary-darker); + stroke-width: 4; +} diff --git a/src/components/atoms/icons/home.stories.tsx b/src/components/atoms/icons/home.stories.tsx new file mode 100644 index 0000000..b1c995c --- /dev/null +++ b/src/components/atoms/icons/home.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import HomeIcon from './home'; + +export default { + title: 'Atoms/Icons', + component: HomeIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof HomeIcon>; + +const Template: ComponentStory<typeof HomeIcon> = (args) => ( + <HomeIcon {...args} /> +); + +export const Home = Template.bind({}); diff --git a/src/components/atoms/icons/home.test.tsx b/src/components/atoms/icons/home.test.tsx new file mode 100644 index 0000000..a08a3cf --- /dev/null +++ b/src/components/atoms/icons/home.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Home from './home'; + +describe('Home', () => { + it('renders a home icon', () => { + const { container } = render(<Home />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/home.tsx b/src/components/atoms/icons/home.tsx new file mode 100644 index 0000000..71bbc4a --- /dev/null +++ b/src/components/atoms/icons/home.tsx @@ -0,0 +1,55 @@ +import { VFC } from 'react'; +import styles from './home.module.scss'; + +export type HomeProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * Home component. + * + * Render a home svg icon. + */ +const Home: VFC<HomeProps> = ({ className = '' }) => { + return ( + <svg + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + className={`${styles.icon} ${className}`} + > + <path + className={styles.wall} + d="M 9.2669392,15.413749 H 90.709833 V 97.751815 H 9.2669392 Z" + /> + <path + className={styles.indoor} + d="m 39.190941,65.836418 h 21.594871 v 31.91539 H 39.190941 Z" + /> + <path + className={styles.door} + d="m 39.190941,65.836418 h 21.594871 v 31.91539 H 39.190941 Z" + /> + <path + className={styles.roof} + d="M 4.8219096,11.719266 H 94.720716 l 3.47304,33.365604 H 1.7830046 Z" + /> + <path + className={styles.chimney} + d="M 70.41848,2.2481852 H 82.957212 V 22.636212 H 70.41848 Z" + /> + <path + className={styles.lines} + d="M 3.9536645,19.342648 H 61.003053 v 3.293563 H 3.9536645 Z" + /> + <path + className={styles.lines} + d="m 38.973709,32.057171 h 57.049389 v 3.293563 H 38.973709 Z" + /> + </svg> + ); +}; + +export default Home; diff --git a/src/components/atoms/icons/magnifying-glass.module.scss b/src/components/atoms/icons/magnifying-glass.module.scss new file mode 100644 index 0000000..d14bec5 --- /dev/null +++ b/src/components/atoms/icons/magnifying-glass.module.scss @@ -0,0 +1,29 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + width: var(--icon-size, #{fun.convert-px(40)}); +} + +.big-handle { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 3; +} + +.glass { + fill: var(--color-bg-opacity); + stroke: var(--color-primary-darker); + stroke-width: 2; +} + +.upright { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 3; +} + +.small-handle { + fill: var(--color-primary); + stroke: var(--color-primary-darker); + stroke-width: 2; +} diff --git a/src/components/atoms/icons/magnifying-glass.stories.tsx b/src/components/atoms/icons/magnifying-glass.stories.tsx new file mode 100644 index 0000000..80e183e --- /dev/null +++ b/src/components/atoms/icons/magnifying-glass.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import MagnifyingGlassIcon from './magnifying-glass'; + +export default { + title: 'Atoms/Icons', + component: MagnifyingGlassIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof MagnifyingGlassIcon>; + +const Template: ComponentStory<typeof MagnifyingGlassIcon> = (args) => ( + <MagnifyingGlassIcon {...args} /> +); + +export const MagnifyingGlass = Template.bind({}); diff --git a/src/components/atoms/icons/magnifying-glass.test.tsx b/src/components/atoms/icons/magnifying-glass.test.tsx new file mode 100644 index 0000000..8e788f7 --- /dev/null +++ b/src/components/atoms/icons/magnifying-glass.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import MagnifyingGlass from './magnifying-glass'; + +describe('MagnifyingGlass', () => { + it('renders a magnifying glass icon', () => { + const { container } = render(<MagnifyingGlass />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/magnifying-glass.tsx b/src/components/atoms/icons/magnifying-glass.tsx new file mode 100644 index 0000000..445ef10 --- /dev/null +++ b/src/components/atoms/icons/magnifying-glass.tsx @@ -0,0 +1,43 @@ +import { VFC } from 'react'; +import styles from './magnifying-glass.module.scss'; + +export type MagnifyingGlassProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * MagnifyingGlass component + * + * Render a magnifying glass svg icon. + */ +const MagnifyingGlass: VFC<MagnifyingGlassProps> = ({ className = '' }) => { + return ( + <svg + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + className={`${styles.icon} ${className}`} + > + <path + className={styles['small-handle']} + d="m 45.39268,48.064692 5.611922,4.307881 -10.292886,13.414321 -5.611923,-4.307882 z" + /> + <path + className={styles.upright} + d="M 90.904041,28.730105 A 27.725691,27.730085 0 0 1 63.17835,56.46019 27.725691,27.730085 0 0 1 35.45266,28.730105 27.725691,27.730085 0 0 1 63.17835,1.00002 27.725691,27.730085 0 0 1 90.904041,28.730105 Z" + /> + <path + className={styles.glass} + d="M 82.438984,28.730105 A 19.260633,19.263685 0 0 1 63.17835,47.99379 19.260633,19.263685 0 0 1 43.917716,28.730105 19.260633,19.263685 0 0 1 63.17835,9.4664203 19.260633,19.263685 0 0 1 82.438984,28.730105 Z" + /> + <path + className={styles['big-handle']} + d="m 35.826055,60.434903 5.75193,4.415356 c 0.998045,0.766128 1.184879,2.186554 0.418913,3.184809 L 18.914717,98.117182 c -0.765969,0.998256 -2.186094,1.185131 -3.18414,0.418997 L 9.9786472,94.120827 C 8.9806032,93.354698 8.7937692,91.934273 9.5597392,90.936014 L 32.641919,60.853903 c 0.765967,-0.998254 2.186091,-1.185129 3.184136,-0.419 z" + /> + </svg> + ); +}; + +export default MagnifyingGlass; diff --git a/src/components/atoms/icons/moon.module.scss b/src/components/atoms/icons/moon.module.scss new file mode 100644 index 0000000..e0b53d6 --- /dev/null +++ b/src/components/atoms/icons/moon.module.scss @@ -0,0 +1,8 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 4; + width: var(--icon-size, #{fun.convert-px(25)}); +} diff --git a/src/components/atoms/icons/moon.stories.tsx b/src/components/atoms/icons/moon.stories.tsx new file mode 100644 index 0000000..4d2fb9a --- /dev/null +++ b/src/components/atoms/icons/moon.stories.tsx @@ -0,0 +1,41 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import MoonIcon from './moon'; + +export default { + title: 'Atoms/Icons', + component: MoonIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The SVG title.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof MoonIcon>; + +const Template: ComponentStory<typeof MoonIcon> = (args) => ( + <MoonIcon {...args} /> +); + +export const Moon = Template.bind({}); diff --git a/src/components/atoms/icons/moon.test.tsx b/src/components/atoms/icons/moon.test.tsx new file mode 100644 index 0000000..1c96303 --- /dev/null +++ b/src/components/atoms/icons/moon.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Moon from './moon'; + +describe('Moon', () => { + it('renders a moon icon', () => { + const { container } = render(<Moon />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/moon.tsx b/src/components/atoms/icons/moon.tsx new file mode 100644 index 0000000..4f52319 --- /dev/null +++ b/src/components/atoms/icons/moon.tsx @@ -0,0 +1,28 @@ +import { VFC } from 'react'; +import styles from './moon.module.scss'; + +type MoonProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; + /** + * The SVG title. + */ + title?: string; +}; + +const Moon: VFC<MoonProps> = ({ className = '', title }) => { + return ( + <svg + className={`${styles.icon} ${className}`} + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + > + {title !== 'undefined' && <title>{title}</title>} + <path d="M 51.077315,1.9893942 A 43.319985,43.319985 0 0 1 72.840039,39.563145 43.319985,43.319985 0 0 1 29.520053,82.88313 43.319985,43.319985 0 0 1 5.4309911,75.569042 48.132997,48.132997 0 0 0 46.126047,98 48.132997,48.132997 0 0 0 94.260004,49.867002 48.132997,48.132997 0 0 0 51.077315,1.9893942 Z" /> + </svg> + ); +}; + +export default Moon; diff --git a/src/components/atoms/icons/plus-minus.module.scss b/src/components/atoms/icons/plus-minus.module.scss new file mode 100644 index 0000000..c54db33 --- /dev/null +++ b/src/components/atoms/icons/plus-minus.module.scss @@ -0,0 +1,39 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + display: flex; + place-content: center; + place-items: center; + width: var(--icon-size, #{fun.convert-px(30)}); + height: var(--icon-size, #{fun.convert-px(30)}); + position: relative; + background: var(--color-bg); + border: fun.convert-px(1) solid var(--color-primary); + border-radius: fun.convert-px(3); + color: var(--color-primary); + + &::before, + &::after { + content: ""; + position: absolute; + background: var(--color-primary); + transition: transform 0.4s ease-out 0s; + } + + &::after { + height: fun.convert-px(3); + width: 60%; + } + + &::before { + height: 60%; + width: fun.convert-px(3); + transform: scaleY(1); + } + + &--minus { + &::before { + transform: scaleY(0); + } + } +} diff --git a/src/components/atoms/icons/plus-minus.stories.tsx b/src/components/atoms/icons/plus-minus.stories.tsx new file mode 100644 index 0000000..ffa28f2 --- /dev/null +++ b/src/components/atoms/icons/plus-minus.stories.tsx @@ -0,0 +1,43 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import PlusMinusIcon from './plus-minus'; + +export default { + title: 'Atoms/Icons', + component: PlusMinusIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + state: { + control: { + type: 'radio', + options: ['plus', 'minus'], + }, + description: 'Which state should be displayed.', + type: { + name: 'enum', + required: true, + value: ['plus', 'minus'], + }, + }, + }, +} as ComponentMeta<typeof PlusMinusIcon>; + +const Template: ComponentStory<typeof PlusMinusIcon> = (args) => ( + <PlusMinusIcon {...args} /> +); + +export const PlusMinus = Template.bind({}); +PlusMinus.args = { + state: 'plus', +}; diff --git a/src/components/atoms/icons/plus-minus.test.tsx b/src/components/atoms/icons/plus-minus.test.tsx new file mode 100644 index 0000000..6903c7a --- /dev/null +++ b/src/components/atoms/icons/plus-minus.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import PlusMinus from './plus-minus'; + +describe('PlusMinus', () => { + it('renders a plus/minus icon', () => { + const { container } = render(<PlusMinus state="plus" />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/plus-minus.tsx b/src/components/atoms/icons/plus-minus.tsx new file mode 100644 index 0000000..78aa14a --- /dev/null +++ b/src/components/atoms/icons/plus-minus.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; +import styles from './plus-minus.module.scss'; + +type PlusMinusProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; + /** + * Which state should be displayed. + */ + state: 'plus' | 'minus'; +}; + +/** + * PlusMinus component + * + * Render a plus or a minus icon. + */ +const PlusMinus: FC<PlusMinusProps> = ({ className, state }) => { + const stateClass = `icon--${state}`; + + return ( + <div + className={`${styles.icon} ${styles[stateClass]} ${className}`} + aria-hidden={true} + ></div> + ); +}; + +export default PlusMinus; diff --git a/src/components/atoms/icons/posts-stack.module.scss b/src/components/atoms/icons/posts-stack.module.scss new file mode 100644 index 0000000..a22d265 --- /dev/null +++ b/src/components/atoms/icons/posts-stack.module.scss @@ -0,0 +1,22 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + display: block; + width: var(--icon-size, #{fun.convert-px(40)}); +} + +.lines { + fill: var(--color-fg); + stroke-width: 4; +} + +.picture { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); +} + +.background { + fill: var(--color-bg); + stroke: var(--color-primary-darker); + stroke-width: 4; +} diff --git a/src/components/atoms/icons/posts-stack.stories.tsx b/src/components/atoms/icons/posts-stack.stories.tsx new file mode 100644 index 0000000..46bb39f --- /dev/null +++ b/src/components/atoms/icons/posts-stack.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import PostsStackIcon from './posts-stack'; + +export default { + title: 'Atoms/Icons', + component: PostsStackIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof PostsStackIcon>; + +const Template: ComponentStory<typeof PostsStackIcon> = (args) => ( + <PostsStackIcon {...args} /> +); + +export const PostsStack = Template.bind({}); diff --git a/src/components/atoms/icons/posts-stack.test.tsx b/src/components/atoms/icons/posts-stack.test.tsx new file mode 100644 index 0000000..8f44fa9 --- /dev/null +++ b/src/components/atoms/icons/posts-stack.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import PostsStack from './posts-stack'; + +describe('PostsStack', () => { + it('renders a posts stack icon', () => { + const { container } = render(<PostsStack />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/posts-stack.tsx b/src/components/atoms/icons/posts-stack.tsx new file mode 100644 index 0000000..1998d25 --- /dev/null +++ b/src/components/atoms/icons/posts-stack.tsx @@ -0,0 +1,75 @@ +import { VFC } from 'react'; +import styles from './posts-stack.module.scss'; + +export type PostsStackProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; +}; + +/** + * Posts stack component. + * + * Render a posts stack svg icon. + */ +const PostsStack: VFC<PostsStackProps> = ({ className = '' }) => { + return ( + <svg + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + className={`${styles.icon} ${className}`} + > + <path + className={styles.background} + d="M 28.992096,1.4822128 H 90.770752 V 82.312253 H 28.992096 Z" + /> + <path + className={styles.background} + d="m 19.110672,8.992094 h 61.778656 v 80.83004 H 19.110672 Z" + /> + <path + className={styles.background} + d="m 9.229248,17.687748 h 61.778656 v 80.83004 H 9.229248 Z" + /> + <path + className={styles.picture} + d="M 18.149242,74.65544 H 33.375246 V 90.194215 H 18.149242 Z" + /> + <path + className={styles.picture} + d="M 18.142653,24.858688 H 62.094499 V 35.908926 H 18.142653 Z" + /> + <path + className={styles.lines} + d="m 17.618576,41.908926 h 45 v 2 h -45 z" + /> + <path + className={styles.lines} + d="m 17.618576,49.908926 h 45 v 2 h -45 z" + /> + <path + className={styles.lines} + d="m 17.618576,57.908926 h 45 v 2 h -45 z" + /> + <path + className={styles.lines} + d="m 17.618576,65.908926 h 45 v 2 h -45 z" + /> + <path + className={styles.lines} + d="m 41.833105,73.424828 h 20.785471 v 2 H 41.833105 Z" + /> + <path + className={styles.lines} + d="m 41.833105,81.424828 h 20.785471 v 2 H 41.833105 Z" + /> + <path + className={styles.lines} + d="m 41.833105,89.424828 h 20.785471 v 2 H 41.833105 Z" + /> + </svg> + ); +}; + +export default PostsStack; diff --git a/src/components/atoms/icons/sun.module.scss b/src/components/atoms/icons/sun.module.scss new file mode 100644 index 0000000..5682aa3 --- /dev/null +++ b/src/components/atoms/icons/sun.module.scss @@ -0,0 +1,8 @@ +@use "@styles/abstracts/functions" as fun; + +.sun { + fill: var(--color-primary-lighter); + stroke: var(--color-primary-darker); + stroke-width: 4; + width: var(--icon-size, #{fun.convert-px(25)}); +} diff --git a/src/components/atoms/icons/sun.stories.tsx b/src/components/atoms/icons/sun.stories.tsx new file mode 100644 index 0000000..23c5b27 --- /dev/null +++ b/src/components/atoms/icons/sun.stories.tsx @@ -0,0 +1,41 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import SunIcon from './sun'; + +export default { + title: 'Atoms/Icons', + component: SunIcon, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The SVG title.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof SunIcon>; + +const Template: ComponentStory<typeof SunIcon> = (args) => ( + <SunIcon {...args} /> +); + +export const Sun = Template.bind({}); diff --git a/src/components/atoms/icons/sun.test.tsx b/src/components/atoms/icons/sun.test.tsx new file mode 100644 index 0000000..21661a9 --- /dev/null +++ b/src/components/atoms/icons/sun.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@test-utils'; +import Sun from './sun'; + +describe('Sun', () => { + it('renders a sun icon', () => { + const { container } = render(<Sun />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/icons/sun.tsx b/src/components/atoms/icons/sun.tsx new file mode 100644 index 0000000..fa9d922 --- /dev/null +++ b/src/components/atoms/icons/sun.tsx @@ -0,0 +1,33 @@ +import { VFC } from 'react'; +import styles from './sun.module.scss'; + +type SunProps = { + /** + * Set additional classnames to the icon. + */ + className?: string; + /** + * The SVG title. + */ + title?: string; +}; + +/** + * Sun component. + * + * Render a svg sun icon. + */ +const Sun: VFC<SunProps> = ({ className = '', title }) => { + return ( + <svg + className={`${styles.sun} ${className}`} + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + > + {title !== 'undefined' && <title>{title}</title>} + <path d="M 69.398043,50.000437 A 19.399259,19.399204 0 0 1 49.998784,69.399641 19.399259,19.399204 0 0 1 30.599525,50.000437 19.399259,19.399204 0 0 1 49.998784,30.601234 19.399259,19.399204 0 0 1 69.398043,50.000437 Z m 27.699233,1.125154 c 2.657696,0.0679 1.156196,12.061455 -1.435545,11.463959 L 80.113224,59.000697 c -2.589801,-0.597494 -1.625657,-8.345536 1.032041,-8.278609 z m -18.06653,37.251321 c 1.644087,2.091234 -9.030355,8.610337 -10.126414,6.188346 L 62.331863,80.024585 c -1.096058,-2.423931 5.197062,-6.285342 6.839209,-4.194107 z M 38.611418,97.594444 C 38.02653,100.18909 26.24148,95.916413 27.436475,93.54001 l 7.168026,-14.256474 c 1.194024,-2.376403 8.102101,0.151313 7.517214,2.744986 z M 6.1661563,71.834242 C 3.7916868,73.028262 -0.25499873,61.16274 2.3386824,60.577853 L 17.905618,57.067567 c 2.593681,-0.584886 4.894434,6.403678 2.518995,7.598668 z M 6.146757,30.055146 c -2.3764094,-1.194991 4.46571,-11.714209 6.479353,-9.97798 l 12.090589,10.414462 c 2.014613,1.736229 -1.937017,7.926514 -4.314396,6.731524 z M 38.56777,4.2639045 C 37.982883,1.6682911 50.480855,0.41801247 50.415868,3.0766733 L 50.020123,19.028638 c -0.06596,2.657691 -7.357169,3.394862 -7.943027,0.800218 z m 40.403808,9.1622435 c 1.635357,-2.098023 10.437771,6.872168 8.339742,8.506552 l -12.58818,9.805327 c -2.099,1.634383 -7.192276,-3.626682 -5.557888,-5.724706 z M 97.096306,50.69105 c 2.657696,-0.06596 1.164926,12.462047 -1.425846,11.863582 L 80.122924,58.96578 c -2.590771,-0.597496 -1.636327,-7.814 1.021371,-7.879957 z" /> + </svg> + ); +}; + +export default Sun; diff --git a/src/components/atoms/images/logo.module.scss b/src/components/atoms/images/logo.module.scss new file mode 100644 index 0000000..f802a4b --- /dev/null +++ b/src/components/atoms/images/logo.module.scss @@ -0,0 +1,28 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { + width: var(--logo-size, fun.convert-px(100)); + height: var(--logo-size, fun.convert-px(100)); + max-width: 100%; + max-height: 100%; +} + +.bg-left { + fill: var(--color-primary-light); +} + +.bg-right { + fill: var(--color-primary-dark); +} + +.letter { + fill: var(--color-fg-inverted); + stroke: var(--color-primary-darker); + stroke-width: 5; +} + +.letter-shadow { + fill: var(--color-shadow-darker); + stroke: var(--color-shadow-darker); + stroke-width: 5; +} diff --git a/src/components/atoms/images/logo.stories.tsx b/src/components/atoms/images/logo.stories.tsx new file mode 100644 index 0000000..fbc7501 --- /dev/null +++ b/src/components/atoms/images/logo.stories.tsx @@ -0,0 +1,28 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import LogoComponent from './logo'; + +export default { + title: 'Atoms/Images', + component: LogoComponent, + argTypes: { + title: { + control: { + type: 'text', + }, + description: 'The SVG title.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof LogoComponent>; + +const Template: ComponentStory<typeof LogoComponent> = (args) => ( + <LogoComponent {...args} /> +); + +export const Logo = Template.bind({}); diff --git a/src/components/atoms/images/logo.test.tsx b/src/components/atoms/images/logo.test.tsx new file mode 100644 index 0000000..3e0d238 --- /dev/null +++ b/src/components/atoms/images/logo.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import Logo from './logo'; + +describe('Logo', () => { + it('renders a logo with a title', () => { + render(<Logo title="My title" />); + expect(screen.getByTitle('My title')).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/images/logo.tsx b/src/components/atoms/images/logo.tsx new file mode 100644 index 0000000..2e52110 --- /dev/null +++ b/src/components/atoms/images/logo.tsx @@ -0,0 +1,46 @@ +import { VFC } from 'react'; +import styles from './logo.module.scss'; + +type LogoProps = { + /** + * SVG Image title. + */ + title?: string; +}; + +/** + * Logo component. + * + * Render a SVG logo. + */ +const Logo: VFC<LogoProps> = ({ title }) => { + return ( + <svg + viewBox="0 0 512 512" + xmlns="http://www.w3.org/2000/svg" + className={styles.wrapper} + > + {title && <title>{title}</title>} + <path className={styles['bg-left']} d="M 0,0 H 506 L 0,506 Z" /> + <path className={styles['bg-right']} d="M 512,512 H 6 L 512,6 Z" /> + <path + className={styles['letter-shadow']} + d="m 66.049088,353.26557 h 57.233082 l 15.4763,-40.00476 h 56.64908 l 15.76831,40.00476 h 57.2331 L 196.28357,165.21398 h -58.10911 z m 80.009522,-79.42552 21.02441,-55.18904 21.02439,55.18904 z" + /> + <path + className={styles['letter']} + d="m 59.569539,346.78602 h 57.233081 l 15.4763,-40.00476 H 188.928 l 15.76831,40.00476 h 57.2331 L 189.80402,158.73443 h -58.10911 z m 80.009521,-79.42552 21.02441,-55.18904 21.02439,55.18904 z" + /> + <path + className={styles['letter-shadow']} + d="m 288.84935,353.26557 h 54.89704 v -50.51696 h 40.88078 c 42.04881,0 68.91332,-28.61654 68.91332,-68.32931 0,-38.5447 -21.60841,-69.20532 -67.74528,-69.20532 h -96.94586 z m 54.89704,-92.56578 v -53.437 h 29.78458 c 16.35231,0 23.94446,10.51221 23.94446,27.15651 0,15.47629 -8.46817,26.28049 -25.40449,26.28049 z" + /> + <path + className={styles['letter']} + d="m 282.3698,346.78602 h 54.89704 v -50.51696 h 40.88078 c 42.04881,0 68.91332,-28.61654 68.91332,-68.3293 0,-38.54471 -21.60841,-69.20533 -67.74528,-69.20533 H 282.3698 Z m 54.89704,-92.56578 v -53.437 h 29.78458 c 16.35231,0 23.94446,10.51221 23.94446,27.15652 0,15.47628 -8.46817,26.28048 -25.40449,26.28048 z" + /> + </svg> + ); +}; + +export default Logo; diff --git a/src/components/atoms/layout/copyright.module.scss b/src/components/atoms/layout/copyright.module.scss new file mode 100644 index 0000000..a0e5347 --- /dev/null +++ b/src/components/atoms/layout/copyright.module.scss @@ -0,0 +1,32 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.wrapper { + --icon-size: #{fun.convert-px(70)}; + + display: flex; + flex-flow: row wrap; + align-items: center; + place-content: center; + gap: var(--spacing-2xs); + margin: 0; + font-family: var(--font-family-secondary); + font-size: var(--font-size-md); + text-align: center; + + @include mix.media("screen") { + @include mix.dimensions("sm") { + text-align: left; + } + } +} + +.owner { + flex: 1 0 100%; + + @include mix.media("screen") { + @include mix.dimensions("sm") { + flex: initial; + } + } +} diff --git a/src/components/atoms/layout/copyright.stories.tsx b/src/components/atoms/layout/copyright.stories.tsx new file mode 100644 index 0000000..3b315fa --- /dev/null +++ b/src/components/atoms/layout/copyright.stories.tsx @@ -0,0 +1,55 @@ +import CCBySA from '@components/atoms/icons/cc-by-sa'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import CopyrightComponent from './copyright'; + +export default { + title: 'Atoms/Layout', + component: CopyrightComponent, + argTypes: { + dates: { + description: 'The copyright dates.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + icon: { + control: { + type: null, + }, + description: 'The copyright icon.', + type: { + name: 'string', + required: true, + }, + }, + owner: { + control: { + type: 'text', + }, + description: 'The copyright owner', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof CopyrightComponent>; + +const Template: ComponentStory<typeof CopyrightComponent> = (args) => ( + <IntlProvider locale="en"> + <CopyrightComponent {...args} /> + </IntlProvider> +); + +export const Copyright = Template.bind({}); +Copyright.args = { + dates: { + start: '2012', + end: '2022', + }, + icon: <CCBySA />, + owner: 'Your name', +}; diff --git a/src/components/atoms/layout/copyright.test.tsx b/src/components/atoms/layout/copyright.test.tsx new file mode 100644 index 0000000..6bfe612 --- /dev/null +++ b/src/components/atoms/layout/copyright.test.tsx @@ -0,0 +1,32 @@ +import CCBySA from '@components/atoms/icons/cc-by-sa'; +import { render, screen } from '@test-utils'; +import Copyright from './copyright'; + +const dates = { + start: '2012', + end: '2022', +}; +const icon = <CCBySA />; +const owner = 'Your name'; + +describe('Copyright', () => { + it('renders the copyright owner', () => { + render(<Copyright dates={dates} icon={icon} owner={owner} />); + expect(screen.getByText(owner)).toBeInTheDocument(); + }); + + it('renders the copyright start date', () => { + render(<Copyright dates={dates} icon={icon} owner={owner} />); + expect(screen.getByText(dates.start)).toBeInTheDocument(); + }); + + it('renders the copyright end date', () => { + render(<Copyright dates={dates} icon={icon} owner={owner} />); + expect(screen.getByText(dates.end)).toBeInTheDocument(); + }); + + it('renders the copyright icon', () => { + render(<Copyright dates={dates} icon={icon} owner={owner} />); + expect(screen.getByTitle('CC BY SA')).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/layout/copyright.tsx b/src/components/atoms/layout/copyright.tsx new file mode 100644 index 0000000..76252ee --- /dev/null +++ b/src/components/atoms/layout/copyright.tsx @@ -0,0 +1,59 @@ +import { ReactNode, VFC } from 'react'; +import styles from './copyright.module.scss'; + +export type CopyrightDates = { + /** + * The copyright start year. + */ + start: string; + /** + * The copyright end year. + */ + end?: string; +}; + +export type CopyrightProps = { + /** + * The copyright owner. + */ + owner: string; + /** + * The copyright dates. + */ + dates: CopyrightDates; + /** + * The copyright icon. + */ + icon: ReactNode; +}; + +/** + * Copyright component + * + * Renders a copyright information (owner, dates, license icon). + */ +const Copyright: VFC<CopyrightProps> = ({ owner, dates, icon }) => { + const getFormattedDate = (date: string) => { + const datetime = new Date(date).toISOString(); + + return <time dateTime={datetime}>{date}</time>; + }; + + return ( + <div className={styles.wrapper}> + <span className={styles.owner}>{owner}</span> + {icon} + {getFormattedDate(dates.start)} + {dates.end ? ( + <> + <span>-</span> + {getFormattedDate(dates.end)} + </> + ) : ( + '' + )} + </div> + ); +}; + +export default Copyright; diff --git a/src/components/atoms/layout/main.stories.tsx b/src/components/atoms/layout/main.stories.tsx new file mode 100644 index 0000000..64df890 --- /dev/null +++ b/src/components/atoms/layout/main.stories.tsx @@ -0,0 +1,52 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import MainComponent from './main'; + +export default { + title: 'Atoms/Layout', + component: MainComponent, + argTypes: { + children: { + control: { + type: 'text', + }, + description: 'The content.', + type: { + name: 'string', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the main element.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'The main element id.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof MainComponent>; + +const Template: ComponentStory<typeof MainComponent> = (args) => ( + <MainComponent {...args} /> +); + +export const Main = Template.bind({}); +Main.args = { + children: 'The main content.', + id: '#main', +}; diff --git a/src/components/atoms/layout/main.test.tsx b/src/components/atoms/layout/main.test.tsx new file mode 100644 index 0000000..f91846f --- /dev/null +++ b/src/components/atoms/layout/main.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@test-utils'; +import Main from './main'; + +const id = 'main'; +const children = 'The main content.'; + +describe('Main', () => { + it('renders the content of main element', () => { + render(<Main id={id}>{children}</Main>); + expect(screen.getByRole('main')).toHaveTextContent(children); + }); +}); diff --git a/src/components/atoms/layout/main.tsx b/src/components/atoms/layout/main.tsx new file mode 100644 index 0000000..4549328 --- /dev/null +++ b/src/components/atoms/layout/main.tsx @@ -0,0 +1,23 @@ +import { FC } from 'react'; + +export type MainProps = { + /** + * Set additional classnames to the main element. + */ + className?: string; + /** + * The main wrapper id. + */ + id: string; +}; + +/** + * Main component + * + * Render a main element. + */ +const Main: FC<MainProps> = ({ children, ...props }) => { + return <main {...props}>{children}</main>; +}; + +export default Main; diff --git a/src/components/atoms/layout/no-script.module.scss b/src/components/atoms/layout/no-script.module.scss new file mode 100644 index 0000000..d8712af --- /dev/null +++ b/src/components/atoms/layout/no-script.module.scss @@ -0,0 +1,19 @@ +@use "@styles/abstracts/functions" as fun; + +.noscript { + color: var(--color-primary-darker); + + &--top { + padding: var(--spacing-xs) var(--spacing-sm); + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 10; + background: var(--color-bg); + border-bottom: fun.convert-px(3) solid var(--color-border); + font-size: var(--font-size-sm); + font-weight: 600; + text-align: center; + } +} diff --git a/src/components/atoms/layout/no-script.stories.tsx b/src/components/atoms/layout/no-script.stories.tsx new file mode 100644 index 0000000..474e2fb --- /dev/null +++ b/src/components/atoms/layout/no-script.stories.tsx @@ -0,0 +1,46 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import NoScriptComponent from './no-script'; + +export default { + title: 'Atoms/Layout', + component: NoScriptComponent, + args: { + position: 'initial', + }, + argTypes: { + message: { + control: { + type: 'text', + }, + description: 'A message to display when Javascript is disabled.', + type: { + name: 'string', + required: true, + }, + }, + position: { + control: { + type: 'select', + }, + description: 'The message position.', + options: ['initial', 'top'], + table: { + category: 'Options', + defaultValue: 'initial', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof NoScriptComponent>; + +const Template: ComponentStory<typeof NoScriptComponent> = (args) => ( + <NoScriptComponent {...args} /> +); + +export const NoScript = Template.bind({}); +NoScript.args = { + message: 'A noscript only message.', +}; diff --git a/src/components/atoms/layout/no-script.test.tsx b/src/components/atoms/layout/no-script.test.tsx new file mode 100644 index 0000000..9ed9c4c --- /dev/null +++ b/src/components/atoms/layout/no-script.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@test-utils'; +import NoScript from './no-script'; + +const message = 'A noscript message.'; + +describe('NoScript', () => { + it('renders a message', () => { + render(<NoScript message={message} />); + expect(screen.getByText(message)).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/layout/no-script.tsx b/src/components/atoms/layout/no-script.tsx new file mode 100644 index 0000000..6358cf8 --- /dev/null +++ b/src/components/atoms/layout/no-script.tsx @@ -0,0 +1,21 @@ +import { VFC } from 'react'; +import styles from './no-script.module.scss'; + +export type NoScriptProps = { + /** + * The noscript message. + */ + message: string; + /** + * The message position. Default: initial. + */ + position?: 'initial' | 'top'; +}; + +const NoScript: VFC<NoScriptProps> = ({ message, position = 'initial' }) => { + const positionClass = styles[`noscript--${position}`]; + + return <div className={`${styles.noscript} ${positionClass}`}>{message}</div>; +}; + +export default NoScript; diff --git a/src/components/atoms/layout/notice.module.scss b/src/components/atoms/layout/notice.module.scss new file mode 100644 index 0000000..38ec7ee --- /dev/null +++ b/src/components/atoms/layout/notice.module.scss @@ -0,0 +1,28 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { + width: max-content; + padding: var(--spacing-2xs) var(--spacing-xs); + border: fun.convert-px(2) solid; + font-weight: bold; + + &--error { + border-color: var(--color-token-red); + color: var(--color-token-red); + } + + &--info { + border-color: var(--color-token-blue); + color: var(--color-token-blue); + } + + &--success { + border-color: var(--color-token-green); + color: var(--color-token-green); + } + + &--warning { + border-color: var(--color-token-orange); + color: var(--color-token-orange); + } +} diff --git a/src/components/atoms/layout/notice.stories.tsx b/src/components/atoms/layout/notice.stories.tsx new file mode 100644 index 0000000..0555a2e --- /dev/null +++ b/src/components/atoms/layout/notice.stories.tsx @@ -0,0 +1,40 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import NoticeComponent from './notice'; + +export default { + title: 'Atoms/Layout', + component: NoticeComponent, + argTypes: { + kind: { + control: { + type: 'select', + }, + description: 'The notice kind.', + options: ['error', 'info', 'success', 'warning'], + type: { + name: 'string', + required: true, + }, + }, + message: { + control: { + type: 'text', + }, + description: 'The notice body.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof NoticeComponent>; + +const Template: ComponentStory<typeof NoticeComponent> = (args) => ( + <NoticeComponent {...args} /> +); + +export const Notice = Template.bind({}); +Notice.args = { + kind: 'info', + message: 'Nisi provident sapiente.', +}; diff --git a/src/components/atoms/layout/notice.test.tsx b/src/components/atoms/layout/notice.test.tsx new file mode 100644 index 0000000..4501f8f --- /dev/null +++ b/src/components/atoms/layout/notice.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@test-utils'; +import Notice from './notice'; + +const message = 'Tenetur consequuntur tempore.'; + +describe('Notice', () => { + it('renders a message', () => { + render(<Notice kind="info" message={message} />); + expect(screen.getByText(message)).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/layout/notice.tsx b/src/components/atoms/layout/notice.tsx new file mode 100644 index 0000000..b6e09c5 --- /dev/null +++ b/src/components/atoms/layout/notice.tsx @@ -0,0 +1,30 @@ +import { VFC } from 'react'; +import styles from './notice.module.scss'; + +export type NoticeKind = 'error' | 'info' | 'success' | 'warning'; + +export type NoticeProps = { + /** + * The notice kind. + */ + kind: NoticeKind; + /** + * The notice body. + */ + message: string; +}; + +/** + * Notice component + * + * Render a colored message depending on notice kind. + */ +const Notice: VFC<NoticeProps> = ({ kind, message }) => { + const kindClass = `wrapper--${kind}`; + + return ( + <div className={`${styles.wrapper} ${styles[kindClass]}`}>{message}</div> + ); +}; + +export default Notice; diff --git a/src/components/atoms/layout/section.module.scss b/src/components/atoms/layout/section.module.scss new file mode 100644 index 0000000..012493a --- /dev/null +++ b/src/components/atoms/layout/section.module.scss @@ -0,0 +1,25 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/placeholders"; + +.wrapper { + @extend %grid; + + padding: var(--spacing-md) 0; + + &--borders { + border-bottom: fun.convert-px(1) solid var(--color-border); + } + + &--dark { + background: var(--color-bg-secondary); + } + + &--light { + background: var(--color-bg); + } +} + +.body, +.title { + grid-column: 2; +} diff --git a/src/components/atoms/layout/section.stories.tsx b/src/components/atoms/layout/section.stories.tsx new file mode 100644 index 0000000..abbbeed --- /dev/null +++ b/src/components/atoms/layout/section.stories.tsx @@ -0,0 +1,85 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import SectionComponent from './section'; + +export default { + title: 'Atoms/Layout', + component: SectionComponent, + args: { + variant: 'dark', + withBorder: true, + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the section element.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + content: { + control: { + type: 'text', + }, + description: 'The section content.', + type: { + name: 'string', + required: true, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The section title.', + type: { + name: 'string', + required: true, + }, + }, + variant: { + control: { + type: 'select', + }, + description: 'The section variant.', + options: ['light', 'dark'], + table: { + category: 'Styles', + defaultValue: { summary: 'dark' }, + }, + type: { + name: 'string', + required: false, + }, + }, + withBorder: { + control: { + type: 'boolean', + }, + description: 'Add a border at the bottom of the section.', + table: { + category: 'Styles', + defaultValue: { summary: true }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + }, +} as ComponentMeta<typeof SectionComponent>; + +const Template: ComponentStory<typeof SectionComponent> = (args) => ( + <SectionComponent {...args} /> +); + +export const Section = Template.bind({}); +Section.args = { + title: 'A title', + content: 'The content.', +}; diff --git a/src/components/atoms/layout/section.test.tsx b/src/components/atoms/layout/section.test.tsx new file mode 100644 index 0000000..ca5f03a --- /dev/null +++ b/src/components/atoms/layout/section.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@test-utils'; +import Section from './section'; + +const title = 'Section title'; +const content = 'Section content.'; + +describe('Section', () => { + it('renders a title (h2)', () => { + render(<Section title={title} content={content} />); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(title); + }); + + it('renders a content', () => { + render(<Section title={title} content={content} />); + expect(screen.getByText(content)).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/layout/section.tsx b/src/components/atoms/layout/section.tsx new file mode 100644 index 0000000..f1bbb34 --- /dev/null +++ b/src/components/atoms/layout/section.tsx @@ -0,0 +1,57 @@ +import { ReactNode, VFC } from 'react'; +import Heading from '../headings/heading'; +import styles from './section.module.scss'; + +export type SectionVariant = 'dark' | 'light'; + +export type SectionProps = { + /** + * Set additional classnames to the section element. + */ + className?: string; + /** + * The section content. + */ + content: ReactNode; + /** + * The section title. + */ + title: string; + /** + * The section variant. + */ + variant?: SectionVariant; + /** + * Add a border at the bottom of the section. Default: true. + */ + withBorder?: boolean; +}; + +/** + * Section component + * + * Render a section element. + */ +const Section: VFC<SectionProps> = ({ + className = '', + content, + title, + variant = 'dark', + withBorder = true, +}) => { + const borderClass = withBorder ? styles[`wrapper--borders`] : ''; + const variantClass = styles[`wrapper--${variant}`]; + + return ( + <section + className={`${styles.wrapper} ${borderClass} ${variantClass} ${className}`} + > + <Heading level={2} className={styles.title}> + {title} + </Heading> + <div className={styles.body}>{content}</div> + </section> + ); +}; + +export default Section; diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss new file mode 100644 index 0000000..e7ead86 --- /dev/null +++ b/src/components/atoms/links/link.module.scss @@ -0,0 +1,37 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/variables" as var; + +.link { + &[hreflang] { + &::after { + display: inline-block; + content: "\0000a0["attr(hreflang) "]"; + font-size: var(--font-size-sm); + } + } + + &--external { + &::after { + display: inline-block; + content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>')); + } + + &:focus:not(:active)::after { + content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>')); + } + + &[hreflang] { + &::after { + content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg( + '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>' + )); + } + + &:focus:not(:active)::after { + content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg( + '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>' + )); + } + } + } +} diff --git a/src/components/atoms/links/link.stories.tsx b/src/components/atoms/links/link.stories.tsx new file mode 100644 index 0000000..569c874 --- /dev/null +++ b/src/components/atoms/links/link.stories.tsx @@ -0,0 +1,79 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import LinkComponent from './link'; + +export default { + title: 'Atoms/Links', + component: LinkComponent, + argTypes: { + children: { + control: { + type: 'text', + }, + description: 'The link body.', + type: { + name: 'string', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + external: { + control: { + type: 'boolean', + }, + table: { + category: 'Options', + }, + description: 'Determine if the link is external of the current website.', + type: { + name: 'boolean', + required: false, + }, + }, + href: { + control: { + type: 'text', + }, + description: 'The link target.', + type: { + name: 'string', + required: true, + }, + }, + lang: { + control: { + type: 'text', + }, + table: { + category: 'Options', + }, + description: 'The target language as code language.', + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof LinkComponent>; + +const Template: ComponentStory<typeof LinkComponent> = (args) => ( + <LinkComponent {...args} /> +); + +export const Link = Template.bind({}); +Link.args = { + children: 'A link', + href: '#', + external: false, +}; diff --git a/src/components/atoms/links/link.test.tsx b/src/components/atoms/links/link.test.tsx new file mode 100644 index 0000000..54e2414 --- /dev/null +++ b/src/components/atoms/links/link.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import Link from './link'; + +describe('Link', () => { + it('render a link', () => { + render(<Link href="#">A link</Link>); + expect(screen.getByRole('link')).toHaveTextContent('A link'); + }); +}); diff --git a/src/components/atoms/links/link.tsx b/src/components/atoms/links/link.tsx new file mode 100644 index 0000000..87f11fc --- /dev/null +++ b/src/components/atoms/links/link.tsx @@ -0,0 +1,51 @@ +import NextLink from 'next/link'; +import { FC } from 'react'; +import styles from './link.module.scss'; + +export type LinkProps = { + /** + * Set additional classnames to the link. + */ + className?: string; + /** + * True if it is an external link. Default: false. + */ + external?: boolean; + /** + * The link target. + */ + href: string; + /** + * The link target code language. + */ + lang?: string; +}; + +/** + * Link Component + * + * Render a link. + */ +const Link: FC<LinkProps> = ({ + children, + className = '', + href, + lang, + external = false, +}) => { + return external ? ( + <a + href={href} + hrefLang={lang} + className={`${styles.link} ${styles['link--external']} ${className}`} + > + {children} + </a> + ) : ( + <NextLink href={href}> + <a className={`${styles.link} ${className}`}>{children}</a> + </NextLink> + ); +}; + +export default Link; diff --git a/src/components/atoms/links/nav-link.module.scss b/src/components/atoms/links/nav-link.module.scss new file mode 100644 index 0000000..241c9c3 --- /dev/null +++ b/src/components/atoms/links/nav-link.module.scss @@ -0,0 +1,46 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; +@use "@styles/abstracts/placeholders"; + +.link { + --draw-border-thickness: #{fun.convert-px(4)}; + --draw-border-color1: var(--color-primary-light); + --draw-border-color2: var(--color-primary-lighter); + --icon-size: #{fun.convert-px(30)}; + + display: inline-flex; + flex-flow: column nowrap; + place-items: center; + place-content: center; + row-gap: var(--spacing-2xs); + min-width: var(--link-min-width, fun.convert-px(85)); + padding: var(--spacing-xs); + background: inherit; + font-size: var(--font-size-sm); + font-variant: small-caps; + font-weight: 600; + line-height: 1; + text-decoration: none; + + @include mix.media("screen") { + @include mix.dimensions("md") { + border-radius: 8%; + } + } + + &:hover, + &:focus { + @extend %draw-borders; + } + + &:focus { + color: var(--color-primary-light); + } + + &:active { + --draw-border-color1: var(--color-primary-dark); + --draw-border-color2: var(--color-primary-light); + + @extend %draw-borders; + } +} diff --git a/src/components/atoms/links/nav-link.stories.tsx b/src/components/atoms/links/nav-link.stories.tsx new file mode 100644 index 0000000..08553be --- /dev/null +++ b/src/components/atoms/links/nav-link.stories.tsx @@ -0,0 +1,49 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import NavLinkComponent from './nav-link'; + +export default { + title: 'Atoms/Links', + component: NavLinkComponent, + argTypes: { + href: { + control: { + type: 'text', + }, + description: 'The link target.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'The link label.', + type: { + name: 'string', + required: true, + }, + }, + logo: { + control: { + type: null, + }, + description: 'The link logo.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof NavLinkComponent>; + +const Template: ComponentStory<typeof NavLinkComponent> = (args) => ( + <NavLinkComponent {...args} /> +); + +export const NavLink = Template.bind({}); +NavLink.args = { + href: '#', + label: 'A nav link', +}; diff --git a/src/components/atoms/links/nav-link.test.tsx b/src/components/atoms/links/nav-link.test.tsx new file mode 100644 index 0000000..7750cee --- /dev/null +++ b/src/components/atoms/links/nav-link.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@test-utils'; +import NavLink from './nav-link'; + +describe('NavLink', () => { + it('renders a nav link to blog page', () => { + render(<NavLink href="/blog" label="Blog" />); + expect(screen.getByRole('link', { name: 'Blog' })).toHaveAttribute( + 'href', + '/blog' + ); + }); +}); diff --git a/src/components/atoms/links/nav-link.tsx b/src/components/atoms/links/nav-link.tsx new file mode 100644 index 0000000..25c0e7d --- /dev/null +++ b/src/components/atoms/links/nav-link.tsx @@ -0,0 +1,36 @@ +import Link from 'next/link'; +import { VFC, ReactNode } from 'react'; +import styles from './nav-link.module.scss'; + +export type NavLinkProps = { + /** + * Link target. + */ + href: string; + /** + * Link label. + */ + label: string; + /** + * Link logo. + */ + logo?: ReactNode; +}; + +/** + * NavLink component + * + * Render a navigation link. + */ +const NavLink: VFC<NavLinkProps> = ({ href, label, logo }) => { + return ( + <Link href={href}> + <a className={styles.link}> + {logo} + {label} + </a> + </Link> + ); +}; + +export default NavLink; diff --git a/src/components/atoms/links/sharing-link.module.scss b/src/components/atoms/links/sharing-link.module.scss new file mode 100644 index 0000000..26ca737 --- /dev/null +++ b/src/components/atoms/links/sharing-link.module.scss @@ -0,0 +1,157 @@ +@use "@styles/abstracts/functions" as fun; + +.link { + display: inline-flex; + align-items: center; + padding: var(--spacing-2xs) var(--spacing-xs); + border-radius: fun.convert-px(3); + + &:hover, + &:focus { + transform: translateX(#{fun.convert-px(-3)}) + translateY(#{fun.convert-px(-3)}); + } + + &:active { + transform: translateX(#{fun.convert-px(2)}) translateY(#{fun.convert-px(2)}); + } + + &::before { + content: ""; + display: block; + width: fun.convert-px(30); + height: fun.convert-px(30); + background-repeat: no-repeat; + filter: drop-shadow( + #{fun.convert-px(1)} #{fun.convert-px(1)} #{fun.convert-px(1)} hsl(0, 0%, 0%) + ); + } + + &--diaspora { + background: hsl(0, 0%, 13%); + box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(0, 0%, 3%); + + &:hover, + &:focus { + box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 hsl(0, 0%, 3%); + } + + &:active { + box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 hsl(0, 0%, 3%); + } + + &::before { + background-image: url(fun.encode-svg( + '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path style="fill:#ffffff;" d="M15.257 21.928l-2.33-3.255c-.622-.87-1.128-1.549-1.155-1.55-.027 0-1.007 1.317-2.317 3.115-1.248 1.713-2.28 3.115-2.292 3.115-.035 0-4.5-3.145-4.51-3.178-.006-.016 1.003-1.497 2.242-3.292 1.239-1.794 2.252-3.29 2.252-3.325 0-.056-.401-.197-3.55-1.247a1604.93 1604.93 0 01-3.593-1.2c-.033-.013.153-.635.79-2.648.46-1.446.845-2.642.857-2.656.013-.015 1.71.528 3.772 1.207 2.062.678 3.766 1.233 3.787 1.233.021 0 .045-.032.053-.07.008-.039.026-1.794.04-3.902.013-2.107.036-3.848.05-3.87.02-.03.599-.038 2.725-.038 1.485 0 2.716.01 2.735.023.023.016.064 1.175.132 3.776.112 4.273.115 4.33.183 4.33.026 0 1.66-.547 3.631-1.216 1.97-.668 3.593-1.204 3.605-1.191.04.045 1.656 5.307 1.636 5.327-.011.01-1.656.574-3.655 1.252-2.75.932-3.638 1.244-3.645 1.284-.006.029.94 1.442 2.143 3.202 1.184 1.733 2.148 3.164 2.143 3.18-.012.036-4.442 3.299-4.48 3.299-.015 0-.577-.767-1.249-1.705z"/></svg>' + )); + } + } + + &--email { + background: hsl(0, 0%, 44%); + box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(0, 0%, 34%); + + &:hover, + &:focus { + box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 hsl(0, 0%, 34%); + } + + &:active { + box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 hsl(0, 0%, 34%); + } + + &::before { + background-image: url(fun.encode-svg( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M15.909 12.123L24 17.238V6.792zM0 6.792v10.446l8.091-5.115zM22.5 3.75h-21c-.748 0-1.343.558-1.455 1.276L12 12.904l11.955-7.877c-.112-.718-.706-1.276-1.455-1.276zm-7.965 9.279l-2.123 1.398a.75.75 0 01-.825 0l-2.122-1.4-9.417 5.957c.116.712.707 1.266 1.452 1.266h21c.746 0 1.337-.553 1.452-1.266z"/></svg>' + )); + } + } + + &--facebook { + background: hsl(214, 89%, 52%); + box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(214, 89%, 42%); + + &:hover, + &:focus { + box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 + hsl(214, 89%, 42%); + } + + &:active { + box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 + hsl(214, 89%, 42%); + } + + &::before { + background-image: url(fun.encode-svg( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>' + )); + } + } + + &--journal-du-hacker { + background: hsl(210, 24%, 51%); + box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(210, 24%, 41%); + + &:hover, + &:focus { + box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 + hsl(210, 24%, 41%); + } + + &:active { + box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 + hsl(210, 24%, 41%); + } + + &::before { + background-image: url(fun.encode-svg( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M17.822 23.297a6.644 6.644 0 00-.654.032c-1.104.1-2.451-.378-3.244-1.15a3.223 3.223 0 01-.52-.739c-.209-.425-.22-.489-.211-1.178a8.174 8.174 0 01.19-1.585c.243-1.151.155-1.449-.514-1.737-.4-.172-.632-.135-1 .16-.268.215-.28.463-.07 1.532.298 1.526.286 2.238-.05 2.907-.28.56-.443.703-1.287 1.133-1.005.513-1.461.638-2.332.638-.73 0-1.014-.082-1.276-.366-.134-.145-.148-.2-.085-.32.099-.184.329-.3.959-.488.277-.082.604-.236.727-.341.123-.105.329-.265.457-.354.32-.222.562-.761.563-1.254 0-.331-.188-1.034-.45-1.676-.138-.338-.38.085-.38.666 0 .434-.673 1.569-.93 1.569-.048 0-.288.101-.532.225-.43.219-.47.225-1.31.225-.815 0-.889-.011-1.235-.194-.42-.22-.902-.694-1.094-1.073a2.752 2.752 0 00-.227-.377c-.083-.102-.08-.143.018-.293.206-.314.473-.317 1.186-.011.583.25 1.22.215 1.582-.086.168-.139.325-.697.342-1.217.02-.598-.049-.66-.596-.528-.86.206-1.762-.084-2.76-.887-.916-.739-1.362-.845-2.241-.538-.262.092-.51.153-.552.137-.042-.016-.134-.136-.204-.268-.118-.218-.12-.252-.02-.403.156-.24.714-.573 1.185-.708.297-.086.588-.11 1.076-.09.655.026.687.035 1.567.458.54.259.99.43 1.127.43.27 0 1.014-.37 1.159-.577.167-.238.124-.34-.322-.776-1.19-1.16-1.943-2.608-2.24-4.31-.124-.702-.14-1.888-.035-2.483.116-.656.677-2.273.915-2.64.385-.59 1.823-1.965 2.585-2.469C9.187.905 11.43.395 13.715.785c2.457.42 4.507 1.61 5.849 3.394 1.062 1.414 1.554 2.859 1.553 4.57 0 1.778-.497 3.238-1.599 4.693a6.207 6.207 0 00-.34.476c0 .013.205.12.456.238.737.345 1.169.844 1.726 1.994.256.527.531 1.031.613 1.12.225.247.614.42 1.099.49.588.085.804.178.9.388.109.24-.111.55-.402.563-.11.005-.394.033-.63.062-.887.107-1.851-.251-2.416-.898-.17-.193-.503-.616-.74-.939-.455-.616-.818-.922-1.054-.888-.117.017-.14.066-.127.28.008.142.068.34.133.438.09.137.127.412.161 1.196.05 1.153.147 1.458.55 1.726.306.204.552.198 1.11-.025.581-.233.923-.238 1.159-.018.243.227.2.637-.11 1.026-.33.419-1.338.899-2.001.954-1.194.1-2.371-.602-2.828-1.686-.062-.147-.197-.61-.301-1.03-.12-.486-.221-.762-.28-.762-.109 0-.263.401-.27.705-.003.12-.056.417-.118.657-.328 1.282.307 2.309 1.66 2.684.657.182.808.299.808.623 0 .319-.165.494-.454.481z"/></svg>' + )); + } + } + + &--linkedin { + background: hsl(210, 90%, 40%); + box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(210, 90%, 30%); + + &:hover, + &:focus { + box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 + hsl(210, 90%, 30%); + } + + &:active { + box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 + hsl(210, 90%, 30%); + } + + &::before { + background-image: url(fun.encode-svg( + '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path style="fill:#ffffff;" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>' + )); + } + } + + &--twitter { + background: hsl(203, 89%, 53%); + box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(203, 89%, 43%); + + &:hover, + &:focus { + box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 + hsl(203, 89%, 43%); + } + + &:active { + box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 + hsl(203, 89%, 43%); + } + + &::before { + background-image: url(fun.encode-svg( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>' + )); + } + } +} diff --git a/src/components/atoms/links/sharing-link.stories.tsx b/src/components/atoms/links/sharing-link.stories.tsx new file mode 100644 index 0000000..335fa50 --- /dev/null +++ b/src/components/atoms/links/sharing-link.stories.tsx @@ -0,0 +1,50 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import SharingLinkComponent from './sharing-link'; + +export default { + title: 'Atoms/Links', + component: SharingLinkComponent, + argTypes: { + medium: { + control: { + type: 'select', + }, + description: 'The sharing medium.', + options: [ + 'diaspora', + 'email', + 'facebook', + 'journal-du-hacker', + 'linkedin', + 'twitter', + ], + type: { + name: 'string', + required: true, + }, + }, + url: { + control: { + type: 'text', + }, + description: 'The sharing url.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof SharingLinkComponent>; + +const Template: ComponentStory<typeof SharingLinkComponent> = (args) => ( + <IntlProvider locale="en"> + <SharingLinkComponent {...args} /> + </IntlProvider> +); + +export const SharingLink = Template.bind({}); +SharingLink.args = { + medium: 'diaspora', + url: '#', +}; diff --git a/src/components/atoms/links/sharing-link.test.tsx b/src/components/atoms/links/sharing-link.test.tsx new file mode 100644 index 0000000..e4c849c --- /dev/null +++ b/src/components/atoms/links/sharing-link.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@test-utils'; +import SharingLink from './sharing-link'; + +describe('SharingLink', () => { + it('render a Diaspora sharing link', () => { + render(<SharingLink medium="diaspora" url="#" />); + expect(screen.getByRole('link', { name: 'Share on diaspora' })).toHaveClass( + 'link--diaspora' + ); + }); + + it('render an Email sharing link', () => { + render(<SharingLink medium="email" url="#" />); + expect(screen.getByRole('link', { name: 'Share on email' })).toHaveClass( + 'link--email' + ); + }); + + it('render a Facebook sharing link', () => { + render(<SharingLink medium="facebook" url="#" />); + expect(screen.getByRole('link', { name: 'Share on facebook' })).toHaveClass( + 'link--facebook' + ); + }); + + it('render a Journal du Hacker sharing link', () => { + render(<SharingLink medium="journal-du-hacker" url="#" />); + expect( + screen.getByRole('link', { name: 'Share on journal-du-hacker' }) + ).toHaveClass('link--journal-du-hacker'); + }); + + it('render a LinkedIn sharing link', () => { + render(<SharingLink medium="linkedin" url="#" />); + expect(screen.getByRole('link', { name: 'Share on linkedin' })).toHaveClass( + 'link--linkedin' + ); + }); + + it('render a Twitter sharing link', () => { + render(<SharingLink medium="twitter" url="#" />); + expect(screen.getByRole('link', { name: 'Share on twitter' })).toHaveClass( + 'link--twitter' + ); + }); +}); diff --git a/src/components/atoms/links/sharing-link.tsx b/src/components/atoms/links/sharing-link.tsx new file mode 100644 index 0000000..3cd2dd1 --- /dev/null +++ b/src/components/atoms/links/sharing-link.tsx @@ -0,0 +1,48 @@ +import { VFC } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './sharing-link.module.scss'; + +export type SharingMedium = + | 'diaspora' + | 'email' + | 'facebook' + | 'journal-du-hacker' + | 'linkedin' + | 'twitter'; + +export type SharingLinkProps = { + /** + * The sharing medium id. + */ + medium: SharingMedium; + /** + * The sharing url. + */ + url: string; +}; + +/** + * SharingLink component + * + * Render a sharing link. + */ +const SharingLink: VFC<SharingLinkProps> = ({ medium, url }) => { + const intl = useIntl(); + const text = intl.formatMessage( + { + defaultMessage: 'Share on {name}', + description: 'Sharing: share on social network text', + id: 'ureXFw', + }, + { name: medium } + ); + const mediumClass = `link--${medium}`; + + return ( + <a href={url} className={`${styles.link} ${styles[mediumClass]}`}> + <span className="screen-reader-text">{text}</span> + </a> + ); +}; + +export default SharingLink; diff --git a/src/components/atoms/links/social-link.module.scss b/src/components/atoms/links/social-link.module.scss new file mode 100644 index 0000000..02fc61c --- /dev/null +++ b/src/components/atoms/links/social-link.module.scss @@ -0,0 +1,43 @@ +@use "@styles/abstracts/functions" as fun; + +.link { + display: flex; + width: var(--link-size, #{fun.convert-px(60)}); + height: var(--link-size, #{fun.convert-px(60)}); + 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(-1) + var(--color-shadow), + fun.convert-px(3) fun.convert-px(4) fun.convert-px(4) fun.convert-px(-3) + var(--color-shadow), + 0 0 0 0 var(--color-shadow); + transition: all 0.25s linear 0s; + + &:hover, + &:focus { + box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) + var(--color-shadow), + fun.convert-px(1) fun.convert-px(1) fun.convert-px(2) fun.convert-px(-1) + var(--color-shadow-light), + fun.convert-px(3) fun.convert-px(3) fun.convert-px(4) fun.convert-px(-4) + var(--color-shadow-light), + fun.convert-px(6) fun.convert-px(6) fun.convert-px(10) fun.convert-px(-3) + var(--color-shadow); + transform: scale(1.15); + } + + &:focus { + outline: var(--color-primary) dashed fun.convert-px(2); + } + + &:active { + box-shadow: 0 0 0 0 var(--color-shadow); + outline: none; + transform: scale(0.9); + } +} + +.icon { + max-width: 100%; + max-height: 100%; +} diff --git a/src/components/atoms/links/social-link.stories.tsx b/src/components/atoms/links/social-link.stories.tsx new file mode 100644 index 0000000..bd9a364 --- /dev/null +++ b/src/components/atoms/links/social-link.stories.tsx @@ -0,0 +1,40 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import SocialLinkComponent from './social-link'; + +export default { + title: 'Atoms/Links', + component: SocialLinkComponent, + argTypes: { + name: { + control: { + type: 'select', + }, + description: 'Social website name.', + options: ['Github', 'Gitlab', 'LinkedIn', 'Twitter'], + type: { + name: 'string', + required: true, + }, + }, + url: { + control: { + type: null, + }, + description: 'Social profile url.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof SocialLinkComponent>; + +const Template: ComponentStory<typeof SocialLinkComponent> = (args) => ( + <SocialLinkComponent {...args} /> +); + +export const SocialLink = Template.bind({}); +SocialLink.args = { + name: 'Github', + url: '#', +}; diff --git a/src/components/atoms/links/social-link.test.tsx b/src/components/atoms/links/social-link.test.tsx new file mode 100644 index 0000000..f49fb5a --- /dev/null +++ b/src/components/atoms/links/social-link.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@test-utils'; +import SocialLink from './social-link'; + +/** + * Next.js mock images to use next/image component. So for now, I need to mock + * the svg files manually. + */ +jest.mock('@assets/images/social-media/github.svg', () => 'svg-file'); + +describe('SocialLink', () => { + it('render a social link', () => { + render(<SocialLink name="Github" url="#" />); + expect(screen.getByRole('link')).toHaveAccessibleName('Github'); + }); +}); diff --git a/src/components/atoms/links/social-link.tsx b/src/components/atoms/links/social-link.tsx new file mode 100644 index 0000000..8c7c790 --- /dev/null +++ b/src/components/atoms/links/social-link.tsx @@ -0,0 +1,53 @@ +import GithubIcon from '@assets/images/social-media/github.svg'; +import GitlabIcon from '@assets/images/social-media/gitlab.svg'; +import LinkedInIcon from '@assets/images/social-media/linkedin.svg'; +import TwitterIcon from '@assets/images/social-media/twitter.svg'; +import { VFC } from 'react'; +import styles from './social-link.module.scss'; + +export type SocialWebsite = 'Github' | 'Gitlab' | 'LinkedIn' | 'Twitter'; + +export type SocialLinkProps = { + /** + * The social website name. + */ + name: SocialWebsite; + /** + * The social profile url. + */ + url: string; +}; + +/** + * SocialLink component + * + * Render a social icon link. + */ +const SocialLink: VFC<SocialLinkProps> = ({ name, url }) => { + /** + * Retrieve a social link icon by id. + * @param {string} id - The social website id. + */ + const getIcon = (id: string) => { + switch (id) { + case 'Github': + return <GithubIcon className={styles.icon} aria-hidden="true" />; + case 'Gitlab': + return <GitlabIcon className={styles.icon} aria-hidden="true" />; + case 'LinkedIn': + return <LinkedInIcon className={styles.icon} aria-hidden="true" />; + case 'Twitter': + return <TwitterIcon className={styles.icon} aria-hidden="true" />; + default: + break; + } + }; + + return ( + <a href={url} className={styles.link} aria-label={name}> + {getIcon(name)} + </a> + ); +}; + +export default SocialLink; diff --git a/src/components/atoms/lists/description-list.module.scss b/src/components/atoms/lists/description-list.module.scss new file mode 100644 index 0000000..caa2711 --- /dev/null +++ b/src/components/atoms/lists/description-list.module.scss @@ -0,0 +1,54 @@ +@use "@styles/abstracts/mixins" as mix; + +.list { + display: flex; + flex-flow: column wrap; + gap: var(--spacing-2xs); + margin: 0; + + &__term { + flex: 0 0 max-content; + color: var(--color-fg-light); + font-weight: 600; + } + + &__description { + flex: 0 0 auto; + margin: 0; + } + + &__item { + display: flex; + } + + &--inline &__item { + flex-flow: column wrap; + + @include mix.media("screen") { + @include mix.dimensions("xs") { + flex-flow: row wrap; + gap: var(--spacing-2xs); + + .list__description:not(:first-of-type) { + &::before { + content: "/"; + margin-right: var(--spacing-2xs); + } + } + } + } + } + + &--column#{&}--responsive { + @include mix.media("screen") { + @include mix.dimensions("xs") { + flex-flow: row wrap; + gap: var(--spacing-lg); + } + } + } + + &--column &__item { + flex-flow: column wrap; + } +} diff --git a/src/components/atoms/lists/description-list.stories.tsx b/src/components/atoms/lists/description-list.stories.tsx new file mode 100644 index 0000000..66d94af --- /dev/null +++ b/src/components/atoms/lists/description-list.stories.tsx @@ -0,0 +1,73 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import DescriptionListComponent, { + DescriptionListItem, +} from './description-list'; + +export default { + title: 'Atoms/Lists', + component: DescriptionListComponent, + args: { + layout: 'column', + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the list wrapper', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + items: { + control: { + type: null, + }, + description: 'The list items.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + layout: { + control: { + type: 'select', + }, + description: 'The list layout.', + options: ['column', 'inline'], + table: { + category: 'Options', + defaultValue: { summary: 'column' }, + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof DescriptionListComponent>; + +const Template: ComponentStory<typeof DescriptionListComponent> = (args) => ( + <DescriptionListComponent {...args} /> +); + +const items: DescriptionListItem[] = [ + { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] }, + { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] }, + { + id: 'term-3', + term: 'Term 3:', + value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'], + }, + { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] }, +]; + +export const DescriptionList = Template.bind({}); +DescriptionList.args = { + items, +}; diff --git a/src/components/atoms/lists/description-list.test.tsx b/src/components/atoms/lists/description-list.test.tsx new file mode 100644 index 0000000..d3f7045 --- /dev/null +++ b/src/components/atoms/lists/description-list.test.tsx @@ -0,0 +1,20 @@ +import { render } from '@test-utils'; +import DescriptionList, { DescriptionListItem } from './description-list'; + +const items: DescriptionListItem[] = [ + { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] }, + { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] }, + { + id: 'term-3', + term: 'Term 3:', + value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'], + }, + { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] }, +]; + +describe('DescriptionList', () => { + it('renders a list of terms and description', () => { + const { container } = render(<DescriptionList items={items} />); + expect(container).toBeDefined(); + }); +}); diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx new file mode 100644 index 0000000..0a92465 --- /dev/null +++ b/src/components/atoms/lists/description-list.tsx @@ -0,0 +1,100 @@ +import { VFC } from 'react'; +import styles from './description-list.module.scss'; + +export type DescriptionListItem = { + /** + * The item id. + */ + id: string; + /** + * A list term. + */ + term: string; + /** + * An array of values for the list term. + */ + value: any[]; +}; + +export type DescriptionListProps = { + /** + * Set additional classnames to the list wrapper. + */ + className?: string; + /** + * Set additional classnames to the `dd` element. + */ + descriptionClassName?: string; + /** + * Set additional classnames to the `dt`/`dd` couple wrapper. + */ + groupClassName?: string; + /** + * The list items. + */ + items: DescriptionListItem[]; + /** + * The list items layout. Default: column. + */ + layout?: 'inline' | 'column'; + /** + * Define if the layout should automatically create rows/columns. + */ + responsiveLayout?: boolean; + /** + * Set additional classnames to the `dt` element. + */ + termClassName?: string; +}; + +/** + * DescriptionList component + * + * Render a description list. + */ +const DescriptionList: VFC<DescriptionListProps> = ({ + className = '', + descriptionClassName = '', + groupClassName = '', + items, + layout = 'column', + responsiveLayout = false, + termClassName = '', +}) => { + const layoutModifier = `list--${layout}`; + const responsiveModifier = responsiveLayout ? 'list--responsive' : ''; + + /** + * Retrieve the description list items wrapped in a div element. + * + * @param {DescriptionListItem[]} listItems - An array of term and description couples. + * @returns {JSX.Element[]} The description list items. + */ + const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => { + return listItems.map(({ id, term, value }) => { + return ( + <div key={id} className={`${styles.list__item} ${groupClassName}`}> + <dt className={`${styles.list__term} ${termClassName}`}>{term}</dt> + {value.map((currentValue, index) => ( + <dd + key={`${id}-${index}`} + className={`${styles.list__description} ${descriptionClassName}`} + > + {currentValue} + </dd> + ))} + </div> + ); + }); + }; + + return ( + <dl + className={`${styles.list} ${styles[layoutModifier]} ${styles[responsiveModifier]} ${className}`} + > + {getItems(items)} + </dl> + ); +}; + +export default DescriptionList; diff --git a/src/components/atoms/lists/list.module.scss b/src/components/atoms/lists/list.module.scss new file mode 100644 index 0000000..df3b49c --- /dev/null +++ b/src/components/atoms/lists/list.module.scss @@ -0,0 +1,39 @@ +.list { + margin: 0; + + ::marker { + color: var(--color-primary-dark); + } + + & & { + margin-top: var(--spacing-2xs); + } + + &--ordered { + padding: 0; + counter-reset: li; + list-style-type: none; + } + + &--ordered &__item { + display: table; + counter-increment: li; + + &::before { + content: counters(li, ".") ". "; + display: table-cell; + padding-right: var(--spacing-2xs); + color: var(--color-secondary); + } + } + + &--unordered { + padding: 0 0 0 var(--spacing-sm); + } + + &--has-margin &__item { + &:not(:last-child) { + margin-bottom: var(--spacing-2xs); + } + } +} diff --git a/src/components/atoms/lists/list.stories.tsx b/src/components/atoms/lists/list.stories.tsx new file mode 100644 index 0000000..30079cb --- /dev/null +++ b/src/components/atoms/lists/list.stories.tsx @@ -0,0 +1,80 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ListComponent, { type ListItem } from './list'; + +export default { + title: 'Atoms/Lists', + component: ListComponent, + args: { + kind: 'unordered', + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the list wrapper', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + items: { + control: { + type: null, + }, + description: 'The list items.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'The list kind: ordered or unordered.', + options: ['ordered', 'unordered'], + table: { + category: 'Options', + defaultValue: { summary: 'unordered' }, + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof ListComponent>; + +const Template: ComponentStory<typeof ListComponent> = (args) => ( + <ListComponent {...args} /> +); + +const items: ListItem[] = [ + { id: 'item-1', value: 'Item 1' }, + { id: 'item-2', value: 'Item 2' }, + { + child: [ + { id: 'nested-item-1', value: 'Nested item 1' }, + { id: 'nested-item-2', value: 'Nested item 2' }, + ], + id: 'item-3', + value: 'Item 3', + }, + { id: 'item-4', value: 'Item 4' }, +]; + +export const Unordered = Template.bind({}); +Unordered.args = { + items, +}; + +export const Ordered = Template.bind({}); +Ordered.args = { + items, + kind: 'ordered', +}; diff --git a/src/components/atoms/lists/list.test.tsx b/src/components/atoms/lists/list.test.tsx new file mode 100644 index 0000000..fcf8813 --- /dev/null +++ b/src/components/atoms/lists/list.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@test-utils'; +import List, { type ListItem } from './list'; + +const items: ListItem[] = [ + { id: 'item-1', value: 'Item 1' }, + { id: 'item-2', value: 'Item 2' }, + { + child: [ + { id: 'nested-item-1', value: 'Nested item 1' }, + { id: 'nested-item-2', value: 'Nested item 2' }, + ], + id: 'item-3', + value: 'Item 3', + }, + { id: 'item-4', value: 'Item 4' }, +]; + +describe('List', () => { + it('renders a nested unordered list', () => { + render(<List items={items} />); + const listItems = screen.getAllByRole('list'); + listItems.forEach((listItem) => + expect(listItem).toHaveClass('list--unordered') + ); + }); +}); diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx new file mode 100644 index 0000000..d100a31 --- /dev/null +++ b/src/components/atoms/lists/list.tsx @@ -0,0 +1,87 @@ +import { VFC } from 'react'; +import styles from './list.module.scss'; + +export type ListItem = { + /** + * Nested list. + */ + child?: ListItem[]; + /** + * Item id. + */ + id: string; + /** + * Item value. + */ + value: any; +}; + +export type ListProps = { + /** + * Set additional classnames to the list wrapper. + */ + className?: string; + /** + * An array of list items. + */ + items: ListItem[]; + /** + * Set additional classnames to the list items. + */ + itemsClassName?: string; + /** + * The list kind (ordered or unordered). + */ + kind?: 'ordered' | 'unordered'; + /** + * Set margin between list items. Default: true. + */ + withMargin?: boolean; +}; + +/** + * List component + * + * Render either an ordered or an unordered list. + */ +const List: VFC<ListProps> = ({ + className = '', + items, + itemsClassName = '', + kind = 'unordered', + withMargin = true, +}) => { + const ListTag = kind === 'ordered' ? 'ol' : 'ul'; + const kindClass = `list--${kind}`; + const marginClass = withMargin ? 'list--has-margin' : 'list--no-margin'; + + /** + * Retrieve the list items. + * @param array - An array of items. + * @returns {JSX.Element[]} - An array of li elements. + */ + const getItems = (array: ListItem[]): JSX.Element[] => { + return array.map(({ child, id, value }) => ( + <li key={id} className={`${styles.list__item} ${itemsClassName}`}> + {value} + {child && ( + <ListTag + className={`${styles.list} ${styles[kindClass]} ${styles[marginClass]} ${className}`} + > + {getItems(child)} + </ListTag> + )} + </li> + )); + }; + + return ( + <ListTag + className={`${styles.list} ${styles[kindClass]} ${styles[marginClass]} ${className}`} + > + {getItems(items)} + </ListTag> + ); +}; + +export default List; diff --git a/src/components/atoms/loaders/progress-bar.module.scss b/src/components/atoms/loaders/progress-bar.module.scss new file mode 100644 index 0000000..166b7c4 --- /dev/null +++ b/src/components/atoms/loaders/progress-bar.module.scss @@ -0,0 +1,43 @@ +@use "@styles/abstracts/functions" as fun; + +.progress { + width: max-content; + margin: var(--spacing-sm) auto var(--spacing-md); + text-align: center; + + &__info { + margin-bottom: var(--spacing-2xs); + font-size: var(--font-size-sm); + } + + &__bar[value] { + display: block; + width: clamp(25ch, 20vw, 30ch); + max-width: 100%; + height: fun.convert-px(13); + appearance: none; + background: var(--color-bg-tertiary); + border: fun.convert-px(1) solid var(--color-primary-darker); + border-radius: 1em; + box-shadow: inset 0 0 fun.convert-px(4) fun.convert-px(1) + var(--color-shadow-light); + + &::-webkit-progress-value { + background-color: var(--color-primary-dark); + border-radius: 1em; + } + + &::-moz-progress-bar { + background-color: var(--color-primary-dark); + border-radius: 1em; + } + + &::-webkit-progress-bar { + background: var(--color-bg-tertiary); + border: fun.convert-px(1) solid var(--color-primary-darker); + border-radius: 1em; + box-shadow: inset 0 0 fun.convert-px(4) fun.convert-px(1) + var(--color-shadow-light); + } + } +} diff --git a/src/components/atoms/loaders/progress-bar.stories.tsx b/src/components/atoms/loaders/progress-bar.stories.tsx new file mode 100644 index 0000000..4fde5a7 --- /dev/null +++ b/src/components/atoms/loaders/progress-bar.stories.tsx @@ -0,0 +1,76 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ProgressBarComponent from './progress-bar'; + +export default { + title: 'Atoms/Loaders', + component: ProgressBarComponent, + argTypes: { + 'aria-label': { + control: { + type: 'string', + }, + description: 'An accessible name.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + current: { + control: { + type: 'number', + }, + description: 'The current value.', + type: { + name: 'number', + required: true, + }, + }, + info: { + control: { + type: 'text', + }, + description: 'An additional information to display.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + max: { + control: { + type: 'number', + }, + description: 'The maximal value.', + type: { + name: 'number', + required: true, + }, + }, + min: { + control: { + type: 'number', + }, + description: 'The minimal value.', + type: { + name: 'number', + required: true, + }, + }, + }, +} as ComponentMeta<typeof ProgressBarComponent>; + +const Template: ComponentStory<typeof ProgressBarComponent> = (args) => ( + <ProgressBarComponent {...args} /> +); + +export const ProgressBar = Template.bind({}); +ProgressBar.args = { + current: 10, + min: 0, + max: 50, +}; diff --git a/src/components/atoms/loaders/progress-bar.test.tsx b/src/components/atoms/loaders/progress-bar.test.tsx new file mode 100644 index 0000000..37a7364 --- /dev/null +++ b/src/components/atoms/loaders/progress-bar.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import ProgressBar from './progress-bar'; + +describe('ProgressBar', () => { + it('renders a progress bar', () => { + render(<ProgressBar min={0} max={50} current={10} />); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/loaders/progress-bar.tsx b/src/components/atoms/loaders/progress-bar.tsx new file mode 100644 index 0000000..1b1ff06 --- /dev/null +++ b/src/components/atoms/loaders/progress-bar.tsx @@ -0,0 +1,55 @@ +import { VFC } from 'react'; +import styles from './progress-bar.module.scss'; + +export type ProgressBarProps = { + /** + * Accessible progress bar name. + */ + 'aria-label'?: string; + /** + * Current value. + */ + current: number; + /** + * Additional information to display before progress bar. + */ + info?: string; + /** + * Minimal value. + */ + min: number; + /** + * Maximal value. + */ + max: number; +}; + +/** + * ProgressBar component + * + * Render a progress bar. + */ +const ProgressBar: VFC<ProgressBarProps> = ({ + current, + info, + min, + max, + ...props +}) => { + return ( + <div className={styles.progress}> + {info && <div className={styles.progress__info}>{info}</div>} + <progress + className={styles.progress__bar} + max={max} + value={current} + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={current} + {...props} + ></progress> + </div> + ); +}; + +export default ProgressBar; diff --git a/src/components/atoms/loaders/spinner.module.scss b/src/components/atoms/loaders/spinner.module.scss new file mode 100644 index 0000000..8d818a2 --- /dev/null +++ b/src/components/atoms/loaders/spinner.module.scss @@ -0,0 +1,48 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: center; + gap: var(--spacing-2xs); + margin: var(--spacing-md) 0; +} + +.ball { + width: fun.convert-px(8); + height: fun.convert-px(8); + background: linear-gradient( + to right, + var(--color-primary-light) 0%, + var(--color-primary-lighter) 100% + ); + border-radius: 50%; + animation: spinner 1.4s infinite ease-in-out both; + + &:first-child { + animation-delay: -0.32s; + } + + &:nth-child(2) { + animation-delay: -0.16s; + } +} + +.text { + margin-left: var(--spacing-xs); + color: var(--color-primary-darker); + text-align: center; +} + +@keyframes spinner { + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} diff --git a/src/components/atoms/loaders/spinner.stories.tsx b/src/components/atoms/loaders/spinner.stories.tsx new file mode 100644 index 0000000..5006ce4 --- /dev/null +++ b/src/components/atoms/loaders/spinner.stories.tsx @@ -0,0 +1,31 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import SpinnerComponent from './spinner'; + +export default { + title: 'Atoms/Loaders', + component: SpinnerComponent, + argTypes: { + message: { + control: { + type: 'text', + }, + description: 'Loading message.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof SpinnerComponent>; + +const Template: ComponentStory<typeof SpinnerComponent> = (args) => ( + <IntlProvider locale="en"> + <SpinnerComponent {...args} /> + </IntlProvider> +); + +export const Spinner = Template.bind({}); diff --git a/src/components/atoms/loaders/spinner.test.tsx b/src/components/atoms/loaders/spinner.test.tsx new file mode 100644 index 0000000..0a6db91 --- /dev/null +++ b/src/components/atoms/loaders/spinner.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@test-utils'; +import Spinner from './spinner'; + +describe('Spinner', () => { + it('renders a spinner loader', () => { + render(<Spinner />); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('renders a spinner loader with a custom message', () => { + render(<Spinner message="Submitting" />); + expect(screen.getByText('Submitting')).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/loaders/spinner.tsx b/src/components/atoms/loaders/spinner.tsx new file mode 100644 index 0000000..bff0f25 --- /dev/null +++ b/src/components/atoms/loaders/spinner.tsx @@ -0,0 +1,37 @@ +import { VFC } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './spinner.module.scss'; + +export type SpinnerProps = { + /** + * The loading message. Default: "Loading...". + */ + message?: string; +}; + +/** + * Spinner component + * + * Render a loading message with animation. + */ +const Spinner: VFC<SpinnerProps> = ({ message }) => { + const intl = useIntl(); + + return ( + <div className={styles.wrapper}> + <div className={styles.ball}></div> + <div className={styles.ball}></div> + <div className={styles.ball}></div> + <div className={styles.text}> + {message || + intl.formatMessage({ + defaultMessage: 'Loading...', + description: 'Spinner: loading text', + id: 'q9cJQe', + })} + </div> + </div> + ); +}; + +export default Spinner; |
