Compare commits

2 Commits

Author SHA1 Message Date
Rasmus Langvad
089bbe7c4f Remove old component and update usage 2026-02-04 14:34:49 +01:00
Rasmus Langvad
c62999879f Move LinkChips and add unit + a11y tests for chips components 2026-02-04 14:28:21 +01:00
17 changed files with 958 additions and 32 deletions

View File

@@ -1,13 +1,13 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { LinkChips } from "@scandic-hotels/design-system/LinkChips"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { getCampaignOverviewPage } from "@/lib/trpc/memoizedRequests"
import { TopCampaign } from "@/components/ContentType/CampaignOverviewPage/TopCampaign"
import LinkChips from "@/components/TempDesignSystem/LinkChips"
import Blocks from "./Blocks"
import CampaignOverviewPageSkeleton from "./CampaignOverviewPageSkeleton"

View File

@@ -7,6 +7,7 @@ import {
} from "@scandic-hotels/design-system/Breadcrumbs"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import Image from "@scandic-hotels/design-system/Image"
import { LinkChips } from "@scandic-hotels/design-system/LinkChips"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
@@ -17,7 +18,6 @@ import { Breadcrumbs } from "@/components/Breadcrumbs"
import HeaderDynamicContent from "@/components/Headers/DynamicContent"
import { HeroVideo } from "@/components/HeroVideo"
import MeetingPackageWidget from "@/components/MeetingPackageWidget"
import LinkChips from "@/components/TempDesignSystem/LinkChips"
import styles from "./collectionPage.module.css"

View File

@@ -3,6 +3,7 @@ import { Suspense } from "react"
import { BreadcrumbsSkeleton } from "@scandic-hotels/design-system/Breadcrumbs"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { LinkChips } from "@scandic-hotels/design-system/LinkChips"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
@@ -16,7 +17,6 @@ import { HeroVideo } from "@/components/HeroVideo"
import Sidebar from "@/components/Sidebar"
import SidebarSkeleton from "@/components/Sidebar/SidebarSkeleton"
import StickyMeetingPackageWidget from "@/components/StickyMeetingPackageWidget"
import LinkChips from "@/components/TempDesignSystem/LinkChips"
import styles from "./contentPage.module.css"

View File

@@ -1,29 +0,0 @@
"use client"
import { ChipLink } from "@scandic-hotels/design-system/ChipLink"
import { Chips } from "@scandic-hotels/design-system/Chips"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
interface LinkChipsProps {
chips: {
url: string
title: string
}[]
}
export default function LinkChips({ chips }: LinkChipsProps) {
if (!chips.length) {
return null
}
return (
<Chips>
{chips.map(({ title, url }) => (
<ChipLink key={`${title}-${url}`} href={url}>
{title}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</ChipLink>
))}
</Chips>
)
}

View File

@@ -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(<ChipButton>Button</ChipButton>)
const button = screen.getByRole("button")
expect(button.tagName).toBe("BUTTON")
})
it("has accessible button text", () => {
render(<ChipButton>Filter by price</ChipButton>)
expect(
screen.getByRole("button", { name: "Filter by price" })
).toBeTruthy()
})
it("button type defaults to button (not submit)", () => {
render(<ChipButton>Not Submit</ChipButton>)
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(<ChipButton isDisabled>Disabled</ChipButton>)
const button = screen.getByRole("button", { name: "Disabled" })
expect(button).toHaveProperty("disabled", true)
})
it("disabled button is not focusable", async () => {
const user = userEvent.setup()
render(
<div>
<ChipButton isDisabled>Disabled</ChipButton>
<ChipButton>Enabled</ChipButton>
</div>
)
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(<ChipButton>Accessible Button</ChipButton>)
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(
<div>
<ChipButton>First</ChipButton>
<ChipButton>Second</ChipButton>
<ChipButton>Third</ChipButton>
</div>
)
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(<ChipButton onPress={onPress}>Activate</ChipButton>)
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(<ChipButton onPress={onPress}>Activate</ChipButton>)
await user.tab()
await user.keyboard(" ")
expect(onPress).toHaveBeenCalledTimes(1)
})
})
describe("screen reader support", () => {
it("button has accessible name from content", () => {
render(<ChipButton>Descriptive Button Text</ChipButton>)
const button = screen.getByRole("button")
expect(button.textContent?.trim().length).toBeGreaterThan(0)
})
it("button with icon and text has accessible name", () => {
render(
<ChipButton>
<span aria-hidden="true"></span>
Selected Filter
</ChipButton>
)
const button = screen.getByRole("button")
expect(button.textContent).toContain("Selected Filter")
})
})
describe("selected state accessibility", () => {
it("selected state is indicated via aria-pressed", () => {
render(
<ChipButton selected aria-pressed={true}>
Selected
</ChipButton>
)
const button = screen.getByRole("button", { name: "Selected" })
expect(button.getAttribute("aria-pressed")).toBe("true")
})
})
})

View File

@@ -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(<ChipButton>Click me</ChipButton>)
const button = screen.getByRole("button", { name: "Click me" })
expect(button).toBeTruthy()
expect(button.tagName).toBe("BUTTON")
})
it("renders children content", () => {
render(<ChipButton>Button Content</ChipButton>)
expect(screen.getByText("Button Content")).toBeTruthy()
})
it("renders with multiple children", () => {
render(
<ChipButton>
<span>Icon</span>
Label
</ChipButton>
)
expect(screen.getByText("Icon")).toBeTruthy()
expect(screen.getByText("Label")).toBeTruthy()
})
})
describe("variants", () => {
it("renders Default variant", () => {
render(<ChipButton variant="Default">Default</ChipButton>)
expect(screen.getByRole("button", { name: "Default" })).toBeTruthy()
})
it("renders Outlined variant", () => {
render(<ChipButton variant="Outlined">Outlined</ChipButton>)
expect(screen.getByRole("button", { name: "Outlined" })).toBeTruthy()
})
it("renders FilterRounded variant", () => {
render(<ChipButton variant="FilterRounded">Filter</ChipButton>)
expect(screen.getByRole("button", { name: "Filter" })).toBeTruthy()
})
})
describe("selected state", () => {
it("renders with selected=false by default", () => {
render(<ChipButton>Not Selected</ChipButton>)
expect(screen.getByRole("button", { name: "Not Selected" })).toBeTruthy()
})
it("renders with selected=true", () => {
render(<ChipButton selected>Selected</ChipButton>)
expect(screen.getByRole("button", { name: "Selected" })).toBeTruthy()
})
})
describe("sizes", () => {
it("renders Medium size", () => {
render(<ChipButton size="Medium">Medium</ChipButton>)
expect(screen.getByRole("button", { name: "Medium" })).toBeTruthy()
})
it("renders Large size (default)", () => {
render(<ChipButton size="Large">Large</ChipButton>)
expect(screen.getByRole("button", { name: "Large" })).toBeTruthy()
})
})
describe("props", () => {
it("applies custom className", () => {
render(<ChipButton className="custom-class">Button</ChipButton>)
const button = screen.getByRole("button", { name: "Button" })
expect(button.className).toContain("custom-class")
})
it("can be disabled", () => {
render(<ChipButton isDisabled>Disabled</ChipButton>)
const button = screen.getByRole("button", { name: "Disabled" })
expect(button).toHaveProperty("disabled", true)
})
})
describe("interactions", () => {
it("calls onPress when clicked", () => {
const onPress = vi.fn()
render(<ChipButton onPress={onPress}>Click me</ChipButton>)
// 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(
<ChipButton isDisabled onPress={onPress}>
Disabled
</ChipButton>
)
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(<ChipButton>Focusable</ChipButton>)
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(<ChipButton onPress={onPress}>Press Enter</ChipButton>)
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(<ChipButton onPress={onPress}>Press Space</ChipButton>)
screen.getByRole("button", { name: "Press Space" })
await user.tab()
await user.keyboard(" ")
expect(onPress).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -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(<ChipLink href="/test">Test Link</ChipLink>)
const link = screen.getByRole("link")
expect(link.tagName).toBe("A")
})
it("has accessible link text", () => {
render(<ChipLink href="/hotels">View Hotels</ChipLink>)
expect(screen.getByRole("link", { name: "View Hotels" })).toBeTruthy()
})
it("link has descriptive href attribute", () => {
render(<ChipLink href="/hotels/stockholm">Stockholm Hotels</ChipLink>)
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(<ChipLink href="/test">Accessible Link</ChipLink>)
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(
<div>
<ChipLink href="/first">First Link</ChipLink>
<ChipLink href="/second">Second Link</ChipLink>
<ChipLink href="/third">Third Link</ChipLink>
</div>
)
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(<ChipLink href="/test">Descriptive Link Text</ChipLink>)
const link = screen.getByRole("link")
expect(link.textContent?.trim().length).toBeGreaterThan(0)
})
it("link with icon and text has accessible name", () => {
render(
<ChipLink href="/test">
<span aria-hidden="true"></span>
Next Page
</ChipLink>
)
const link = screen.getByRole("link")
expect(link.textContent).toContain("Next Page")
})
})
})

View File

@@ -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(<ChipLink href="/test">Test Link</ChipLink>)
const link = screen.getByRole("link", { name: "Test Link" })
expect(link).toBeTruthy()
expect(link.tagName).toBe("A")
})
it("renders children content", () => {
render(<ChipLink href="/test">Link Content</ChipLink>)
expect(screen.getByText("Link Content")).toBeTruthy()
})
it("applies correct href attribute", () => {
render(<ChipLink href="/destination">Go somewhere</ChipLink>)
const link = screen.getByRole("link", { name: "Go somewhere" })
expect(link.getAttribute("href")).toBe("/destination")
})
it("renders with multiple children", () => {
render(
<ChipLink href="/test">
<span>Icon</span>
Text
</ChipLink>
)
expect(screen.getByText("Icon")).toBeTruthy()
expect(screen.getByText("Text")).toBeTruthy()
})
})
describe("props", () => {
it("applies custom className", () => {
render(
<ChipLink href="/test" className="custom-class">
Link
</ChipLink>
)
const link = screen.getByRole("link", { name: "Link" })
expect(link.className).toContain("custom-class")
})
it("supports target attribute", () => {
render(
<ChipLink href="/external" target="_blank">
External Link
</ChipLink>
)
const link = screen.getByRole("link", { name: "External Link" })
expect(link.getAttribute("target")).toBe("_blank")
})
it("supports rel attribute", () => {
render(
<ChipLink href="/external" rel="noopener noreferrer">
External Link
</ChipLink>
)
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(<ChipLink href="/test">Focusable Link</ChipLink>)
const link = screen.getByRole("link", { name: "Focusable Link" })
await user.tab()
expect(document.activeElement).toBe(link)
})
})
})

View File

@@ -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(<ChipStatic>Static Label</ChipStatic>)
const chip = screen.getByText("Static Label")
expect(chip.tagName).toBe("SPAN")
})
it("is not a button or link", () => {
render(<ChipStatic>Not Interactive</ChipStatic>)
expect(screen.queryByRole("button")).toBeNull()
expect(screen.queryByRole("link")).toBeNull()
})
it("content is visible and readable", () => {
render(<ChipStatic>Readable Content</ChipStatic>)
expect(screen.getByText("Readable Content")).toBeTruthy()
})
})
describe("non-interactive behavior", () => {
it("is not focusable by default", async () => {
const user = userEvent.setup()
render(
<div>
<ChipStatic>Static Chip</ChipStatic>
<button>Focusable Button</button>
</div>
)
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(
<div>
<button>First</button>
<ChipStatic>Static</ChipStatic>
<button>Second</button>
</div>
)
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(<ChipStatic>Screen Reader Text</ChipStatic>)
const chip = screen.getByText("Screen Reader Text")
expect(chip.textContent?.trim().length).toBeGreaterThan(0)
})
it("text content is accessible in the DOM", () => {
render(<ChipStatic>Status: Active</ChipStatic>)
expect(screen.getByText("Status: Active")).toBeTruthy()
})
})
describe("color contrast considerations", () => {
it("Neutral color variant renders", () => {
render(<ChipStatic color="Neutral">Neutral</ChipStatic>)
expect(screen.getByText("Neutral")).toBeTruthy()
})
it("Subtle color variant renders", () => {
render(<ChipStatic color="Subtle">Subtle</ChipStatic>)
expect(screen.getByText("Subtle")).toBeTruthy()
})
it("Disabled color variant renders", () => {
render(<ChipStatic color="Disabled">Disabled</ChipStatic>)
expect(screen.getByText("Disabled")).toBeTruthy()
})
})
describe("text sizing", () => {
it("xs size renders readable text", () => {
render(<ChipStatic size="xs">Extra Small</ChipStatic>)
expect(screen.getByText("Extra Small")).toBeTruthy()
})
it("sm size renders readable text", () => {
render(<ChipStatic size="sm">Small</ChipStatic>)
expect(screen.getByText("Small")).toBeTruthy()
})
it("lg size renders readable text", () => {
render(<ChipStatic size="lg">Large</ChipStatic>)
expect(screen.getByText("Large")).toBeTruthy()
})
})
})

View File

@@ -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(<ChipStatic>Static Chip</ChipStatic>)
const chip = screen.getByText("Static Chip")
expect(chip).toBeTruthy()
expect(chip.tagName).toBe("SPAN")
})
it("renders children content", () => {
render(<ChipStatic>Chip Content</ChipStatic>)
expect(screen.getByText("Chip Content")).toBeTruthy()
})
it("renders with multiple children", () => {
render(
<ChipStatic>
<span>Icon</span>
Label
</ChipStatic>
)
expect(screen.getByText("Icon")).toBeTruthy()
expect(screen.getByText("Label")).toBeTruthy()
})
})
describe("color variants", () => {
it("renders Neutral color (default)", () => {
render(<ChipStatic color="Neutral">Neutral</ChipStatic>)
expect(screen.getByText("Neutral")).toBeTruthy()
})
it("renders Subtle color", () => {
render(<ChipStatic color="Subtle">Subtle</ChipStatic>)
expect(screen.getByText("Subtle")).toBeTruthy()
})
it("renders Disabled color", () => {
render(<ChipStatic color="Disabled">Disabled</ChipStatic>)
expect(screen.getByText("Disabled")).toBeTruthy()
})
})
describe("sizes", () => {
it("renders xs size", () => {
render(<ChipStatic size="xs">Extra Small</ChipStatic>)
expect(screen.getByText("Extra Small")).toBeTruthy()
})
it("renders sm size (default)", () => {
render(<ChipStatic size="sm">Small</ChipStatic>)
expect(screen.getByText("Small")).toBeTruthy()
})
it("renders lg size", () => {
render(<ChipStatic size="lg">Large</ChipStatic>)
expect(screen.getByText("Large")).toBeTruthy()
})
})
describe("typography", () => {
it("uses uppercase typography by default", () => {
render(<ChipStatic>Default Case</ChipStatic>)
expect(screen.getByText("Default Case")).toBeTruthy()
})
it("uses lowercase typography when lowerCase is true", () => {
render(<ChipStatic lowerCase>Lower Case</ChipStatic>)
expect(screen.getByText("Lower Case")).toBeTruthy()
})
})
describe("props", () => {
it("applies custom className", () => {
render(<ChipStatic className="custom-class">Styled</ChipStatic>)
const chip = screen.getByText("Styled")
expect(chip.className).toContain("custom-class")
})
})
describe("edge cases", () => {
it("handles empty string children", () => {
const emptyString = ""
const { container } = render(<ChipStatic>{emptyString}</ChipStatic>)
const span = container.querySelector("span")
expect(span).toBeTruthy()
})
it("handles numeric children", () => {
render(<ChipStatic>{42}</ChipStatic>)
expect(screen.getByText("42")).toBeTruthy()
})
})
})

View File

@@ -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(<LinkChips chips={defaultChips} />)
const links = screen.getAllByRole("link")
expect(links.length).toBe(3)
links.forEach((link) => {
expect(link.tagName).toBe("A")
})
})
it("has accessible link text", () => {
render(<LinkChips chips={defaultChips} />)
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(<LinkChips chips={defaultChips} />)
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(<LinkChips chips={defaultChips} />)
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(<LinkChips chips={defaultChips} />)
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(<LinkChips chips={defaultChips} />)
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(<LinkChips chips={[]} />)
expect(container.firstChild).toBeNull()
expect(screen.queryAllByRole("link")).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { LinkChips } from "./LinkChips"
const meta: Meta<typeof LinkChips> = {
title: "Product Components/LinkChips",
component: LinkChips,
}
export default meta
type Story = StoryObj<typeof LinkChips>
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" },
],
},
}

View File

@@ -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(<LinkChips chips={defaultChips} />)
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(<LinkChips chips={defaultChips} />)
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(<LinkChips chips={[]} />)
expect(container.firstChild).toBeNull()
})
it("handles single chip", () => {
const singleChip = [{ title: "View all hotels", url: "/hotels" }]
render(<LinkChips chips={singleChip} />)
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(<LinkChips chips={defaultChips} />)
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(<LinkChips chips={duplicateTitles} />)
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")
})
})
})

View File

@@ -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>
{chips.map(({ title, url }) => (
<ChipLink key={`${title}-${url}`} href={url}>
{title}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</ChipLink>
))}
</Chips>
)
}

View File

@@ -0,0 +1,2 @@
export { LinkChips } from "./LinkChips"
export type { LinkChipsProps } from "./types"

View File

@@ -0,0 +1,6 @@
export interface LinkChipsProps {
chips: {
url: string
title: string
}[]
}

View File

@@ -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",