diff options
Diffstat (limited to 'src/components/molecules/nav')
| -rw-r--r-- | src/components/molecules/nav/breadcrumb.module.scss | 19 | ||||
| -rw-r--r-- | src/components/molecules/nav/breadcrumb.stories.tsx | 48 | ||||
| -rw-r--r-- | src/components/molecules/nav/breadcrumb.test.tsx | 15 | ||||
| -rw-r--r-- | src/components/molecules/nav/breadcrumb.tsx | 113 | ||||
| -rw-r--r-- | src/components/molecules/nav/nav.module.scss | 22 | ||||
| -rw-r--r-- | src/components/molecules/nav/nav.stories.tsx | 75 | ||||
| -rw-r--r-- | src/components/molecules/nav/nav.test.tsx | 28 | ||||
| -rw-r--r-- | src/components/molecules/nav/nav.tsx | 80 |
8 files changed, 400 insertions, 0 deletions
diff --git a/src/components/molecules/nav/breadcrumb.module.scss b/src/components/molecules/nav/breadcrumb.module.scss new file mode 100644 index 0000000..c26f60a --- /dev/null +++ b/src/components/molecules/nav/breadcrumb.module.scss @@ -0,0 +1,19 @@ +@use "@styles/abstracts/placeholders"; + +.list { + @extend %reset-ordered-list; + + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--spacing-2xs); +} + +.item { + &:not(:last-of-type) { + &::after { + content: ">"; + margin-left: var(--spacing-2xs); + } + } +} diff --git a/src/components/molecules/nav/breadcrumb.stories.tsx b/src/components/molecules/nav/breadcrumb.stories.tsx new file mode 100644 index 0000000..d283619 --- /dev/null +++ b/src/components/molecules/nav/breadcrumb.stories.tsx @@ -0,0 +1,48 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import BreadcrumbComponent, { type BreadcrumbItem } from './breadcrumb'; + +export default { + title: 'Molecules/Nav', + component: BreadcrumbComponent, + argTypes: { + className: { + control: { + type: 'text', + }, + table: { + category: 'Styles', + }, + description: 'Set additional classnames to the nav element.', + type: { + name: 'string', + required: false, + }, + }, + items: { + description: 'The breadcrumb items.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }, +} as ComponentMeta<typeof BreadcrumbComponent>; + +const Template: ComponentStory<typeof BreadcrumbComponent> = (args) => ( + <IntlProvider locale="en"> + <BreadcrumbComponent {...args} /> + </IntlProvider> +); + +const items: BreadcrumbItem[] = [ + { id: 'home', url: '#', name: 'Home' }, + { id: 'blog', url: '#', name: 'Blog' }, + { id: 'post1', url: '#', name: 'A Post' }, +]; + +export const Breadcrumb = Template.bind({}); +Breadcrumb.args = { + items, +}; diff --git a/src/components/molecules/nav/breadcrumb.test.tsx b/src/components/molecules/nav/breadcrumb.test.tsx new file mode 100644 index 0000000..43220c9 --- /dev/null +++ b/src/components/molecules/nav/breadcrumb.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@test-utils'; +import Breadcrumb, { type BreadcrumbItem } from './breadcrumb'; + +const items: BreadcrumbItem[] = [ + { id: 'home', url: '#', name: 'Home' }, + { id: 'blog', url: '#', name: 'Blog' }, + { id: 'post1', url: '#', name: 'A Post' }, +]; + +describe('Breadcrumb', () => { + it('renders a navigation', () => { + render(<Breadcrumb items={items} />); + expect(screen.getByRole('navigation')).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/nav/breadcrumb.tsx b/src/components/molecules/nav/breadcrumb.tsx new file mode 100644 index 0000000..33af735 --- /dev/null +++ b/src/components/molecules/nav/breadcrumb.tsx @@ -0,0 +1,113 @@ +import Link from '@components/atoms/links/link'; +import { settings } from '@utils/config'; +import Script from 'next/script'; +import { VFC } from 'react'; +import { useIntl } from 'react-intl'; +import { BreadcrumbList, ListItem, WithContext } from 'schema-dts'; +import styles from './breadcrumb.module.scss'; + +export type BreadcrumbItem = { + /** + * The item id. + */ + id: string; + /** + * The item URL. + */ + url: string; + /** + * The item name. + */ + name: string; +}; + +export type BreadcrumbProps = { + /** + * Set additional classnames to the nav element. + */ + className?: string; + /** + * The breadcrumb items + */ + items: BreadcrumbItem[]; +}; + +/** + * Breadcrumb component + * + * Render a breadcrumb navigation. + */ +const Breadcrumb: VFC<BreadcrumbProps> = ({ items, ...props }) => { + const intl = useIntl(); + + /** + * Retrieve the breadcrumb list items. + * + * @param {BreadcrumbItem[]} list - The breadcrumb items. + * @returns {JSX.Element[]} The list items. + */ + const getListItems = (list: BreadcrumbItem[]): JSX.Element[] => { + return list.map((item, index) => { + const isLastItem = index === list.length - 1; + const itemClassnames = isLastItem + ? `${styles.item} screen-reader-text` + : styles.item; + + return ( + <li key={item.id} className={itemClassnames}> + {isLastItem ? item.name : <Link href={item.url}>{item.name}</Link>} + </li> + ); + }); + }; + + /** + * Retrieve the breadcrumb list items with Schema.org format. + * + * @param {BreadcrumbItem[]} list - The breadcrumb items. + * @returns {ListItem[]} An array of list items using Schema.org format. + */ + const getSchemaItems = (list: BreadcrumbItem[]): ListItem[] => { + const schemaItems: ListItem[] = []; + + list.forEach((item, index) => { + schemaItems.push({ + '@type': 'ListItem', + position: index + 1, + name: item.name, + item: item.url, + }); + }); + + return schemaItems; + }; + + const schemaJsonLd: WithContext<BreadcrumbList> = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + '@id': `${settings.url}/#breadcrumb`, + itemListElement: getSchemaItems(items), + }; + + return ( + <> + <Script + id="schema-breadcrumb" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} + /> + <nav {...props}> + <span className="screen-reader-text"> + {intl.formatMessage({ + defaultMessage: 'You are here:', + description: 'Breadcrumb: You are here prefix', + id: '16zl9Z', + })} + </span> + <ol className={styles.list}>{getListItems(items)}</ol> + </nav> + </> + ); +}; + +export default Breadcrumb; diff --git a/src/components/molecules/nav/nav.module.scss b/src/components/molecules/nav/nav.module.scss new file mode 100644 index 0000000..9c0f6de --- /dev/null +++ b/src/components/molecules/nav/nav.module.scss @@ -0,0 +1,22 @@ +@use "@styles/abstracts/mixins" as mix; +@use "@styles/abstracts/placeholders"; + +.nav { + &__list { + @extend %reset-list; + + display: flex; + flex-flow: row wrap; + gap: var(--spacing-2xs); + align-items: center; + } + + &--footer & { + &__item:not(:first-child) { + &::before { + content: "\2022"; + margin-right: var(--spacing-2xs); + } + } + } +} diff --git a/src/components/molecules/nav/nav.stories.tsx b/src/components/molecules/nav/nav.stories.tsx new file mode 100644 index 0000000..9975bbd --- /dev/null +++ b/src/components/molecules/nav/nav.stories.tsx @@ -0,0 +1,75 @@ +import Envelop from '@components/atoms/icons/envelop'; +import Home from '@components/atoms/icons/home'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import NavComponent, { type NavItem } from './nav'; + +const MainNavItems: NavItem[] = [ + { id: 'homeLink', href: '/', label: 'Home', logo: <Home /> }, + { id: 'contactLink', href: '/contact', label: 'Contact', logo: <Envelop /> }, +]; + +const FooterNavItems: NavItem[] = [ + { id: 'contactLink', href: '/contact', label: 'Contact' }, + { id: 'legalLink', href: '/legal-notice', label: 'Legal notice' }, +]; + +export default { + title: 'Molecules/Nav', + component: NavComponent, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the navigation wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + items: { + control: { + type: null, + }, + description: 'The nav items.', + type: { + name: 'other', + required: true, + value: '', + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'The navigation kind.', + options: ['main', 'footer'], + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof NavComponent>; + +const Template: ComponentStory<typeof NavComponent> = (args) => ( + <IntlProvider locale="en"> + <NavComponent {...args} /> + </IntlProvider> +); + +export const MainNav = Template.bind({}); +MainNav.args = { + items: MainNavItems, + kind: 'main', +}; + +export const FooterNav = Template.bind({}); +FooterNav.args = { + items: FooterNavItems, + kind: 'footer', +}; diff --git a/src/components/molecules/nav/nav.test.tsx b/src/components/molecules/nav/nav.test.tsx new file mode 100644 index 0000000..183ca0b --- /dev/null +++ b/src/components/molecules/nav/nav.test.tsx @@ -0,0 +1,28 @@ +import Envelop from '@components/atoms/icons/envelop'; +import Home from '@components/atoms/icons/home'; +import { render, screen } from '@test-utils'; +import Nav, { type NavItem } from './nav'; + +const navItems: NavItem[] = [ + { id: 'homeLink', href: '/', label: 'Home', logo: <Home /> }, + { id: 'contactLink', href: '/contact', label: 'Contact', logo: <Envelop /> }, +]; + +describe('Nav', () => { + it('renders a main navigation', () => { + render(<Nav kind="main" items={navItems} />); + expect(screen.getByRole('navigation')).toHaveClass('nav--main'); + }); + + it('renders a footer navigation', () => { + render(<Nav kind="footer" items={navItems} />); + expect(screen.getByRole('navigation')).toHaveClass('nav--footer'); + }); + + it('renders navigation links', () => { + render(<Nav kind="main" items={navItems} />); + expect( + screen.getByRole('link', { name: navItems[0].label }) + ).toHaveAttribute('href', navItems[0].href); + }); +}); diff --git a/src/components/molecules/nav/nav.tsx b/src/components/molecules/nav/nav.tsx new file mode 100644 index 0000000..6ef9158 --- /dev/null +++ b/src/components/molecules/nav/nav.tsx @@ -0,0 +1,80 @@ +import Link from '@components/atoms/links/link'; +import NavLink from '@components/atoms/links/nav-link'; +import { ReactNode, VFC } from 'react'; +import styles from './nav.module.scss'; + +export type NavItem = { + /** + * The item id. + */ + id: string; + /** + * The item link. + */ + href: string; + /** + * The item name. + */ + label: string; + /** + * The item logo. + */ + logo?: ReactNode; +}; + +export type NavProps = { + /** + * Set additional classnames to the navigation wrapper. + */ + className?: string; + /** + * The navigation items. + */ + items: NavItem[]; + /** + * The navigation kind. + */ + kind: 'main' | 'footer'; + /** + * Set additional classnames to the navigation list. + */ + listClassName?: string; +}; + +/** + * Nav component + * + * Render the nav links. + */ +const Nav: VFC<NavProps> = ({ + className = '', + items, + kind, + listClassName = '', +}) => { + const kindClass = `nav--${kind}`; + + /** + * Get the nav items. + * @returns {JSX.Element[]} An array of nav items. + */ + const getItems = (): JSX.Element[] => { + return items.map(({ id, href, label, logo }) => ( + <li key={id} className={styles.nav__item}> + {kind === 'main' ? ( + <NavLink href={href} label={label} logo={logo} /> + ) : ( + <Link href={href}>{label}</Link> + )} + </li> + )); + }; + + return ( + <nav className={`${styles[kindClass]} ${className}`}> + <ul className={`${styles.nav__list} ${listClassName}`}>{getItems()}</ul> + </nav> + ); +}; + +export default Nav; |
