Skip to content

Commit

Permalink
Create StudioBreadcrumbs component
Browse files Browse the repository at this point in the history
  • Loading branch information
ErlingHauan committed Nov 4, 2024
1 parent 18d21a0 commit 970c414
Show file tree
Hide file tree
Showing 10 changed files with 292 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Meta, Controls, Primary, Canvas } from '@storybook/blocks';
import * as StudioBreadcrumbsStories from './StudioBreadcrumbs.stories';

<Meta of={StudioBreadcrumbsStories} />

# StudioBreadcrumbs

This is a copy of Designsystemet's `Breadcrumb` component from v1.0.0-next.35. It should eventually be replaced with an import from Designsystemet when we upgrade to v1.

`StudioBreadcrumbs` is a navigation with a visible breadcrumb trail. Use this component to help users understand where they are within a structure, such as on a website. This allows them to more easily switch between the different levels of the structure.

<Primary />
<Controls of={StudioBreadcrumbsStories.Preview} />

## How to use `Breadcrumb`

The last link in the breadcrumb trail is automatically marked with `aria-current="page"`.

```tsx
<Breadcrumbs aria-label='You are here:'>
<Breadcrumbs.List>
<Breadcrumbs.Item>
<Breadcrumbs.Link href='https://designsystemet.no/'>Level 1</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Item>
<Breadcrumbs.Link href='https://designsystemet.no/niva-2/'>Level 2</Breadcrumbs.Link>
</Breadcrumbs.Item>
</Breadcrumbs.List>
</Breadcrumbs>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
.ds-breadcrumbs {
--dsc-breadcrumbs-spacing: var(--fds-spacing-2);
--dsc-breadcrumbs-chevron-size: var(--fds-sizing-6);
--dsc-breadcrumbs-icon-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'%3E%3Cpath d='M9.47 5.97a.75.75 0 0 1 1.06 0l5.5 5.5a.75.75 0 0 1 0 1.06l-5.5 5.5a.75.75 0 1 1-1.06-1.06L14.44 12 9.47 7.03a.75.75 0 0 1 0-1.06'/%3E%3C/svg%3E");

& > :is(ol, ul) {
display: flex;
flex-wrap: wrap;
list-style-type: none;
margin: 0;
padding: 0;
gap: var(--dsc-breadcrumbs-spacing) 0;
}

& a:not(:focus-visible) {
color: inherit;
}

& a[aria-current='page'] {
text-decoration: none;
}

/* Draw chevron between items and before back link */
& li:where(:not(:last-child))::after,
& > :not(ol, ul)::before {
background: currentcolor;
content: '';
display: inline-block;
height: var(--dsc-breadcrumbs-chevron-size);
margin-inline: var(--dsc-breadcrumbs-spacing);
mask: center / contain no-repeat var(--dsc-breadcrumbs-icon-url);
vertical-align: middle;
width: var(--dsc-breadcrumbs-chevron-size);
}

/* When link is direct child of Breadcrumbs, make it back button */
& > :not(ol, ul)::before {
margin: 0;
rotate: 180deg;
}

@media (width < 650px) {
& > :is(ol, ul):not(:only-child) {
display: none; /* Hide list when mobile and having back link */
}
}

@media (min-width: 650px) {
& > :is(:not(ol, ul)):not(:only-child) {
display: none; /* Hide back link when desktop and having list */
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Meta, StoryFn } from '@storybook/react';
import React from 'react';

import { StudioBreadcrumbs } from '.';

export default {
title: 'Components/StudioBreadcrumbs',
component: StudioBreadcrumbs,
args: {
'aria-label': 'You are here:',
},
} as Meta;

export const Preview: StoryFn<typeof StudioBreadcrumbs> = (args) => (
<StudioBreadcrumbs {...args}>
<StudioBreadcrumbs.List>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Level 1</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Level 2</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Level 3</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Level 4</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
</StudioBreadcrumbs.List>
</StudioBreadcrumbs>
);

export const Back: StoryFn<typeof StudioBreadcrumbs> = (args) => (
<StudioBreadcrumbs {...args}>
<StudioBreadcrumbs.Link href='#' aria-label='Back to level 3'>
Level 3
</StudioBreadcrumbs.Link>
</StudioBreadcrumbs>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import type { StudioBreadcrumbsProps } from './StudioBreadcrumbs';

import { StudioBreadcrumbs } from './';

const renderWithRoot = (props?: StudioBreadcrumbsProps) =>
render(
<StudioBreadcrumbs aria-label='Du er her:' {...props}>
<StudioBreadcrumbs.Link href='#' aria-label='Tilbake til Nivå 3'>
Nivå 3
</StudioBreadcrumbs.Link>
<StudioBreadcrumbs.List>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 1</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 2</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 3</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 4</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
</StudioBreadcrumbs.List>
</StudioBreadcrumbs>,
);

describe('StudioBreadcrumbs', () => {
it('should render correctly with default props', () => {
renderWithRoot();

expect(screen.getByRole('navigation')).toBeInTheDocument();
});
});

describe('StudioBreadcrumbs.List', () => {
it('should render with aria-current on last item', () => {
renderWithRoot();
const links = screen.getAllByRole('link');
expect(links.at(0)).not.toHaveAttribute('aria-current', 'page');
expect(links.at(1)).not.toHaveAttribute('aria-current', 'page');
expect(links.at(2)).not.toHaveAttribute('aria-current', 'page');
expect(links.at(-1)).toHaveAttribute('aria-current', 'page');
});

it('should move aria-current to item when re-rendering', () => {
renderWithRoot();
const links = screen.getAllByRole('link');
expect(links.at(-1)).toHaveAttribute('aria-current', 'page');

// Re-render with additional level
render(
<StudioBreadcrumbs aria-label='Du er her:'>
<StudioBreadcrumbs.Link href='#' aria-label='Tilbake til Nivå 3'>
Nivå 3
</StudioBreadcrumbs.Link>
<StudioBreadcrumbs.List>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 1</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 2</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 3</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 4</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Item>
<StudioBreadcrumbs.Link href='#'>Nivå 5</StudioBreadcrumbs.Link>
</StudioBreadcrumbs.Item>
</StudioBreadcrumbs.List>
</StudioBreadcrumbs>,
);

expect(links.at(-2)).not.toHaveAttribute('aria-current', 'page');
expect(links.at(-1)).toHaveAttribute('aria-current', 'page');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { type HTMLAttributes, forwardRef } from 'react';
import classes from './StudioBreadcrumbs.module.css';

export type StudioBreadcrumbsProps = {
'aria-label'?: string;
} & HTMLAttributes<HTMLElement>;

const StudioBreadcrumbs = forwardRef<HTMLElement, StudioBreadcrumbsProps>(
({ 'aria-label': ariaLabel = 'You are here:', className, ...rest }, ref) => (
<nav
aria-label={ariaLabel}
className={`${classes['ds-breadcrumbs']} ${className}`}
ref={ref}
{...rest}
/>
),
);

StudioBreadcrumbs.displayName = 'StudioBreadcrumbs';

export { StudioBreadcrumbs };
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type HTMLAttributes, forwardRef } from 'react';
import React from 'react';

export type StudioBreadcrumbsItemProps = HTMLAttributes<HTMLLIElement>;

export const StudioBreadcrumbsItem = forwardRef<HTMLLIElement, StudioBreadcrumbsItemProps>(
function BreadcrumbsItem({ className, ...rest }, ref) {
return <li ref={ref} {...rest} />;
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React, { forwardRef } from 'react';

import { Link } from '@digdir/designsystemet-react';
import type { LinkProps } from '@digdir/designsystemet-react';

export type StudioBreadcrumbsLinkProps = LinkProps;

export const StudioBreadcrumbsLink = forwardRef<HTMLAnchorElement, StudioBreadcrumbsLinkProps>(
function BreadcrumbsLink(rest, ref) {
return <Link ref={ref} {...rest} />;
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useMergeRefs } from '@floating-ui/react';
import { type HTMLAttributes, forwardRef, useEffect, useRef } from 'react';
import React from 'react';

export type StudioBreadcrumbsListProps = HTMLAttributes<HTMLOListElement>;

export const StudioBreadcrumbsList = forwardRef<HTMLOListElement, StudioBreadcrumbsListProps>(
function BreadcrumbsList(rest, ref) {
const innerRef = useRef<HTMLOListElement>(null);
const mergedRefs = useMergeRefs([innerRef, ref]);

// Set aria-current on last link
useEffect(() => {
const links = innerRef.current?.querySelectorAll(':scope > * > *') || [];
const lastLink = links[links?.length - 1];

lastLink?.setAttribute('aria-current', 'page');
return () => lastLink?.removeAttribute('aria-current'); // Remove on re-render as React can re-use DOM elements
});

return <ol ref={mergedRefs} {...rest} />;
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { StudioBreadcrumbs as StudioBreadcrumbsParent } from './StudioBreadcrumbs';
import { StudioBreadcrumbsItem } from './StudioBreadcrumbsItem';
import { StudioBreadcrumbsLink } from './StudioBreadcrumbsLink';
import { StudioBreadcrumbsList } from './StudioBreadcrumbsList';

export const StudioBreadcrumbs = Object.assign(StudioBreadcrumbsParent, {
List: StudioBreadcrumbsList,
Item: StudioBreadcrumbsItem,
Link: StudioBreadcrumbsLink,
});

StudioBreadcrumbs.List.displayName = 'StudioBreadcrumbs.List';
StudioBreadcrumbs.Item.displayName = 'StudioBreadcrumbs.Item';
StudioBreadcrumbs.Link.displayName = 'StudioBreadcrumbs.Link';

export type { StudioBreadcrumbsProps } from './StudioBreadcrumbs';
export type { StudioBreadcrumbsListProps } from './StudioBreadcrumbsList';
export type { StudioBreadcrumbsItemProps } from './StudioBreadcrumbsItem';
export type { StudioBreadcrumbsLinkProps } from './StudioBreadcrumbsLink';
export { StudioBreadcrumbsList, StudioBreadcrumbsItem, StudioBreadcrumbsLink };

Check warning on line 20 in frontend/libs/studio-components/src/components/StudioBreadcrumbs/index.ts

View check run for this annotation

Codecov / codecov/patch

frontend/libs/studio-components/src/components/StudioBreadcrumbs/index.ts#L20

Added line #L20 was not covered by tests
1 change: 1 addition & 0 deletions frontend/libs/studio-components/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './StudioAvatar';
export * from './StudioBetaTag';
export * from './StudioBlobDownloader';
export * from './StudioBooleanToggleGroup';
export * from './StudioBreadcrumbs';
export * from './StudioButton';
export * from './StudioCard';
export * from './StudioCenter';
Expand Down

0 comments on commit 970c414

Please sign in to comment.