diff options
Diffstat (limited to 'src/components/atoms/layout')
19 files changed, 655 insertions, 0 deletions
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; |
