From d66df8bd9e21329a531f6192626b44924f3a48d6 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 9 Dec 2024 14:45:23 +0100 Subject: [PATCH] Add breadcrumb component --- .../Breadcrumb-Default-1-chromium-linux.png | Bin 0 -> 6779 bytes .../Breadcrumb/Breadcrumb.module.css | 65 +++++++++ .../Breadcrumb/Breadcrumb.stories.tsx | 69 ++++++++++ src/components/Breadcrumb/Breadcrumb.test.tsx | 63 +++++++++ src/components/Breadcrumb/Breadcrumb.tsx | 126 ++++++++++++++++++ .../__snapshots__/Breadcrumb.test.tsx.snap | 68 ++++++++++ src/components/Breadcrumb/index.ts | 17 +++ 7 files changed, 408 insertions(+) create mode 100644 playwright/visual.test.ts-snapshots/Breadcrumb-Default-1-chromium-linux.png create mode 100644 src/components/Breadcrumb/Breadcrumb.module.css create mode 100644 src/components/Breadcrumb/Breadcrumb.stories.tsx create mode 100644 src/components/Breadcrumb/Breadcrumb.test.tsx create mode 100644 src/components/Breadcrumb/Breadcrumb.tsx create mode 100644 src/components/Breadcrumb/__snapshots__/Breadcrumb.test.tsx.snap create mode 100644 src/components/Breadcrumb/index.ts diff --git a/playwright/visual.test.ts-snapshots/Breadcrumb-Default-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Breadcrumb-Default-1-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..80dea24692b0e199bbb96db385bf1db3443e87ec GIT binary patch literal 6779 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 literal 0 HcmV?d00001 diff --git a/src/components/Breadcrumb/Breadcrumb.module.css b/src/components/Breadcrumb/Breadcrumb.module.css new file mode 100644 index 00000000..9be031a5 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.module.css @@ -0,0 +1,65 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.breadcrumb { + display: flex; + align-items: center; + block-size: 40px; + gap: var(--cpd-space-3x); + padding-block-end: var(--cpd-space-3x); + border-block-end: 1px solid var(--cpd-color-alpha-gray-400); + box-sizing: border-box; + + .pages { + display: flex; + gap: var(--cpd-space-1x); + + /* override list styles */ + list-style: none; + margin: 0; + padding: 0; + + a { + cursor: pointer; + } + + .last-page { + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-secondary); + } + + /* + * Breadcrumb separator + * We want this separator to be only visual and to not be in the accessibility tree. + * The nav html element already provides an accessible way to separate the links. + */ + li + li::before { + display: inline-block; + margin: 0 0.3em 0 0.25em; + transform: rotate(15deg); + border-inline-end: 0.1em solid var(--cpd-color-text-secondary); + block-size: 0.75em; + content: ""; + } + + /* Last page */ + :last-child { + span { + padding-inline-start: 0.25rem; + } + } + } +} diff --git a/src/components/Breadcrumb/Breadcrumb.stories.tsx b/src/components/Breadcrumb/Breadcrumb.stories.tsx new file mode 100644 index 00000000..761109f5 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.stories.tsx @@ -0,0 +1,69 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Breadcrumb as BreadcrumbComponent } from "./Breadcrumb"; +import { Meta, StoryFn } from "@storybook/react"; +import React, { ComponentProps, useState } from "react"; +import { Button } from "../Button"; + +export default { + title: "Breadcrumb", + component: BreadcrumbComponent, + tags: ["autodocs"], + argTypes: {}, + args: { + pages: ["1st level page", "2nd level page", "Current page"], + }, +} as Meta; + +function BreadcrumbStory(args: ComponentProps) { + const pagesContent = ["Page 1", "Page 2", "Page 3"]; + const [currentIndex, setCurrentIndex] = useState(2); + const currentPage = pagesContent[currentIndex]; + + return ( +
+ setCurrentIndex(index)} + onBackClick={() => + setCurrentIndex((_currentIndex) => + _currentIndex === 0 ? 0 : _currentIndex - 1, + ) + } + /> + {currentPage} + +
+ ); +} + +const Template: StoryFn = ( + args: ComponentProps, +) => { + return ; +}; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/src/components/Breadcrumb/Breadcrumb.test.tsx b/src/components/Breadcrumb/Breadcrumb.test.tsx new file mode 100644 index 00000000..1105a2d1 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.test.tsx @@ -0,0 +1,63 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, expect, it, vi } from "vitest"; +import { composeStories } from "@storybook/react"; +import * as stories from "./Breadcrumb.stories.tsx"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { Breadcrumb } from "./Breadcrumb.tsx"; + +const { Default } = composeStories(stories); + +describe("Breadcrumb", () => { + it("should render", () => { + render(); + expect(screen.getByRole("navigation")).toMatchSnapshot(); + }); + + it("should call onPageClick when a page is clicked", async () => { + const user = userEvent.setup(); + const onPageClick = vi.fn(); + const onBackClick = vi.fn(); + + render( + , + ); + + // Click listener + await user.click(screen.getByRole("button", { name: "1st level page" })); + expect(onPageClick).toHaveBeenCalledWith("1st level page", 0); + + onPageClick.mockReset(); + // Keyboard listener + await user.type( + screen.getByRole("button", { name: "1st level page" }), + " ", + ); + expect(onPageClick).toHaveBeenCalledWith("1st level page", 0); + + // Back button + await user.click(screen.getByRole("button", { name: "Back" })); + expect(onBackClick).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 00000000..dcc82688 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,126 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { HTMLProps, JSX, MouseEventHandler, KeyboardEvent } from "react"; +import { IconButton } from "../Button"; +import Chevron from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left"; +import styles from "./Breadcrumb.module.css"; +import { Link } from "../Link/Link.tsx"; +import classNames from "classnames"; + +interface BreadcrumbProps extends HTMLProps { + /** + * The label for the back button. + */ + backLabel: string; + /** + * The click handler for the back button. + */ + onBackClick: MouseEventHandler; + /** + * The pages to display in the breadcrumb. + * All the pages except the last one are displayed as links. + */ + pages: string[]; + /** + * The click handler for a page. + * @param page - The page that was clicked. + * @param index - The index of the page that was clicked. + */ + onPageClick: (page: string, index: number) => void; +} + +/** + * A breadcrumb component. + */ +export function Breadcrumb({ + backLabel, + onBackClick, + pages, + onPageClick, + className, + ...props +}: BreadcrumbProps): JSX.Element { + return ( + + ); +} + +interface PageProps { + /** + * The page to display. + */ + page: string; + /** + * Whether this is the last page in the breadcrumb. + */ + isLastPage: boolean; + /** + * The click handler for the page, ignore for last page. + */ + onClick: () => void; +} + +/** + * A breadcrumb page. + * If not the last page, the page is displayed in a link. + */ +function Page({ page, isLastPage, onClick }: PageProps): JSX.Element { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === " ") { + onClick(); + } + }; + + return ( +
  • + {isLastPage ? ( + + {page} + + ) : ( + + {page} + + )} +
  • + ); +} diff --git a/src/components/Breadcrumb/__snapshots__/Breadcrumb.test.tsx.snap b/src/components/Breadcrumb/__snapshots__/Breadcrumb.test.tsx.snap new file mode 100644 index 00000000..53e277db --- /dev/null +++ b/src/components/Breadcrumb/__snapshots__/Breadcrumb.test.tsx.snap @@ -0,0 +1,68 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Breadcrumb > should render 1`] = ` + +`; diff --git a/src/components/Breadcrumb/index.ts b/src/components/Breadcrumb/index.ts new file mode 100644 index 00000000..c760b48f --- /dev/null +++ b/src/components/Breadcrumb/index.ts @@ -0,0 +1,17 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { Breadcrumb } from "./Breadcrumb";