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 00000000..3bd0b523 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Breadcrumb-Default-1-chromium-linux.png differ diff --git a/src/components/Breadcrumb/Breadcrumb.module.css b/src/components/Breadcrumb/Breadcrumb.module.css new file mode 100644 index 00000000..991bc9fa --- /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: var(--cpd-space-3x); + content: ""; + } + + /* Last page */ + :last-child { + span { + padding-inline-start: var(--cpd-space-1x); + } + } + } +} 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";