diff --git a/packages/design-system/lib/components/ChipButton/ChipButton.a11y.test.tsx b/packages/design-system/lib/components/ChipButton/ChipButton.a11y.test.tsx new file mode 100644 index 000000000..77339a8d6 --- /dev/null +++ b/packages/design-system/lib/components/ChipButton/ChipButton.a11y.test.tsx @@ -0,0 +1,144 @@ +/* eslint-disable formatjs/no-literal-string-in-jsx */ +import { describe, expect, it, vi, afterEach } from "vitest" +import { render, screen, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { ChipButton } from "./ChipButton" + +afterEach(() => { + cleanup() +}) + +describe("ChipButton accessibility", () => { + describe("semantic HTML", () => { + it("uses proper button element", () => { + render(Button) + const button = screen.getByRole("button") + expect(button.tagName).toBe("BUTTON") + }) + + it("has accessible button text", () => { + render(Filter by price) + expect( + screen.getByRole("button", { name: "Filter by price" }) + ).toBeTruthy() + }) + + it("button type defaults to button (not submit)", () => { + render(Not Submit) + const button = screen.getByRole("button") + // React Aria Components Button defaults to type="button" + expect(button.getAttribute("type")).toBe("button") + }) + }) + + describe("disabled state", () => { + it("disabled button has disabled attribute", () => { + render(Disabled) + const button = screen.getByRole("button", { name: "Disabled" }) + expect(button).toHaveProperty("disabled", true) + }) + + it("disabled button is not focusable", async () => { + const user = userEvent.setup() + render( +
+ Disabled + Enabled +
+ ) + + await user.tab() + // Focus should skip disabled button and go to enabled one + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Enabled" }) + ) + }) + }) + + describe("keyboard navigation", () => { + it("button is keyboard accessible", async () => { + const user = userEvent.setup() + render(Accessible Button) + + const button = screen.getByRole("button") + await user.tab() + expect(document.activeElement).toBe(button) + }) + + it("multiple buttons maintain logical focus order", async () => { + const user = userEvent.setup() + render( +
+ First + Second + Third +
+ ) + + const firstButton = screen.getByRole("button", { name: "First" }) + const secondButton = screen.getByRole("button", { name: "Second" }) + const thirdButton = screen.getByRole("button", { name: "Third" }) + + await user.tab() + expect(document.activeElement).toBe(firstButton) + + await user.tab() + expect(document.activeElement).toBe(secondButton) + + await user.tab() + expect(document.activeElement).toBe(thirdButton) + }) + + it("Enter key activates button", async () => { + const onPress = vi.fn() + const user = userEvent.setup() + render(Activate) + + await user.tab() + await user.keyboard("{Enter}") + expect(onPress).toHaveBeenCalledTimes(1) + }) + + it("Space key activates button", async () => { + const onPress = vi.fn() + const user = userEvent.setup() + render(Activate) + + await user.tab() + await user.keyboard(" ") + expect(onPress).toHaveBeenCalledTimes(1) + }) + }) + + describe("screen reader support", () => { + it("button has accessible name from content", () => { + render(Descriptive Button Text) + const button = screen.getByRole("button") + expect(button.textContent?.trim().length).toBeGreaterThan(0) + }) + + it("button with icon and text has accessible name", () => { + render( + + + Selected Filter + + ) + const button = screen.getByRole("button") + expect(button.textContent).toContain("Selected Filter") + }) + }) + + describe("selected state accessibility", () => { + it("selected state is indicated via aria-pressed", () => { + render( + + Selected + + ) + const button = screen.getByRole("button", { name: "Selected" }) + expect(button.getAttribute("aria-pressed")).toBe("true") + }) + }) +}) diff --git a/packages/design-system/lib/components/ChipButton/ChipButton.test.tsx b/packages/design-system/lib/components/ChipButton/ChipButton.test.tsx new file mode 100644 index 000000000..e87d2767b --- /dev/null +++ b/packages/design-system/lib/components/ChipButton/ChipButton.test.tsx @@ -0,0 +1,148 @@ +/* eslint-disable formatjs/no-literal-string-in-jsx */ +import { describe, expect, it, vi, afterEach } from "vitest" +import { render, screen, cleanup, fireEvent } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { ChipButton } from "./ChipButton" + +afterEach(() => { + cleanup() +}) + +describe("ChipButton", () => { + describe("rendering", () => { + it("renders as a button element", () => { + render(Click me) + const button = screen.getByRole("button", { name: "Click me" }) + expect(button).toBeTruthy() + expect(button.tagName).toBe("BUTTON") + }) + + it("renders children content", () => { + render(Button Content) + expect(screen.getByText("Button Content")).toBeTruthy() + }) + + it("renders with multiple children", () => { + render( + + Icon + Label + + ) + expect(screen.getByText("Icon")).toBeTruthy() + expect(screen.getByText("Label")).toBeTruthy() + }) + }) + + describe("variants", () => { + it("renders Default variant", () => { + render(Default) + expect(screen.getByRole("button", { name: "Default" })).toBeTruthy() + }) + + it("renders Outlined variant", () => { + render(Outlined) + expect(screen.getByRole("button", { name: "Outlined" })).toBeTruthy() + }) + + it("renders FilterRounded variant", () => { + render(Filter) + expect(screen.getByRole("button", { name: "Filter" })).toBeTruthy() + }) + }) + + describe("selected state", () => { + it("renders with selected=false by default", () => { + render(Not Selected) + expect(screen.getByRole("button", { name: "Not Selected" })).toBeTruthy() + }) + + it("renders with selected=true", () => { + render(Selected) + expect(screen.getByRole("button", { name: "Selected" })).toBeTruthy() + }) + }) + + describe("sizes", () => { + it("renders Medium size", () => { + render(Medium) + expect(screen.getByRole("button", { name: "Medium" })).toBeTruthy() + }) + + it("renders Large size (default)", () => { + render(Large) + expect(screen.getByRole("button", { name: "Large" })).toBeTruthy() + }) + }) + + describe("props", () => { + it("applies custom className", () => { + render(Button) + const button = screen.getByRole("button", { name: "Button" }) + expect(button.className).toContain("custom-class") + }) + + it("can be disabled", () => { + render(Disabled) + const button = screen.getByRole("button", { name: "Disabled" }) + expect(button).toHaveProperty("disabled", true) + }) + }) + + describe("interactions", () => { + it("calls onPress when clicked", () => { + const onPress = vi.fn() + render(Click me) + + // React Aria Components Button uses onPress which listens to click events + fireEvent.click(screen.getByRole("button", { name: "Click me" })) + expect(onPress).toHaveBeenCalledTimes(1) + }) + + it("does not call onPress when disabled", () => { + const onPress = vi.fn() + render( + + Disabled + + ) + + fireEvent.click(screen.getByRole("button", { name: "Disabled" })) + expect(onPress).not.toHaveBeenCalled() + }) + }) + + describe("keyboard navigation", () => { + it("is focusable via keyboard", async () => { + const user = userEvent.setup() + render(Focusable) + + const button = screen.getByRole("button", { name: "Focusable" }) + await user.tab() + expect(document.activeElement).toBe(button) + }) + + it("can be activated with Enter key", async () => { + const onPress = vi.fn() + const user = userEvent.setup() + render(Press Enter) + + screen.getByRole("button", { name: "Press Enter" }) + await user.tab() + await user.keyboard("{Enter}") + expect(onPress).toHaveBeenCalledTimes(1) + }) + + it("can be activated with Space key", async () => { + const onPress = vi.fn() + const user = userEvent.setup() + render(Press Space) + + screen.getByRole("button", { name: "Press Space" }) + await user.tab() + await user.keyboard(" ") + expect(onPress).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/design-system/lib/components/ChipLink/ChipLink.a11y.test.tsx b/packages/design-system/lib/components/ChipLink/ChipLink.a11y.test.tsx new file mode 100644 index 000000000..edc8c1ee1 --- /dev/null +++ b/packages/design-system/lib/components/ChipLink/ChipLink.a11y.test.tsx @@ -0,0 +1,85 @@ +/* eslint-disable formatjs/no-literal-string-in-jsx */ +import { describe, expect, it, afterEach } from "vitest" +import { render, screen, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { ChipLink } from "./ChipLink" + +afterEach(() => { + cleanup() +}) + +describe("ChipLink accessibility", () => { + describe("semantic HTML", () => { + it("uses proper link element for navigation", () => { + render(Test Link) + const link = screen.getByRole("link") + expect(link.tagName).toBe("A") + }) + + it("has accessible link text", () => { + render(View Hotels) + expect(screen.getByRole("link", { name: "View Hotels" })).toBeTruthy() + }) + + it("link has descriptive href attribute", () => { + render(Stockholm Hotels) + const link = screen.getByRole("link", { name: "Stockholm Hotels" }) + expect(link.getAttribute("href")).toBe("/hotels/stockholm") + }) + }) + + describe("keyboard navigation", () => { + it("link is keyboard accessible", async () => { + const user = userEvent.setup() + render(Accessible Link) + + const link = screen.getByRole("link") + await user.tab() + expect(document.activeElement).toBe(link) + }) + + it("multiple links maintain logical focus order", async () => { + const user = userEvent.setup() + render( +
+ First Link + Second Link + Third Link +
+ ) + + const firstLink = screen.getByRole("link", { name: "First Link" }) + const secondLink = screen.getByRole("link", { name: "Second Link" }) + const thirdLink = screen.getByRole("link", { name: "Third Link" }) + + await user.tab() + expect(document.activeElement).toBe(firstLink) + + await user.tab() + expect(document.activeElement).toBe(secondLink) + + await user.tab() + expect(document.activeElement).toBe(thirdLink) + }) + }) + + describe("screen reader support", () => { + it("link has accessible name from content", () => { + render(Descriptive Link Text) + const link = screen.getByRole("link") + expect(link.textContent?.trim().length).toBeGreaterThan(0) + }) + + it("link with icon and text has accessible name", () => { + render( + + + Next Page + + ) + const link = screen.getByRole("link") + expect(link.textContent).toContain("Next Page") + }) + }) +}) diff --git a/packages/design-system/lib/components/ChipLink/ChipLink.test.tsx b/packages/design-system/lib/components/ChipLink/ChipLink.test.tsx new file mode 100644 index 000000000..b549242ec --- /dev/null +++ b/packages/design-system/lib/components/ChipLink/ChipLink.test.tsx @@ -0,0 +1,86 @@ +/* eslint-disable formatjs/no-literal-string-in-jsx */ +import { describe, expect, it, afterEach } from "vitest" +import { render, screen, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { ChipLink } from "./ChipLink" + +afterEach(() => { + cleanup() +}) + +describe("ChipLink", () => { + describe("rendering", () => { + it("renders as a link element", () => { + render(Test Link) + const link = screen.getByRole("link", { name: "Test Link" }) + expect(link).toBeTruthy() + expect(link.tagName).toBe("A") + }) + + it("renders children content", () => { + render(Link Content) + expect(screen.getByText("Link Content")).toBeTruthy() + }) + + it("applies correct href attribute", () => { + render(Go somewhere) + const link = screen.getByRole("link", { name: "Go somewhere" }) + expect(link.getAttribute("href")).toBe("/destination") + }) + + it("renders with multiple children", () => { + render( + + Icon + Text + + ) + expect(screen.getByText("Icon")).toBeTruthy() + expect(screen.getByText("Text")).toBeTruthy() + }) + }) + + describe("props", () => { + it("applies custom className", () => { + render( + + Link + + ) + const link = screen.getByRole("link", { name: "Link" }) + expect(link.className).toContain("custom-class") + }) + + it("supports target attribute", () => { + render( + + External Link + + ) + const link = screen.getByRole("link", { name: "External Link" }) + expect(link.getAttribute("target")).toBe("_blank") + }) + + it("supports rel attribute", () => { + render( + + External Link + + ) + const link = screen.getByRole("link", { name: "External Link" }) + expect(link.getAttribute("rel")).toBe("noopener noreferrer") + }) + }) + + describe("keyboard navigation", () => { + it("is focusable via keyboard", async () => { + const user = userEvent.setup() + render(Focusable Link) + + const link = screen.getByRole("link", { name: "Focusable Link" }) + await user.tab() + expect(document.activeElement).toBe(link) + }) + }) +}) diff --git a/packages/design-system/lib/components/ChipStatic/ChipStatic.a11y.test.tsx b/packages/design-system/lib/components/ChipStatic/ChipStatic.a11y.test.tsx new file mode 100644 index 000000000..4e0013664 --- /dev/null +++ b/packages/design-system/lib/components/ChipStatic/ChipStatic.a11y.test.tsx @@ -0,0 +1,116 @@ +/* eslint-disable formatjs/no-literal-string-in-jsx */ +import { describe, expect, it, afterEach } from "vitest" +import { render, screen, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { ChipStatic } from "./ChipStatic" + +afterEach(() => { + cleanup() +}) + +describe("ChipStatic accessibility", () => { + describe("semantic HTML", () => { + it("uses span element (non-interactive)", () => { + render(Static Label) + const chip = screen.getByText("Static Label") + expect(chip.tagName).toBe("SPAN") + }) + + it("is not a button or link", () => { + render(Not Interactive) + expect(screen.queryByRole("button")).toBeNull() + expect(screen.queryByRole("link")).toBeNull() + }) + + it("content is visible and readable", () => { + render(Readable Content) + expect(screen.getByText("Readable Content")).toBeTruthy() + }) + }) + + describe("non-interactive behavior", () => { + it("is not focusable by default", async () => { + const user = userEvent.setup() + render( +
+ Static Chip + +
+ ) + + await user.tab() + // Focus should skip the static chip and go directly to the button + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Focusable Button" }) + ) + }) + + it("does not receive focus when tabbing through page", async () => { + const user = userEvent.setup() + render( +
+ + Static + +
+ ) + + const firstButton = screen.getByRole("button", { name: "First" }) + const secondButton = screen.getByRole("button", { name: "Second" }) + + await user.tab() + expect(document.activeElement).toBe(firstButton) + + await user.tab() + expect(document.activeElement).toBe(secondButton) + }) + }) + + describe("screen reader support", () => { + it("has visible text content", () => { + render(Screen Reader Text) + const chip = screen.getByText("Screen Reader Text") + expect(chip.textContent?.trim().length).toBeGreaterThan(0) + }) + + it("text content is accessible in the DOM", () => { + render(Status: Active) + expect(screen.getByText("Status: Active")).toBeTruthy() + }) + }) + + describe("color contrast considerations", () => { + it("Neutral color variant renders", () => { + render(Neutral) + expect(screen.getByText("Neutral")).toBeTruthy() + }) + + it("Subtle color variant renders", () => { + render(Subtle) + expect(screen.getByText("Subtle")).toBeTruthy() + }) + + it("Disabled color variant renders", () => { + render(Disabled) + expect(screen.getByText("Disabled")).toBeTruthy() + }) + }) + + describe("text sizing", () => { + it("xs size renders readable text", () => { + render(Extra Small) + expect(screen.getByText("Extra Small")).toBeTruthy() + }) + + it("sm size renders readable text", () => { + render(Small) + expect(screen.getByText("Small")).toBeTruthy() + }) + + it("lg size renders readable text", () => { + render(Large) + expect(screen.getByText("Large")).toBeTruthy() + }) + }) +}) diff --git a/packages/design-system/lib/components/ChipStatic/ChipStatic.test.tsx b/packages/design-system/lib/components/ChipStatic/ChipStatic.test.tsx new file mode 100644 index 000000000..5cf64deb4 --- /dev/null +++ b/packages/design-system/lib/components/ChipStatic/ChipStatic.test.tsx @@ -0,0 +1,104 @@ +/* eslint-disable formatjs/no-literal-string-in-jsx */ +import { describe, expect, it, afterEach } from "vitest" +import { render, screen, cleanup } from "@testing-library/react" + +import { ChipStatic } from "./ChipStatic" + +afterEach(() => { + cleanup() +}) + +describe("ChipStatic", () => { + describe("rendering", () => { + it("renders as a span element", () => { + render(Static Chip) + const chip = screen.getByText("Static Chip") + expect(chip).toBeTruthy() + expect(chip.tagName).toBe("SPAN") + }) + + it("renders children content", () => { + render(Chip Content) + expect(screen.getByText("Chip Content")).toBeTruthy() + }) + + it("renders with multiple children", () => { + render( + + Icon + Label + + ) + expect(screen.getByText("Icon")).toBeTruthy() + expect(screen.getByText("Label")).toBeTruthy() + }) + }) + + describe("color variants", () => { + it("renders Neutral color (default)", () => { + render(Neutral) + expect(screen.getByText("Neutral")).toBeTruthy() + }) + + it("renders Subtle color", () => { + render(Subtle) + expect(screen.getByText("Subtle")).toBeTruthy() + }) + + it("renders Disabled color", () => { + render(Disabled) + expect(screen.getByText("Disabled")).toBeTruthy() + }) + }) + + describe("sizes", () => { + it("renders xs size", () => { + render(Extra Small) + expect(screen.getByText("Extra Small")).toBeTruthy() + }) + + it("renders sm size (default)", () => { + render(Small) + expect(screen.getByText("Small")).toBeTruthy() + }) + + it("renders lg size", () => { + render(Large) + expect(screen.getByText("Large")).toBeTruthy() + }) + }) + + describe("typography", () => { + it("uses uppercase typography by default", () => { + render(Default Case) + expect(screen.getByText("Default Case")).toBeTruthy() + }) + + it("uses lowercase typography when lowerCase is true", () => { + render(Lower Case) + expect(screen.getByText("Lower Case")).toBeTruthy() + }) + }) + + describe("props", () => { + it("applies custom className", () => { + render(Styled) + const chip = screen.getByText("Styled") + expect(chip.className).toContain("custom-class") + }) + }) + + describe("edge cases", () => { + it("handles empty string children", () => { + const emptyString = "" + const { container } = render({emptyString}) + const span = container.querySelector("span") + expect(span).toBeTruthy() + }) + + it("handles numeric children", () => { + render({42}) + expect(screen.getByText("42")).toBeTruthy() + }) + }) +}) diff --git a/packages/design-system/lib/components/LinkChips/LinkChips.a11y.test.tsx b/packages/design-system/lib/components/LinkChips/LinkChips.a11y.test.tsx new file mode 100644 index 000000000..2f0f56c99 --- /dev/null +++ b/packages/design-system/lib/components/LinkChips/LinkChips.a11y.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, it, afterEach } from "vitest" +import { render, screen, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { LinkChips } from "./LinkChips" +import type { LinkChipsProps } from "./types" + +afterEach(() => { + cleanup() +}) + +const defaultChips: LinkChipsProps["chips"] = [ + { title: "Hotels in Stockholm", url: "/hotels/stockholm" }, + { title: "Hotels in Copenhagen", url: "/hotels/copenhagen" }, + { title: "Hotels in Oslo", url: "/hotels/oslo" }, +] + +describe("LinkChips accessibility", () => { + describe("semantic HTML", () => { + it("uses proper link elements for navigation", () => { + render() + const links = screen.getAllByRole("link") + expect(links.length).toBe(3) + links.forEach((link) => { + expect(link.tagName).toBe("A") + }) + }) + + it("has accessible link text", () => { + render() + expect( + screen.getByRole("link", { name: "Hotels in Stockholm" }) + ).toBeTruthy() + expect( + screen.getByRole("link", { name: "Hotels in Copenhagen" }) + ).toBeTruthy() + expect(screen.getByRole("link", { name: "Hotels in Oslo" })).toBeTruthy() + }) + + it("links have descriptive href attributes", () => { + render() + const stockholmLink = screen.getByRole("link", { + name: "Hotels in Stockholm", + }) + expect(stockholmLink.getAttribute("href")).toBe("/hotels/stockholm") + }) + }) + + describe("keyboard navigation", () => { + it("all links are keyboard accessible", async () => { + const user = userEvent.setup() + render() + + const links = screen.getAllByRole("link") + expect(links.length).toBeGreaterThan(0) + + // Tab through all links + for (const link of links) { + await user.tab() + expect(document.activeElement).toBe(link) + } + }) + + it("maintains logical focus order", async () => { + const user = userEvent.setup() + render() + + const firstLink = screen.getByRole("link", { + name: "Hotels in Stockholm", + }) + const secondLink = screen.getByRole("link", { + name: "Hotels in Copenhagen", + }) + const thirdLink = screen.getByRole("link", { name: "Hotels in Oslo" }) + + await user.tab() + expect(document.activeElement).toBe(firstLink) + + await user.tab() + expect(document.activeElement).toBe(secondLink) + + await user.tab() + expect(document.activeElement).toBe(thirdLink) + }) + }) + + describe("screen reader support", () => { + it("links have accessible names", () => { + render() + const links = screen.getAllByRole("link") + links.forEach((link) => { + // Check that link has text content (accessible name) + expect(link.textContent?.trim().length).toBeGreaterThan(0) + }) + }) + }) + + describe("empty state", () => { + it("does not render anything when chips array is empty", () => { + const { container } = render() + expect(container.firstChild).toBeNull() + expect(screen.queryAllByRole("link")).toHaveLength(0) + }) + }) +}) diff --git a/packages/design-system/lib/components/LinkChips/LinkChips.stories.tsx b/packages/design-system/lib/components/LinkChips/LinkChips.stories.tsx new file mode 100644 index 000000000..6beb84526 --- /dev/null +++ b/packages/design-system/lib/components/LinkChips/LinkChips.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { LinkChips } from "./LinkChips" + +const meta: Meta = { + title: "Product Components/LinkChips", + component: LinkChips, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + chips: [ + { title: "Hotels in Stockholm", url: "/hotels/stockholm" }, + { title: "Hotels in Copenhagen", url: "/hotels/copenhagen" }, + { title: "Hotels in Oslo", url: "/hotels/oslo" }, + ], + }, +} + +export const SingleChip: Story = { + args: { + chips: [{ title: "View all hotels", url: "/hotels" }], + }, +} + +export const ManyChips: Story = { + args: { + chips: [ + { title: "Stockholm", url: "/hotels/stockholm" }, + { title: "Copenhagen", url: "/hotels/copenhagen" }, + { title: "Oslo", url: "/hotels/oslo" }, + { title: "Helsinki", url: "/hotels/helsinki" }, + { title: "Gothenburg", url: "/hotels/gothenburg" }, + { title: "Malmö", url: "/hotels/malmo" }, + { title: "Bergen", url: "/hotels/bergen" }, + { title: "Tampere", url: "/hotels/tampere" }, + ], + }, +} diff --git a/packages/design-system/lib/components/LinkChips/LinkChips.test.tsx b/packages/design-system/lib/components/LinkChips/LinkChips.test.tsx new file mode 100644 index 000000000..904c1838e --- /dev/null +++ b/packages/design-system/lib/components/LinkChips/LinkChips.test.tsx @@ -0,0 +1,92 @@ +import { describe, expect, it, afterEach } from "vitest" +import { render, screen, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { LinkChips } from "./LinkChips" +import type { LinkChipsProps } from "./types" + +afterEach(() => { + cleanup() +}) + +const defaultChips: LinkChipsProps["chips"] = [ + { title: "Hotels in Stockholm", url: "/hotels/stockholm" }, + { title: "Hotels in Copenhagen", url: "/hotels/copenhagen" }, + { title: "Hotels in Oslo", url: "/hotels/oslo" }, +] + +describe("LinkChips", () => { + describe("rendering", () => { + it("renders all chip links", () => { + render() + expect( + screen.getByRole("link", { name: "Hotels in Stockholm" }) + ).toBeTruthy() + expect( + screen.getByRole("link", { name: "Hotels in Copenhagen" }) + ).toBeTruthy() + expect(screen.getByRole("link", { name: "Hotels in Oslo" })).toBeTruthy() + }) + + it("renders chip links with correct href attributes", () => { + render() + expect( + screen.getByRole("link", { name: "Hotels in Stockholm" }).getAttribute("href") + ).toBe("/hotels/stockholm") + expect( + screen.getByRole("link", { name: "Hotels in Copenhagen" }).getAttribute("href") + ).toBe("/hotels/copenhagen") + expect( + screen.getByRole("link", { name: "Hotels in Oslo" }).getAttribute("href") + ).toBe("/hotels/oslo") + }) + + it("returns null when chips array is empty", () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it("handles single chip", () => { + const singleChip = [{ title: "View all hotels", url: "/hotels" }] + render() + expect(screen.getByRole("link", { name: "View all hotels" })).toBeTruthy() + expect( + screen.getByRole("link", { name: "View all hotels" }).getAttribute("href") + ).toBe("/hotels") + }) + }) + + describe("keyboard navigation", () => { + it("allows keyboard navigation between links", async () => { + const user = userEvent.setup() + render() + + const firstLink = screen.getByRole("link", { + name: "Hotels in Stockholm", + }) + const secondLink = screen.getByRole("link", { + name: "Hotels in Copenhagen", + }) + + await user.tab() + expect(document.activeElement).toBe(firstLink) + + await user.tab() + expect(document.activeElement).toBe(secondLink) + }) + }) + + describe("edge cases", () => { + it("handles chips with duplicate titles but different URLs", () => { + const duplicateTitles: LinkChipsProps["chips"] = [ + { title: "Hotels", url: "/hotels/stockholm" }, + { title: "Hotels", url: "/hotels/copenhagen" }, + ] + render() + const links = screen.getAllByRole("link", { name: "Hotels" }) + expect(links).toHaveLength(2) + expect(links[0].getAttribute("href")).toBe("/hotels/stockholm") + expect(links[1].getAttribute("href")).toBe("/hotels/copenhagen") + }) + }) +}) diff --git a/packages/design-system/lib/components/LinkChips/LinkChips.tsx b/packages/design-system/lib/components/LinkChips/LinkChips.tsx new file mode 100644 index 000000000..c26c2fe16 --- /dev/null +++ b/packages/design-system/lib/components/LinkChips/LinkChips.tsx @@ -0,0 +1,23 @@ +"use client" + +import { ChipLink } from "../ChipLink" +import { Chips } from "../Chips" +import { MaterialIcon } from "../Icons/MaterialIcon" +import type { LinkChipsProps } from "./types" + +export function LinkChips({ chips }: LinkChipsProps) { + if (!chips.length) { + return null + } + + return ( + + {chips.map(({ title, url }) => ( + + {title} + + + ))} + + ) +} diff --git a/packages/design-system/lib/components/LinkChips/index.tsx b/packages/design-system/lib/components/LinkChips/index.tsx new file mode 100644 index 000000000..2144ce5d4 --- /dev/null +++ b/packages/design-system/lib/components/LinkChips/index.tsx @@ -0,0 +1,2 @@ +export { LinkChips } from "./LinkChips" +export type { LinkChipsProps } from "./types" diff --git a/packages/design-system/lib/components/LinkChips/types.ts b/packages/design-system/lib/components/LinkChips/types.ts new file mode 100644 index 000000000..511546979 --- /dev/null +++ b/packages/design-system/lib/components/LinkChips/types.ts @@ -0,0 +1,6 @@ +export interface LinkChipsProps { + chips: { + url: string + title: string + }[] +} diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 2ee72a2e4..c562ddc7e 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -20,6 +20,7 @@ "./ChipLink": "./lib/components/ChipLink/index.tsx", "./Chips": "./lib/components/Chips/index.tsx", "./ChipStatic": "./lib/components/ChipStatic/index.tsx", + "./LinkChips": "./lib/components/LinkChips/index.tsx", "./CodeRateCard": "./lib/components/RateCard/Code/index.tsx", "./ContentCard": "./lib/components/ContentCard/index.tsx", "./DeprecatedSelect": "./lib/components/DeprecatedSelect/index.tsx",