Skip to content

Commit

Permalink
feat: breadcrumbs component (#72)
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuagraber authored May 30, 2024
1 parent 9c21f29 commit a93329b
Show file tree
Hide file tree
Showing 10 changed files with 469 additions and 89 deletions.
199 changes: 111 additions & 88 deletions docs/components.md

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions src/components/Breadcrumbs/PdapBreadcrumbs.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<template>
<nav aria-label="Breadcrumb">
<transition-group class="pdap-breadcrumbs" name="pdap-breadcrumbs" tag="ul">
<li
v-for="breadcrumb in breadcrumbs"
:key="breadcrumb.text"
:class="{ 'is-active': breadcrumb.active }"
>
<router-link v-if="!breadcrumb.active" :to="breadcrumb.path">
{{ breadcrumb.text }}
</router-link>
<span v-else>{{ breadcrumb.text }}</span>
</li>
</transition-group>
</nav>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { getBreadcrumbs } from '../../utils/breadcrumbs';
const route = useRoute();
const breadcrumbs = computed(() => getBreadcrumbs(route));
</script>

<style scoped>
.pdap-breadcrumbs {
@apply flex items-center;
}
.pdap-breadcrumbs li {
@apply flex items-center;
}
.pdap-breadcrumbs li:not(:first-child)::before {
@apply mx-2;
content: '/';
}
.pdap-breadcrumbs .is-active {
@apply text-neutral-950;
}
/* Animations */
.pdap-breadcrumbs-enter-active,
.pdap-breadcrumbs-leave-active {
transition: opacity 0.2s ease;
}
.pdap-breadcrumbs-enter-from,
.pdap-breadcrumbs-leave-to {
opacity: 0;
}
</style>
30 changes: 30 additions & 0 deletions src/components/Breadcrumbs/__snapshots__/breadcrumbs.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`PdapBreadcrumbs > renders breadcrumbs correctly for a double-nested route 1`] = `
<nav aria-label="Breadcrumb">
<transition-group-stub appear="false" class="pdap-breadcrumbs" css="true" name="pdap-breadcrumbs" persisted="false" tag="ul">
<li class>
<a>Product</a>
</li>
<li class>
<a>Product ID</a>
</li>
<li class="is-active">
<span>Edit Product ID</span>
</li>
</transition-group-stub>
</nav>
`;

exports[`PdapBreadcrumbs > renders breadcrumbs correctly for a single-nested route 1`] = `
<nav aria-label="Breadcrumb">
<transition-group-stub appear="false" class="pdap-breadcrumbs" css="true" name="pdap-breadcrumbs" persisted="false" tag="ul">
<li class>
<a>Product</a>
</li>
<li class="is-active">
<span>Product ID</span>
</li>
</transition-group-stub>
</nav>
`;
121 changes: 121 additions & 0 deletions src/components/Breadcrumbs/breadcrumbs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { RouterLinkStub, flushPromises, mount } from '@vue/test-utils';
import { createRouter, createWebHistory } from 'vue-router';
import PdapBreadcrumbs from './PdapBreadcrumbs.vue';
import { describe, it, expect } from 'vitest';

const routes = [
{
path: '/product',
name: 'Product',
meta: { breadcrumbText: 'Product' },
redirect: undefined,
children: [
{
path: '/product/:id',
name: 'Product ID',
meta: { breadcrumbText: 'Product ID' },
redirect: undefined,
children: [
{
path: '/product/:id/edit',
name: 'Edit Product ID',
meta: { breadcrumbText: 'Edit Product ID' },
redirect: undefined,
children: [],
},
],
},
],
},
];

const router = createRouter({
history: createWebHistory(),
routes,
});

describe('PdapBreadcrumbs', () => {
it('renders breadcrumbs correctly for a root route', async () => {
router.push({ name: 'Product' });
await router.isReady();

const wrapper = mount(PdapBreadcrumbs, {
global: {
mocks: {
$route: routes[0],
},
plugins: [router],
stubs: {
RouterLink: RouterLinkStub,
},
},
});

const breadcrumbItems = wrapper.findAll('li');
expect(breadcrumbItems).toHaveLength(1);
// @ts-expect-error
expect(breadcrumbItems.at(0).text()).toBe('Product');
// @ts-expect-error
expect(breadcrumbItems.at(0).classes()).toContain('is-active');
});

it('renders breadcrumbs correctly for a single-nested route', async () => {
router.push({ name: 'Product ID', params: { id: 1234 } });
await router.isReady();
await flushPromises();

const wrapper = mount(PdapBreadcrumbs, {
global: {
mocks: {
$route: routes[0].children[0],
},
plugins: [router],
stubs: {
RouterLink: RouterLinkStub,
},
},
});

const breadcrumbItems = wrapper.findAll('li');
expect(breadcrumbItems).toHaveLength(2);
// @ts-expect-error
expect(breadcrumbItems.at(0).text()).toBe('Product');
// @ts-expect-error
expect(breadcrumbItems.at(1).text()).toBe('Product ID');
// @ts-expect-error
expect(breadcrumbItems.at(1).classes()).toContain('is-active');

expect(wrapper.html()).toMatchSnapshot();
});

it('renders breadcrumbs correctly for a double-nested route', async () => {
router.push({ name: 'Edit Product ID', params: { id: 1234 } });
await router.isReady();
await flushPromises();

const wrapper = mount(PdapBreadcrumbs, {
global: {
mocks: {
$route: routes[0].children[0].children[0],
},
plugins: [router],
stubs: {
RouterLink: RouterLinkStub,
},
},
});

const breadcrumbItems = wrapper.findAll('li');
expect(breadcrumbItems).toHaveLength(3);
// @ts-expect-error
expect(breadcrumbItems.at(0).text()).toBe('Product');
// @ts-expect-error
expect(breadcrumbItems.at(1).text()).toBe('Product ID');
// @ts-expect-error
expect(breadcrumbItems.at(2).text()).toBe('Edit Product ID');
// @ts-expect-error
expect(breadcrumbItems.at(2).classes()).toContain('is-active');

expect(wrapper.html()).toMatchSnapshot();
});
});
3 changes: 3 additions & 0 deletions src/components/Breadcrumbs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import PdapBreadcrumbs from './PdapBreadcrumbs.vue';

export { PdapBreadcrumbs as Breadcrumbs };
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { Header } from './Header';
export { Nav } from './Nav';
export { QuickSearchForm } from './QuickSearchForm';
export { TileIcon } from './TileIcon';
export { Breadcrumbs } from './Breadcrumbs';
15 changes: 14 additions & 1 deletion src/demo/pages/ComponentDemo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
<h6>This is a sixth-level heading</h6>

<div class="pdap-grid-container mt-5">
<h2 class="col-span-full">Breadcrumbs</h2>
<div
class="col-span-2 pdap-flex-container flex-row justify-between w-full"
>
Click to navigate:
<router-link to="/">Home</router-link>
<router-link to="/foo">Foo</router-link>
<router-link to="/foo/bar">FooBar</router-link>
<router-link to="/foo/bar/baz">FooBarBaz</router-link>
</div>

<Breadcrumbs class="col-span-full" />

<h2 class="col-span-full mb-0">Buttons</h2>
<p class="col-span-full max-w-none">
These are all contained within a grid container, defined by using the
Expand Down Expand Up @@ -68,7 +81,7 @@

<script setup lang="ts">
import { PdapInputTypes } from '../../components/Input/types';
import { Button, Form, QuickSearchForm } from '../../components';
import { Breadcrumbs, Button, Form, QuickSearchForm } from '../../components';
const mockFormSchema = [
{
Expand Down
33 changes: 33 additions & 0 deletions src/demo/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,39 @@ const routes = [
path: '/',
component: ComponentDemo,
name: 'ComponentDemo',
meta: {
breadcrumbText: 'Home',
},
children: [
{
path: '/foo',
component: ComponentDemo,
name: 'ComponentDemoFoo',
meta: {
breadcrumbText: 'Foo',
},
children: [
{
path: '/foo/bar',
component: ComponentDemo,
name: 'ComponentDemoFooBar',
meta: {
breadcrumbText: 'Bar',
},
children: [
{
path: '/foo/bar/baz',
component: ComponentDemo,
name: 'ComponentDemoFooBarBaz',
meta: {
breadcrumbText: 'Baz',
},
},
],
},
],
},
],
},
];

Expand Down
66 changes: 66 additions & 0 deletions src/utils/breadcrumbs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// breadcrumbs.test.ts
import { getBreadcrumbs, BreadcrumbItem } from './breadcrumbs';
import { RouteLocationNormalized } from 'vue-router';
import { describe, it, expect } from 'vitest';

describe('getBreadcrumbs', () => {
it('should return an empty array when the route has no matched routes', () => {
const route = {
matched: [],
// other properties omitted for brevity
};

const breadcrumbs = getBreadcrumbs(
route as unknown as RouteLocationNormalized
);

expect(breadcrumbs).toEqual([]);
});

it('should return breadcrumbs with correct properties', () => {
const route = {
matched: [
{
name: 'Home',
path: '/',
meta: { breadcrumbText: 'Home' },
},
{
name: 'About',
path: '/about',
meta: {},
},
{
name: 'Contact',
path: '/contact',
meta: { breadcrumbText: 'Get in Touch' },
},
],
// other properties omitted for brevity
};

const expectedBreadcrumbs: BreadcrumbItem[] = [
{
text: 'Home',
path: '/',
active: false,
},
{
text: 'About',
path: '/about',
active: false,
},
{
text: 'Get in Touch',
path: '/contact',
active: true,
},
];

const breadcrumbs = getBreadcrumbs(
route as unknown as RouteLocationNormalized
);

expect(breadcrumbs).toEqual(expectedBreadcrumbs);
});
});
33 changes: 33 additions & 0 deletions src/utils/breadcrumbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { RouteLocationNormalized } from 'vue-router';

export interface BreadcrumbItem {
text: string;
path: string;
active: boolean;
}

export function getBreadcrumbs(
route: RouteLocationNormalized
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];

for (const matched of route.matched) {
const { name, path, meta } = matched;
const breadcrumbText = meta.breadcrumbText ?? name;

if (breadcrumbText) {
breadcrumbs.push({
text: breadcrumbText as string,
path,
active: false,
});
}
}

// Set "active" if breadcrumbs > 1
if (breadcrumbs.length > 0) {
breadcrumbs[breadcrumbs.length - 1].active = true;
}

return breadcrumbs;
}

0 comments on commit a93329b

Please sign in to comment.