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