diff options
Diffstat (limited to 'src/components/molecules/layout')
19 files changed, 1169 insertions, 0 deletions
diff --git a/src/components/molecules/layout/branding.module.scss b/src/components/molecules/layout/branding.module.scss new file mode 100644 index 0000000..aa18002 --- /dev/null +++ b/src/components/molecules/layout/branding.module.scss @@ -0,0 +1,48 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { + display: grid; + grid-template-columns: + var(--logo-size, fun.convert-px(100)) + minmax(0, 1fr); + grid-template-rows: 1fr min-content; + align-items: center; + column-gap: var(--spacing-sm); +} + +.logo { + grid-row: span 2; +} + +.title { + font-size: var(--font-size-2xl); +} + +.baseline { + color: var(--color-fg-light); +} + +.link { + background: linear-gradient( + to top, + var(--color-primary-light) fun.convert-px(5), + transparent fun.convert-px(5) + ) + left / 0 100% no-repeat; + text-decoration: none; + transition: all 0.6s ease-out 0s; + + &:hover, + &:focus { + background-size: 100% 100%; + } + + &:focus { + color: var(--color-primary-light); + } + + &:active { + background-size: 0 100%; + color: var(--color-primary-dark); + } +} diff --git a/src/components/molecules/layout/branding.stories.tsx b/src/components/molecules/layout/branding.stories.tsx new file mode 100644 index 0000000..726ba26 --- /dev/null +++ b/src/components/molecules/layout/branding.stories.tsx @@ -0,0 +1,83 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import BrandingComponent from './branding'; + +export default { + title: 'Molecules/Layout', + component: BrandingComponent, + args: { + isHome: false, + }, + argTypes: { + baseline: { + control: { + type: 'text', + }, + description: 'The Branding baseline.', + type: { + name: 'string', + required: false, + }, + }, + isHome: { + control: { + type: 'boolean', + }, + description: 'Use H1 if the current page is homepage.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + photo: { + control: { + type: 'text', + }, + description: 'The Branding photo.', + type: { + name: 'string', + required: true, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The Branding title.', + type: { + name: 'string', + required: true, + }, + }, + withLink: { + control: { + type: 'boolean', + }, + description: 'Wraps the title with a link to homepage.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + }, +} as ComponentMeta<typeof BrandingComponent>; + +const Template: ComponentStory<typeof BrandingComponent> = (args) => ( + <IntlProvider locale="en"> + <BrandingComponent {...args} /> + </IntlProvider> +); + +export const Branding = Template.bind({}); +Branding.args = { + title: 'Website title', + photo: 'http://placeimg.com/640/480', +}; diff --git a/src/components/molecules/layout/branding.test.tsx b/src/components/molecules/layout/branding.test.tsx new file mode 100644 index 0000000..4fe1e9a --- /dev/null +++ b/src/components/molecules/layout/branding.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@test-utils'; +import Branding from './branding'; + +describe('Branding', () => { + it('renders a photo', () => { + render( + <Branding + photo="http://placeimg.com/640/480/city" + title="Website title" + /> + ); + expect( + screen.getByRole('img', { name: 'Website title picture' }) + ).toBeInTheDocument(); + }); + + it('renders a logo', () => { + render( + <Branding photo="http://placeimg.com/640/480/city" title="Website name" /> + ); + expect(screen.getByTitle('Website name logo')).toBeInTheDocument(); + }); + + it('renders a baseline', () => { + render( + <Branding + photo="http://placeimg.com/640/480" + title="Website title" + baseline="Website baseline" + /> + ); + expect(screen.getByText('Website baseline')).toBeInTheDocument(); + }); + + it('renders a title wrapped with h1 element', () => { + render( + <Branding + photo="http://placeimg.com/640/480" + title="Website title" + isHome={true} + /> + ); + expect( + screen.getByRole('heading', { level: 1, name: 'Website title' }) + ).toBeInTheDocument(); + }); + + it('renders a title with h1 styles', () => { + render( + <Branding + photo="http://placeimg.com/640/480" + title="Website title" + isHome={false} + /> + ); + expect( + screen.queryByRole('heading', { level: 1, name: 'Website title' }) + ).not.toBeInTheDocument(); + expect(screen.getByText('Website title')).toHaveClass('heading--1'); + }); +}); diff --git a/src/components/molecules/layout/branding.tsx b/src/components/molecules/layout/branding.tsx new file mode 100644 index 0000000..9f564bf --- /dev/null +++ b/src/components/molecules/layout/branding.tsx @@ -0,0 +1,97 @@ +import Heading from '@components/atoms/headings/heading'; +import Link from 'next/link'; +import { VFC } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './branding.module.scss'; +import FlippingLogo from './flipping-logo'; + +type BrandingProps = { + /** + * The Branding baseline. + */ + baseline?: string; + /** + * Use H1 if the current page is homepage. Default: false. + */ + isHome?: boolean; + /** + * A photography URL. + */ + photo: string; + /** + * The Branding title; + */ + title: string; + /** + * Wraps the title with a link to homepage. Default: false. + */ + withLink?: boolean; +}; + +/** + * Branding component + * + * Render the branding logo, title and optional baseline. + */ +const Branding: VFC<BrandingProps> = ({ + baseline, + isHome = false, + photo, + title, + withLink = false, +}) => { + const intl = useIntl(); + const altText = intl.formatMessage( + { + defaultMessage: '{website} picture', + description: 'Branding: photo alternative text', + id: 'dDK5oc', + }, + { website: title } + ); + const logoTitle = intl.formatMessage( + { + defaultMessage: '{website} logo', + description: 'Branding: logo title', + id: 'x55qsD', + }, + { website: title } + ); + + return ( + <div className={styles.wrapper}> + <FlippingLogo + className={styles.logo} + altText={altText} + logoTitle={logoTitle} + photo={photo} + /> + <Heading + isFake={!isHome} + level={1} + withMargin={false} + className={styles.title} + > + {withLink ? ( + <Link href="/"> + <a className={styles.link}>{title}</a> + </Link> + ) : ( + title + )} + </Heading> + {baseline && ( + <Heading + isFake={true} + level={4} + withMargin={false} + className={styles.baseline} + > + {baseline} + </Heading> + )} + </div> + ); +}; + +export default Branding; diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss new file mode 100644 index 0000000..2b1b7dc --- /dev/null +++ b/src/components/molecules/layout/card.module.scss @@ -0,0 +1,77 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { + --scale-up: 1.05; + --scale-down: 0.95; + + display: flex; + flex-flow: column wrap; + max-width: var(--card-width, 40ch); + padding: 0; + text-align: center; + + .article { + flex: 1; + display: flex; + flex-flow: column nowrap; + justify-content: flex-start; + } + + .footer { + margin-top: var(--spacing-md); + } + + .cover { + align-self: flex-start; + max-height: fun.convert-px(150); + margin: auto; + border-bottom: fun.convert-px(1) solid var(--color-border); + } + + .title, + .tagline, + .footer { + padding: 0 var(--spacing-md); + } + + .title { + flex: 1; + margin: var(--spacing-sm) 0; + } + + h2.title { + background: none; + text-shadow: none; + } + + .tagline { + flex: 1; + color: var(--color-fg); + font-weight: 400; + } + + .list { + margin-bottom: var(--spacing-md); + } + + .items { + flex-flow: row wrap; + place-content: center; + gap: var(--spacing-2xs); + } + + .term { + flex: 0 0 100%; + } + + .description { + padding: fun.convert-px(2) var(--spacing-xs); + border: fun.convert-px(1) solid var(--color-primary-darker); + color: var(--color-fg); + font-weight: 400; + + &::before { + display: none; + } + } +} diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx new file mode 100644 index 0000000..a07f8dc --- /dev/null +++ b/src/components/molecules/layout/card.stories.tsx @@ -0,0 +1,102 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import CardComponent from './card'; + +export default { + title: 'Molecules/Layout', + component: CardComponent, + argTypes: { + cover: { + description: 'The card cover data (src, dimensions, alternative text).', + table: { + category: 'Options', + }, + type: { + name: 'object', + required: false, + value: {}, + }, + }, + meta: { + description: 'The card metadata (a publication date for example).', + table: { + category: 'Options', + }, + type: { + name: 'object', + required: false, + value: {}, + }, + }, + tagline: { + control: { + type: 'text', + }, + description: 'A few words about the card.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The card title.', + type: { + name: 'string', + required: true, + }, + }, + titleLevel: { + control: { + type: 'number', + }, + description: 'The title level.', + type: { + name: 'number', + required: true, + }, + }, + url: { + control: { + type: 'text', + }, + description: 'The card target.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof CardComponent>; + +const Template: ComponentStory<typeof CardComponent> = (args) => ( + <CardComponent {...args} /> +); + +const cover = { + alt: 'A picture', + height: 480, + src: 'http://placeimg.com/640/480', + width: 640, +}; + +const meta = [ + { + id: 'an-id', + term: 'Voluptates', + value: ['Autem', 'Eos'], + }, +]; + +export const Card = Template.bind({}); +Card.args = { + cover, + meta, + title: 'Veritatis dicta quod', + titleLevel: 2, + url: '#', +}; diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx new file mode 100644 index 0000000..404bc7a --- /dev/null +++ b/src/components/molecules/layout/card.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@test-utils'; +import Card from './card'; + +const cover = { + alt: 'A picture', + height: 480, + src: 'http://placeimg.com/640/480', + width: 640, +}; + +const meta = [ + { + id: 'an-id', + term: 'Voluptates', + value: ['Autem', 'Eos'], + }, +]; + +const tagline = 'Ut rerum incidunt'; + +const title = 'Alias qui porro'; + +const url = '/an-existing-url'; + +describe('Card', () => { + it('renders a title wrapped in h2 element', () => { + render(<Card title={title} titleLevel={2} url={url} />); + expect( + screen.getByRole('heading', { level: 2, name: title }) + ).toBeInTheDocument(); + }); + + it('renders a link to another page', () => { + render(<Card title={title} titleLevel={2} url={url} />); + expect(screen.getByRole('link')).toHaveAttribute('href', url); + }); + + it('renders a cover', () => { + render(<Card title={title} titleLevel={2} url={url} cover={cover} />); + expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); + }); + + it('renders a tagline', () => { + render(<Card title={title} titleLevel={2} url={url} tagline={tagline} />); + expect(screen.getByText(tagline)).toBeInTheDocument(); + }); + + it('renders some meta', () => { + render(<Card title={title} titleLevel={2} url={url} meta={meta} />); + expect(screen.getByText(meta[0].term)).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx new file mode 100644 index 0000000..23a0e54 --- /dev/null +++ b/src/components/molecules/layout/card.tsx @@ -0,0 +1,114 @@ +import ButtonLink from '@components/atoms/buttons/button-link'; +import Heading, { type HeadingLevel } from '@components/atoms/headings/heading'; +import DescriptionList, { + DescriptionListItem, +} from '@components/atoms/lists/description-list'; +import { VFC } from 'react'; +import ResponsiveImage, { + ResponsiveImageProps, +} from '../images/responsive-image'; +import styles from './card.module.scss'; + +export type Cover = { + /** + * The cover alternative text. + */ + alt: string; + /** + * The cover height. + */ + height: number; + /** + * The cover source. + */ + src: string; + /** + * The cover width. + */ + width: number; +}; + +export type CardProps = { + /** + * Set additional classnames to the card wrapper. + */ + className?: string; + /** + * The card cover. + */ + cover?: Cover; + /** + * The cover fit. Default: cover. + */ + coverFit?: ResponsiveImageProps['objectFit']; + /** + * The card meta. + */ + meta?: DescriptionListItem[]; + /** + * The card tagline. + */ + tagline?: string; + /** + * The card title. + */ + title: string; + /** + * The title level (hn). + */ + titleLevel: HeadingLevel; + /** + * The card target. + */ + url: string; +}; + +/** + * Card component + * + * Render a link with minimal information about its content. + */ +const Card: VFC<CardProps> = ({ + className = '', + cover, + coverFit = 'cover', + meta, + tagline, + title, + titleLevel, + url, +}) => { + return ( + <ButtonLink target={url} className={`${styles.wrapper} ${className}`}> + <article className={styles.article}> + <header className={styles.header}> + {cover && ( + <ResponsiveImage + {...cover} + objectFit={coverFit} + className={styles.cover} + /> + )} + <Heading level={titleLevel} className={styles.title}> + {title} + </Heading> + </header> + <div className={styles.tagline}>{tagline}</div> + {meta && ( + <footer className={styles.footer}> + <DescriptionList + items={meta} + layout="inline" + className={styles.list} + groupClassName={styles.items} + termClassName={styles.term} + descriptionClassName={styles.description} + /> + </footer> + )} + </article> + </ButtonLink> + ); +}; + +export default Card; diff --git a/src/components/molecules/layout/flipping-logo.module.scss b/src/components/molecules/layout/flipping-logo.module.scss new file mode 100644 index 0000000..89b9499 --- /dev/null +++ b/src/components/molecules/layout/flipping-logo.module.scss @@ -0,0 +1,59 @@ +@use "@styles/abstracts/functions" as fun; + +.logo { + width: var(--logo-size, fun.convert-px(100)); + height: var(--logo-size, fun.convert-px(100)); + position: relative; + border-radius: 50%; + transform-style: preserve-3d; + transition: all 0.6s linear 0s; + + &__front, + &__back { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + backface-visibility: hidden; + background: var(--color-bg); + border: fun.convert-px(2) solid var(--color-primary-dark); + border-radius: 50%; + transition: all 0.6s linear 0s; + + svg, + img { + // !important is required to override next/image styles... + padding: fun.convert-px(2) !important; + border-radius: 50%; + } + } + + &__front { + box-shadow: fun.convert-px(1) fun.convert-px(2) fun.convert-px(1) 0 + var(--color-shadow-light), + fun.convert-px(2) fun.convert-px(3) fun.convert-px(3) 0 + var(--color-shadow-light); + } + + &__back { + transform: rotateY(180deg); + } + + &:hover { + transform: rotateY(180deg); + } + + &:hover & { + &__front { + box-shadow: none; + } + + &__back { + box-shadow: fun.convert-px(1) fun.convert-px(2) fun.convert-px(1) 0 + var(--color-shadow-light), + fun.convert-px(2) fun.convert-px(3) fun.convert-px(3) 0 + var(--color-shadow-light); + } + } +} diff --git a/src/components/molecules/layout/flipping-logo.stories.tsx b/src/components/molecules/layout/flipping-logo.stories.tsx new file mode 100644 index 0000000..1ac8de8 --- /dev/null +++ b/src/components/molecules/layout/flipping-logo.stories.tsx @@ -0,0 +1,66 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import FlippingLogoComponent from './flipping-logo'; + +export default { + title: 'Molecules/Layout', + component: FlippingLogoComponent, + argTypes: { + altText: { + control: { + type: 'text', + }, + description: 'Photo alternative text.', + type: { + name: 'string', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the logo wrapper.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + logoTitle: { + control: { + type: 'text', + }, + description: 'An accessible name for the logo.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + photo: { + control: { + type: 'text', + }, + description: 'Photo url.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof FlippingLogoComponent>; + +const Template: ComponentStory<typeof FlippingLogoComponent> = (args) => ( + <FlippingLogoComponent {...args} /> +); + +export const FlippingLogo = Template.bind({}); +FlippingLogo.args = { + altText: 'Website picture', + logoTitle: 'Website logo', + photo: 'http://placeimg.com/640/480', +}; diff --git a/src/components/molecules/layout/flipping-logo.test.tsx b/src/components/molecules/layout/flipping-logo.test.tsx new file mode 100644 index 0000000..806fdbe --- /dev/null +++ b/src/components/molecules/layout/flipping-logo.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@test-utils'; +import FlippingLogo from './flipping-logo'; + +describe('FlippingLogo', () => { + it('renders a photo', () => { + render( + <FlippingLogo + altText="Alternative text" + photo="http://placeimg.com/640/480" + /> + ); + expect(screen.getByAltText('Alternative text')).toBeInTheDocument(); + }); + + it('renders a logo', () => { + render( + <FlippingLogo + altText="Alternative text" + logoTitle="A logo title" + photo="http://placeimg.com/640/480" + /> + ); + expect(screen.getByTitle('A logo title')).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/layout/flipping-logo.tsx b/src/components/molecules/layout/flipping-logo.tsx new file mode 100644 index 0000000..6f7645f --- /dev/null +++ b/src/components/molecules/layout/flipping-logo.tsx @@ -0,0 +1,48 @@ +import Logo from '@components/atoms/images/logo'; +import Image from 'next/image'; +import { VFC } from 'react'; +import styles from './flipping-logo.module.scss'; + +type FlippingLogoProps = { + /** + * Set additional classnames to the logo wrapper. + */ + className?: string; + /** + * Photo alternative text. + */ + altText: string; + /** + * Logo image title. + */ + logoTitle?: string; + /** + * Photo url. + */ + photo: string; +}; + +/** + * FlippingLogo component + * + * Render a logo and a photo with a flipping effect. + */ +const FlippingLogo: VFC<FlippingLogoProps> = ({ + className = '', + altText, + logoTitle, + photo, +}) => { + return ( + <div className={`${styles.logo} ${className}`}> + <div className={styles.logo__front}> + <Image src={photo} alt={altText} layout="fill" objectFit="cover" /> + </div> + <div className={styles.logo__back}> + <Logo title={logoTitle} /> + </div> + </div> + ); +}; + +export default FlippingLogo; diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx new file mode 100644 index 0000000..e7a932d --- /dev/null +++ b/src/components/molecules/layout/meta.stories.tsx @@ -0,0 +1,57 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import MetaComponent from './meta'; + +export default { + title: 'Molecules/Layout', + component: MetaComponent, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the meta wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + meta: { + control: { + type: null, + }, + description: 'The page metadata.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }, +} as ComponentMeta<typeof MetaComponent>; + +const Template: ComponentStory<typeof MetaComponent> = (args) => ( + <MetaComponent {...args} /> +); + +const data = { + publication: { name: 'Published on:', value: 'April 9th 2022' }, + categories: { + name: 'Categories:', + value: [ + <a key="category1" href="#"> + Category 1 + </a>, + <a key="category2" href="#"> + Category 2 + </a>, + ], + }, +}; + +export const Meta = Template.bind({}); +Meta.args = { + data, +}; diff --git a/src/components/molecules/layout/meta.test.tsx b/src/components/molecules/layout/meta.test.tsx new file mode 100644 index 0000000..a738bdb --- /dev/null +++ b/src/components/molecules/layout/meta.test.tsx @@ -0,0 +1,8 @@ +import { render } from '@test-utils'; +import Meta from './meta'; + +describe('Meta', () => { + it('renders a Meta component', () => { + render(<Meta data={{}} />); + }); +}); diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx new file mode 100644 index 0000000..218ebd9 --- /dev/null +++ b/src/components/molecules/layout/meta.tsx @@ -0,0 +1,74 @@ +import DescriptionList, { + type DescriptionListProps, + type DescriptionListItem, +} from '@components/atoms/lists/description-list'; +import { ReactNode, VFC } from 'react'; + +export type MetaItem = { + /** + * The meta name. + */ + name: string; + /** + * The meta value. + */ + value: ReactNode | ReactNode[]; +}; + +export type MetaMap = { + [key: string]: MetaItem | undefined; +}; + +export type MetaProps = { + /** + * Set additional classnames to the meta wrapper. + */ + className?: string; + /** + * The meta data. + */ + data: MetaMap; + /** + * The meta layout. + */ + layout?: DescriptionListProps['layout']; + /** + * Determine if the layout should be responsive. + */ + responsiveLayout?: DescriptionListProps['responsiveLayout']; +}; + +/** + * Meta component + * + * Renders the page metadata. + */ +const Meta: VFC<MetaProps> = ({ data, ...props }) => { + /** + * Transform the metadata to description list item format. + * + * @param {MetaMap} items - The meta. + * @returns {DescriptionListItem[]} The formatted description list items. + */ + const getItems = (items: MetaMap): DescriptionListItem[] => { + const listItems: DescriptionListItem[] = Object.entries(items) + .map(([key, item]) => { + if (!item) return; + + const { name, value } = item; + + return { + id: key, + term: name, + value: Array.isArray(value) ? value : [value], + } as DescriptionListItem; + }) + .filter((item): item is DescriptionListItem => !!item); + + return listItems; + }; + + return <DescriptionList items={getItems(data)} {...props} />; +}; + +export default Meta; diff --git a/src/components/molecules/layout/widget.module.scss b/src/components/molecules/layout/widget.module.scss new file mode 100644 index 0000000..727ffb7 --- /dev/null +++ b/src/components/molecules/layout/widget.module.scss @@ -0,0 +1,40 @@ +@use "@styles/abstracts/functions" as fun; + +.widget { + display: flex; + flex-flow: column; + + &__header { + background: var(--color-bg); + } + + &--has-borders & { + &__body { + border: fun.convert-px(2) solid var(--color-primary-dark); + } + } + + &--collapsed & { + &__body { + max-height: 0; + margin: 0; + visibility: hidden; + opacity: 0; + overflow: hidden; + border: 0 solid transparent; + transition: all 0.1s linear 0.3s, + max-height 0.5s cubic-bezier(0, 1, 0, 1) 0s, margin 0.3s ease-in-out 0s; + } + } + + &--expanded & { + &__body { + max-height: 10000px; // needs a fixed value for transition. + margin: var(--spacing-sm) 0; + opacity: 1; + visibility: visible; + transition: all 0.5s ease-in-out 0s, border 0s linear 0s, + max-height 0.6s ease-in-out 0s; + } + } +} diff --git a/src/components/molecules/layout/widget.stories.tsx b/src/components/molecules/layout/widget.stories.tsx new file mode 100644 index 0000000..d79f66e --- /dev/null +++ b/src/components/molecules/layout/widget.stories.tsx @@ -0,0 +1,85 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import WidgetComponent from './widget'; + +export default { + title: 'Molecules/Layout', + component: WidgetComponent, + args: { + expanded: true, + withBorders: false, + }, + argTypes: { + children: { + control: { + type: 'text', + }, + description: 'The widget body', + type: { + name: 'string', + required: true, + }, + }, + expanded: { + control: { + type: 'boolean', + }, + description: 'The widget state (expanded or collapsed)', + table: { + category: 'Options', + defaultValue: { summary: true }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + level: { + control: { + type: 'number', + }, + description: 'The heading level.', + type: { + name: 'number', + required: true, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The widget title.', + type: { + name: 'string', + required: true, + }, + }, + withBorders: { + control: { + type: 'boolean', + }, + description: 'Define if the content should have borders.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + }, +} as ComponentMeta<typeof WidgetComponent>; + +const Template: ComponentStory<typeof WidgetComponent> = (args) => ( + <IntlProvider locale="en"> + <WidgetComponent {...args} /> + </IntlProvider> +); + +export const Widget = Template.bind({}); +Widget.args = { + children: 'Widget body', + level: 2, + title: 'Widget title', +}; diff --git a/src/components/molecules/layout/widget.test.tsx b/src/components/molecules/layout/widget.test.tsx new file mode 100644 index 0000000..af561ea --- /dev/null +++ b/src/components/molecules/layout/widget.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@test-utils'; +import Widget from './widget'; + +const children = 'Widget body'; +const title = 'Widget title'; +const titleLevel = 2; + +describe('Widget', () => { + it('renders the widget title', () => { + render( + <Widget expanded={true} title={title} level={titleLevel}> + {children} + </Widget> + ); + expect( + screen.getByRole('heading', { level: titleLevel }) + ).toHaveTextContent(title); + }); +}); diff --git a/src/components/molecules/layout/widget.tsx b/src/components/molecules/layout/widget.tsx new file mode 100644 index 0000000..c04362a --- /dev/null +++ b/src/components/molecules/layout/widget.tsx @@ -0,0 +1,54 @@ +import { FC, useState } from 'react'; +import HeadingButton, { HeadingButtonProps } from '../buttons/heading-button'; +import styles from './widget.module.scss'; + +export type WidgetProps = Pick< + HeadingButtonProps, + 'expanded' | 'level' | 'title' +> & { + /** + * Set additional classnames to the widget wrapper. + */ + className?: string; + /** + * Determine if the widget body should have borders. Default: false. + */ + withBorders?: boolean; +}; + +/** + * Widget component + * + * Render an expandable widget. + */ +const Widget: FC<WidgetProps> = ({ + children, + className = '', + expanded = true, + level, + title, + withBorders = false, +}) => { + const [isExpanded, setIsExpanded] = useState<boolean>(expanded); + const stateClass = isExpanded ? 'widget--expanded' : 'widget--collapsed'; + const bordersClass = withBorders + ? 'widget--has-borders' + : 'widget--no-borders'; + + return ( + <div + className={`${styles.widget} ${styles[bordersClass]} ${styles[stateClass]} ${className}`} + > + <HeadingButton + level={level} + title={title} + expanded={isExpanded} + setExpanded={setIsExpanded} + className={styles.widget__header} + /> + <div className={styles.widget__body}>{children}</div> + </div> + ); +}; + +export default Widget; |
