Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ A serverless, progressive, responsive starter user interface (UI) with React at

## Helpful Hints

### Viewing the Starter Kit

If you do not wish to check out the repository and run the starter kit locally, you may view the latest release of the React Starter Kit at [https://react-starter.leanstacks.net/][app]. Please see the _Authentication_ section below for instructions to sign in.

> **NOTE:** This app does not collect any data and does not use cookies.

### Data

This project's API integration uses the simulated REST endpoints made available by [JSON Placeholder](https://jsonplaceholder.typicode.com/).

There are some limitations to the JSONP Placeholder APIs. The primary limitation is that the API is stateless. You may create (`post`), update (`put`), and delete items within the JSON Placeholder collections; however, the collections are not actually mutated and persisted by JSON Placeholder. Within the starter kit, we update the TanStack Query caches upon successful mutation to simulate a stateful back end.
There are some limitations to the JSON Placeholder APIs. The primary limitation is that the API is stateless. You may create (`post`), update (`put`), and delete items within the JSON Placeholder collections; however, the collections are not actually mutated and persisted by JSON Placeholder. Within the starter kit, we update the TanStack Query caches upon successful mutation to simulate a stateful back end.

### Authentication

When running the application, you may sign in with any of the JSON Placeholder [Users](https://jsonplaceholder.typicode.com/users). Simply enter the _Username_ value from any user in the API and use any value for the _Password_. For example, try username `Bret` and password `abc123`.
When using the application, you may sign in with any of the JSON Placeholder [Users](https://jsonplaceholder.typicode.com/users). Simply enter the _Username_ value from any user in the API and use any value for the _Password_. For example, try username `Kamren` or `Samantha` and password `abc123`.

## About

Expand Down Expand Up @@ -273,6 +279,7 @@ This project uses GitHub Actions to perform DevOps automation activities such as
- [Storybook][storybook]
- [GitHub Actions][ghactions]

[app]: https://react-starter.leanstacks.net/ 'React Starter Kit | LeanStacks'
[repo]: https://github.com/leanstacks/react-starter-kit 'GitHub Repository'
[nvm]: https://github.com/nvm-sh/nvm 'Node Version Manager'
[react]: https://react.dev 'React'
Expand Down
153 changes: 153 additions & 0 deletions src/common/components/Breadcrumbs/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { PropsWithChildren } from 'react';

import { BaseComponentProps } from 'common/utils/types';
import { cn } from 'common/utils/css';
import { default as CommonLink, LinkProps as CommonLinkProps } from 'common/components/Link/Link';
import FAIcon, { FAIconProps } from '../Icon/FAIcon';

/**
* Properties for the `Breadcrumbs` component.
*/
export interface BreadcrumbsProps extends BaseComponentProps, PropsWithChildren {}

/**
* The `Breadcrumbs` component renders a heirarchy of links as a path to the
* current route.
*/
const Breadcrumbs = ({
children,
className,
testId = 'breadcrumbs',
}: BreadcrumbsProps): JSX.Element => {
return (
<nav className={cn(className)} aria-label="breadcrumbs" data-testid={testId}>
{children}
</nav>
);
};

/**
* The `List` contains the list of items in the breadcrumbs. It is located immediately
* within the `Breadcrumbs` component and contains one to many `Item` components.
*/
const List = ({
children,
className,
testId = 'breadcrumbs-list',
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
return (
<ol
className={cn('flex flex-wrap items-center gap-2 text-sm break-words sm:gap-3', className)}
data-testid={testId}
>
{children}
</ol>
);
};
Breadcrumbs.List = List;

/**
* The `Item` represents a single element of the breadcrumbs list. An item typically
* represents a single element of the path to which a user may navigate.
*
* An `Item` may contain any type of child; however, an item typically contains a `Link`,
* `Page`, or a `DropdownMenu`.
*/
const Item = ({
children,
className,
testId = 'breadcrumbs-item',
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
return (
<li className={cn('last:font-bold', className)} data-testid={testId}>
{children}
</li>
);
};
Breadcrumbs.Item = Item;

/**
* A `Link` represents a navigation link item within breadcrumbs. A link is
* a child of an `Item`. Use the `to` property to specify the relative path for
* the link.
*/
const Link = ({
children,
className,
testId = 'breadcrumbs-link',
...props
}: CommonLinkProps): JSX.Element => {
return (
<CommonLink
className={cn('text-light-text dark:text-dark-text block max-w-40 truncate', className)}
data-testid={testId}
{...props}
>
{children}
</CommonLink>
);
};
Breadcrumbs.Link = Link;

/**
* A `Page` represents an item within breadcrumbs with no associated navigation.
* Use the `Page` as an alternative to the `Link` when the breadcrumb item is
* not clickable. A `Page` is commonly used for the current page, i.e. the last
* element in the breadcrumbs.
*/
const Page = ({
children,
className,
testId = 'breadcrumbs-page',
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
return (
<span className={cn('block max-w-40 truncate', className)} data-testid={testId}>
{children}
</span>
);
};
Breadcrumbs.Page = Page;

/**
* The `Separator` component renders an icon which visually separates two
* breadcrumbs elements. The default icon is a right chevron.
*/
const Separator = ({
className,
icon = 'chevronRight',
size = 'sm',
testId = 'breadcrumbs-separator',
...iconProps
}: BaseComponentProps &
Omit<FAIconProps, 'icon'> &
Partial<Pick<FAIconProps, 'icon'>>): JSX.Element => {
return (
<li className={cn(className)} data-testid={testId}>
<FAIcon icon={icon} size={size} className="size-4" {...iconProps} testId={`${testId}-icon`} />
</li>
);
};
Breadcrumbs.Separator = Separator;

/**
* The `Ellipsis` component renders a horizontal ellipsis icon. This is useful when
* the number of breadcrumbs elements is too great to render all of them. Use the
* ellipsis to represent breadcrumbs items which have been omitted to shorten the
* overall breadcrumbs list. Alternatively, use the ellipsis as the trigger for
* a `DropdownMenu` within the breadcrumbs to allow navigation to those routes
* which have been omitted for brevity.
*/
const Ellipsis = ({
className,
testId = 'breadcrumbs-ellipsis',
}: BaseComponentProps): JSX.Element => {
return (
<span className={cn('hover:opacity-75', className)} data-testid={testId}>
<FAIcon icon="ellipsis" className="size-4" />
<span className="sr-only">More</span>
</span>
);
};
Breadcrumbs.Ellipsis = Ellipsis;

export default Breadcrumbs;
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MemoryRouter } from 'react-router-dom';

import Breadcrumbs from '../Breadcrumbs';
import DropdownMenu from 'common/components/Dropdown/DropdownMenu';

const meta = {
title: 'Common/Breadcrumbs',
component: Breadcrumbs,
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
tags: ['autodocs'],
argTypes: {
children: { description: 'The content.' },
className: { description: 'Additional CSS classes.' },
testId: { description: 'The test identifier.' },
},
} satisfies Meta<typeof Breadcrumbs>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Basic: Story = {
render: (args) => (
<Breadcrumbs {...args}>
<Breadcrumbs.List>
<Breadcrumbs.Item>
<Breadcrumbs.Link to="/">Home</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Link to="/tasks">Tasks</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Link to="/app/tasks/97">
dolorum laboriosam eos qui iure aliquam
</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Page>Edit</Breadcrumbs.Page>
</Breadcrumbs.Item>
</Breadcrumbs.List>
</Breadcrumbs>
),
args: {
className: 'w-100',
},
};

export const WithDropdownMenu: Story = {
render: (args) => (
<Breadcrumbs {...args}>
<Breadcrumbs.List>
<Breadcrumbs.Item>
<Breadcrumbs.Link to="/">Home</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<DropdownMenu>
<DropdownMenu.Trigger>
<Breadcrumbs.Ellipsis />
</DropdownMenu.Trigger>
<DropdownMenu.Content className="left-0">
<DropdownMenu.Item>Components</DropdownMenu.Item>
<DropdownMenu.Item>Settings</DropdownMenu.Item>
<DropdownMenu.Item>Tasks</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Link to="/app/tasks/97">
dolorum laboriosam eos qui iure aliquam
</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Page>Edit</Breadcrumbs.Page>
</Breadcrumbs.Item>
</Breadcrumbs.List>
</Breadcrumbs>
),
args: {
className: 'w-100 h-40',
},
};
62 changes: 62 additions & 0 deletions src/common/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';

import { render, screen } from 'test/test-utils';

import Breadcrumbs from '../Breadcrumbs';

describe('Breadcrumbs', () => {
it('should render successfully', async () => {
// ARRANGE
render(
<Breadcrumbs>
<Breadcrumbs.List>
<Breadcrumbs.Item>
<Breadcrumbs.Link to="/">Home</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Ellipsis />
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Page>Page</Breadcrumbs.Page>
</Breadcrumbs.Item>
</Breadcrumbs.List>
</Breadcrumbs>,
);
await screen.findByTestId('breadcrumbs');

// ASSERT
expect(screen.getByTestId('breadcrumbs')).toBeDefined();
});

it('should render inner components successfully', async () => {
// ARRANGE
render(
<Breadcrumbs>
<Breadcrumbs.List>
<Breadcrumbs.Item>
<Breadcrumbs.Link to="/">Home</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Ellipsis />
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Page>Page</Breadcrumbs.Page>
</Breadcrumbs.Item>
</Breadcrumbs.List>
</Breadcrumbs>,
);
await screen.findByTestId('breadcrumbs');

// ASSERT
expect(screen.getByTestId('breadcrumbs')).toBeDefined();
expect(screen.getByTestId('breadcrumbs-list')).toBeDefined();
expect(screen.getAllByTestId('breadcrumbs-item')).toHaveLength(3);
expect(screen.getAllByTestId('breadcrumbs-separator')).toHaveLength(2);
expect(screen.getByTestId('breadcrumbs-ellipsis')).toBeDefined();
expect(screen.getByTestId('breadcrumbs-page')).toBeDefined();
});
});
9 changes: 9 additions & 0 deletions src/common/components/Icon/FAIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import {
faBuilding,
faCheck,
faChevronDown,
faChevronRight,
faChevronUp,
faCircleCheck,
faCircleExclamation,
faCircleInfo,
faCircleNotch,
faCircleXmark,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faLanguage,
faLink,
Expand Down Expand Up @@ -48,13 +51,16 @@ export type FAIconName =
| 'building'
| 'check'
| 'chevronDown'
| 'chevronRight'
| 'chevronUp'
| 'circleCheck'
| 'circleExclamation'
| 'circleInfo'
| 'circleNotch'
| 'circleRegular'
| 'circleXmark'
| 'ellipsis'
| 'ellipsisVertical'
| 'envelope'
| 'language'
| 'link'
Expand Down Expand Up @@ -97,13 +103,16 @@ const icons: Record<FAIconName, IconProp> = {
building: faBuilding,
check: faCheck,
chevronDown: faChevronDown,
chevronRight: faChevronRight,
chevronUp: faChevronUp,
circleCheck: faCircleCheck,
circleExclamation: faCircleExclamation,
circleInfo: faCircleInfo,
circleNotch: faCircleNotch,
circleRegular: faCircleRegular,
circleXmark: faCircleXmark,
ellipsis: faEllipsis,
ellipsisVertical: faEllipsisVertical,
envelope: faEnvelope,
language: faLanguage,
link: faLink,
Expand Down
Loading