Skip to content

Commit

Permalink
feat(components): add Breadcrumbs component (nodejs#5928)
Browse files Browse the repository at this point in the history
* feat(components): add `Breadcrumbs` component

* feat(Storybook): add Breadcrumbs Hidden Home story

* fix(Breadcrumbs): fix CSS indentation

* fix(Breadcrumbs): fix typo

* fix(Breadcrumbs): fix eslint import order

* refactor(Breadcrumbs): split Breadcrumb into sub components

* style(Breadcrumbs): remove comment and update component

* fix(Breadcrumbs): use LocalizedLink

* style(Breadcrumbs): remove abbreviation

* refactor(Breadcrumbs): improve code readability

* style(Breadcrumbs): format Breadcrumbs story

* fix(Breadcrumbs): add default value for links prop

* test(Breadcrumbs): add unit test

* test(Breadcrumbs): remove unit test

* feat(Breadcrumbs): add microdata

* feat(Breadcrumbs): use intl for home aria label

* fix(Breadcrumbs): itemProp without wrapping with span

* refactor(Breadcrumbs): modularize Breadcrumb and refine logic

* style(Breadcrumbs): rename other to props

* fix(Breadcrumbs): move name to link

* fix(Breadcrumbs): disable microdata for truncated item
  • Loading branch information
junwen-k authored Oct 9, 2023
1 parent 7d6b69a commit fdbbcc8
Show file tree
Hide file tree
Showing 12 changed files with 319 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.icon {
@apply h-4
w-4;
}
36 changes: 36 additions & 0 deletions components/Common/Breadcrumbs/BreadcrumbHomeLink/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import HomeIcon from '@heroicons/react/24/outline/HomeIcon';
import type { ComponentProps, FC } from 'react';
import { useIntl } from 'react-intl';

import BreadcrumbLink from '@/components/Common/Breadcrumbs/BreadcrumbLink';

import styles from './index.module.css';

type BreadcrumbHomeLinkProps = Omit<
ComponentProps<typeof BreadcrumbLink>,
'href'
> &
Partial<Pick<ComponentProps<typeof BreadcrumbLink>, 'href'>>;

const BreadcrumbHomeLink: FC<BreadcrumbHomeLinkProps> = ({
href = '/',
...props
}) => {
const { formatMessage } = useIntl();

const navigateToHome = formatMessage({
id: 'components.common.breadcrumbs.navigateToHome',
});

return (
<BreadcrumbLink href={href} {...props}>
<HomeIcon
title={navigateToHome}
aria-label={navigateToHome}
className={styles.icon}
/>
</BreadcrumbLink>
);
};

export default BreadcrumbHomeLink;
20 changes: 20 additions & 0 deletions components/Common/Breadcrumbs/BreadcrumbItem/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.item {
@apply flex
items-center
gap-5
text-sm
font-medium
text-neutral-800
dark:text-neutral-200;

&.visuallyHidden {
@apply hidden;
}

.separator {
@apply h-4
w-4
text-neutral-600
dark:text-neutral-400;
}
}
42 changes: 42 additions & 0 deletions components/Common/Breadcrumbs/BreadcrumbItem/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon';
import classNames from 'classnames';
import type { ComponentProps, FC, PropsWithChildren } from 'react';

import styles from './index.module.css';

type BreadcrumbItemProps = {
disableMicrodata?: boolean;
hidden?: boolean;
hideSeparator?: boolean;
position?: number;
} & ComponentProps<'li'>;

const BreadcrumbItem: FC<PropsWithChildren<BreadcrumbItemProps>> = ({
disableMicrodata,
children,
hidden = false,
hideSeparator = false,
position,
...props
}) => (
<li
{...props}
itemProp={!disableMicrodata ? 'itemListElement' : undefined}
itemScope={!disableMicrodata ? true : undefined}
itemType={!disableMicrodata ? 'https://schema.org/ListItem' : undefined}
className={classNames(
styles.item,
{ [styles.visuallyHidden]: hidden },
props.className
)}
aria-hidden={hidden ? 'true' : undefined}
>
{children}
{position && <meta itemProp="position" content={`${position}`} />}
{!hideSeparator && (
<ChevronRightIcon aria-hidden="true" className={styles.separator} />
)}
</li>
);

export default BreadcrumbItem;
8 changes: 8 additions & 0 deletions components/Common/Breadcrumbs/BreadcrumbLink/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.active {
@apply rounded
bg-green-600
px-2
py-1
font-semibold
text-white;
}
31 changes: 31 additions & 0 deletions components/Common/Breadcrumbs/BreadcrumbLink/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import classNames from 'classnames';
import type { ComponentProps, FC } from 'react';

import LocalizedLink from '@/components/LocalizedLink';

import styles from './index.module.css';

type BreadcrumbLinkProps = {
active?: boolean;
} & ComponentProps<typeof LocalizedLink>;

const BreadcrumbLink: FC<BreadcrumbLinkProps> = ({
href,
active,
...props
}) => (
<LocalizedLink
itemScope
itemType="http://schema.org/Thing"
itemProp="item"
itemID={href.toString()}
href={href}
className={classNames({ [styles.active]: active }, props.className)}
aria-current={active ? 'page' : undefined}
{...props}
>
<span itemProp="name">{props.children}</span>
</LocalizedLink>
);

export default BreadcrumbLink;
5 changes: 5 additions & 0 deletions components/Common/Breadcrumbs/BreadcrumbRoot/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.list {
@apply flex
items-center
gap-5;
}
20 changes: 20 additions & 0 deletions components/Common/Breadcrumbs/BreadcrumbRoot/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { FC, PropsWithChildren, ComponentProps } from 'react';

import styles from './index.module.css';

const BreadcrumbRoot: FC<PropsWithChildren<ComponentProps<'nav'>>> = ({
children,
...props
}) => (
<nav aria-label="breadcrumb" {...props}>
<ol
itemScope
itemType="https://schema.org/BreadcrumbList"
className={styles.list}
>
{children}
</ol>
</nav>
);

export default BreadcrumbRoot;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import BreadcrumbItem from '@/components/Common/Breadcrumbs/BreadcrumbItem';

const BreadcrumbTruncatedItem = () => (
<BreadcrumbItem disableMicrodata>
<button disabled></button>
</BreadcrumbItem>
);

export default BreadcrumbTruncatedItem;
75 changes: 75 additions & 0 deletions components/Common/Breadcrumbs/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import Breadcrumbs from './';

type Story = StoryObj<typeof Breadcrumbs>;
type Meta = MetaObj<typeof Breadcrumbs>;

export const Default: Story = {
args: {
links: [
{
label: 'Learn',
href: '/learn',
},
{
label: 'Getting Started',
href: '/learn/getting-started',
},
{
label: 'Introduction to Node.js',
href: '/learn/getting-started/intro',
},
],
},
};

export const Truncate: Story = {
args: {
links: [
{
label: 'Learn',
href: '/learn',
},
{
label: 'Getting Started',
href: '/learn/getting-started',
},
{
label: 'Introduction to Node.js',
href: '/learn/getting-started/intro',
},
{
label: 'Installation',
href: '/learn/getting-started/intro/installation',
},
{
label: 'Documentation',
href: '/learn/getting-started/intro/installation/documentation',
},
],
maxLength: 1,
},
};

export const HiddenHome: Story = {
args: {
hideHome: true,
links: [
{
label: 'Learn',
href: '/learn',
},
{
label: 'Getting Started',
href: '/learn/getting-started',
},
{
label: 'Introduction to Node.js',
href: '/learn/getting-started/intro',
},
],
},
};

export default { component: Breadcrumbs } as Meta;
68 changes: 68 additions & 0 deletions components/Common/Breadcrumbs/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { type LinkProps } from 'next/link';
import { useMemo, type FC } from 'react';

import BreadcrumbHomeLink from './BreadcrumbHomeLink';
import BreadcrumbItem from './BreadcrumbItem';
import BreadcrumbLink from './BreadcrumbLink';
import BreadcrumbRoot from './BreadcrumbRoot';
import BreadcrumbTruncatedItem from './BreadcrumbTruncatedItem';

type BreadcrumbLink = {
label: string;
href: LinkProps['href'];
};

type BreadcrumbsProps = {
links: BreadcrumbLink[];
maxLength?: number;
hideHome?: boolean;
};

const Breadcrumbs: FC<BreadcrumbsProps> = ({
links = [],
maxLength = 5,
hideHome = false,
}) => {
const totalLength = links.length + +!hideHome;
const lengthOffset = maxLength - totalLength;
const isOverflow = lengthOffset < 0;

const items = useMemo(
() =>
links.map((link, index, items) => {
const position = index + 1;
const isLastItem = index === items.length - 1;
const hidden =
// We add 1 here to take into account of the truncated breadcrumb.
position <= Math.abs(lengthOffset) + 1 && isOverflow && !isLastItem;

return (
<BreadcrumbItem
key={link.href.toString()}
hidden={hidden}
hideSeparator={isLastItem}
position={position + +!hideHome}
>
<BreadcrumbLink href={link.href} active={isLastItem}>
{link.label}
</BreadcrumbLink>
</BreadcrumbItem>
);
}),
[hideHome, isOverflow, lengthOffset, links]
);

return (
<BreadcrumbRoot>
{!hideHome && (
<BreadcrumbItem position={1}>
<BreadcrumbHomeLink />
</BreadcrumbItem>
)}
{isOverflow && <BreadcrumbTruncatedItem />}
{items}
</BreadcrumbRoot>
);
};

export default Breadcrumbs;
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"components.header.buttons.toggleDarkMode": "Toggle dark/light mode",
"components.pagination.next": "Newer | ",
"components.pagination.previous": "Older",
"components.common.breadcrumbs.navigateToHome": "Navigate to Home",
"components.common.crossLink.previous": "Prev",
"components.common.crossLink.next": "Next",
"components.common.codebox.copied": "Copied to clipboard!",
Expand Down

0 comments on commit fdbbcc8

Please sign in to comment.