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
29 changed files with 1073 additions and 73 deletions

View File

@@ -1,7 +1,12 @@
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { getEurobonusMembership } from "@scandic-hotels/trpc/routers/user/helpers"
import {
getEurobonusMembership,
scandicMembershipTypes,
} from "@scandic-hotels/trpc/routers/user/helpers"
import { env } from "@/env/server"
import {
getBasicProfileSafely,
getProfileSafely,
getProfilingConsent,
} from "@/lib/trpc/memoizedRequests"
@@ -21,7 +26,15 @@ type MyPagesLayoutProps = React.PropsWithChildren<{
breadcrumbs: React.ReactNode
}>
export default async function MyPagesLayout({
export default async function MyPagesLayout(props: MyPagesLayoutProps) {
if (env.ENABLE_PROFILE_CONSENT) {
return <MyPagesLayoutWithConsent {...props} />
}
return <MyPagesLayoutBase {...props} />
}
async function MyPagesLayoutWithConsent({
breadcrumbs,
children,
}: MyPagesLayoutProps) {
@@ -71,3 +84,25 @@ export default async function MyPagesLayout({
</ProfilingConsentAlertProvider>
)
}
async function MyPagesLayoutBase({
breadcrumbs,
children,
}: MyPagesLayoutProps) {
const profile = await getBasicProfileSafely()
const eurobonusMembership = profile?.loyalty?.memberships?.find(
(m) => m.membershipType === scandicMembershipTypes.SAS_EB
)
return (
<div className={styles.container}>
<div className={styles.layout}>
{breadcrumbs}
<div className={styles.content}>{children}</div>
</div>
{eurobonusMembership && <SASLevelUpgradeCheck />}
<Surprises />
</div>
)
}

View File

@@ -1,13 +1,24 @@
import { redirect } from "next/navigation"
import { profile } from "@scandic-hotels/common/constants/routes/myPages"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { ProfilingConsent } from "@/components/Forms/ProfilingConsent"
import { getLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
export default async function ProfilingConsentSlot() {
const lang = await getLang()
if (!env.ENABLE_PROFILE_CONSENT) {
redirect(profile[lang])
}
const caller = await serverClient()
const accountPage = await caller.contentstack.accountPage.get()
const user = await getProfile()

View File

@@ -1,3 +1,5 @@
import { env } from "@/env/server"
import SignupForm from "@/components/Forms/Signup"
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
@@ -5,5 +7,10 @@ import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicCo
export default async function SignupFormWrapper({
dynamic_content,
}: SignupFormWrapperProps) {
return <SignupForm {...dynamic_content} />
return (
<SignupForm
{...dynamic_content}
enableProfileConsent={env.ENABLE_PROFILE_CONSENT}
/>
)
}

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

@@ -107,6 +107,7 @@ export default function Form({ user }: EditFormProps) {
} else {
router.push(profile[lang])
}
router.refresh() // Can be removed on NextJs 15
}
break
}

View File

@@ -48,9 +48,14 @@ import styles from "./form.module.css"
interface SignUpFormProps {
title: string
enableProfileConsent?: boolean
}
export default function SignupForm({ title }: SignUpFormProps) {
export default function SignupForm({
title,
// Handled as a prop rather than a client env var due to limits in Netlify env var size.
enableProfileConsent = false,
}: SignUpFormProps) {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
@@ -135,7 +140,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
return (
<div className={styles.formWrapper}>
<ProfilingConsentModalReadOnly />
{enableProfileConsent && <ProfilingConsentModalReadOnly />}
{title ? (
<Typography variant="Title/md">
<h2>{title}</h2>
@@ -288,39 +293,41 @@ export default function SignupForm({ title }: SignUpFormProps) {
/>
</section>
<section className={styles.personalization}>
<header>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "signup.UnlockYourPersonalizedExperience",
defaultMessage: "Unlock your personalized experience!",
})}
</h3>
</Typography>
</header>
<Checkbox
name="profilingConsent"
registerOptions={{ required: true }}
>
{intl.formatMessage({
id: "signup.yesConsent",
defaultMessage:
"I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
})}
</Checkbox>
<TextLinkButton
typography="Link/sm"
color="Primary"
className={styles.personalizationButton}
onClick={openPersonalizationModal}
>
{intl.formatMessage({
id: "signup.ReadMoreAboutPersonalization",
defaultMessage: "Read more about personalization at Scandic",
})}
</TextLinkButton>
</section>
{enableProfileConsent && (
<section className={styles.personalization}>
<header>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "signup.UnlockYourPersonalizedExperience",
defaultMessage: "Unlock your personalized experience!",
})}
</h3>
</Typography>
</header>
<Checkbox
name="profilingConsent"
registerOptions={{ required: true }}
>
{intl.formatMessage({
id: "signup.yesConsent",
defaultMessage:
"I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
})}
</Checkbox>
<TextLinkButton
typography="Link/sm"
color="Primary"
className={styles.personalizationButton}
onClick={openPersonalizationModal}
>
{intl.formatMessage({
id: "signup.ReadMoreAboutPersonalization",
defaultMessage: "Read more about personalization at Scandic",
})}
</TextLinkButton>
</section>
)}
<section className={styles.terms}>
<header>

View File

@@ -1,3 +1,5 @@
import { env } from "@/env/server"
import { getIntl } from "@/i18n"
import { Section } from "../Section"
@@ -15,7 +17,7 @@ export async function CommunicationSettings() {
})}
>
<EmailSlot />
<PersonalizationSlot />
{env.ENABLE_PROFILE_CONSENT && <PersonalizationSlot />}
</Section>
)
}

View File

@@ -1,5 +1,6 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "@/env/server"
import { getProfile, getProfilingConsent } from "@/lib/trpc/memoizedRequests"
import { GetMainIconByCSIdentifier, userHasConsent } from "../utils"
@@ -8,6 +9,8 @@ import { BannerButton } from "./Button"
import styles from "./profilingConsentBanner.module.css"
export async function ProfilingConsentBanner() {
if (!env.ENABLE_PROFILE_CONSENT) return null
const user = await getProfile()
if (!user || userHasConsent(user?.profilingConsent)) return null

View File

@@ -1,6 +1,6 @@
# Profiling Consent
Profiling consent allows users to opt in/out of personalized experiences.
Profiling consent allows users to opt in/out of personalized experiences. The feature is controlled by the `ENABLE_PROFILE_CONSENT` environment variable.
## User Journey
@@ -121,9 +121,11 @@ Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
Required content for the feature:
1. **Profiling Consent (config)**
- Config needs to be created and published in each language
2. **/consent (account page)**
- Page needs to be created and published in each language
3. **/overview (account page)**

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

@@ -96,6 +96,11 @@ export const env = createEnv({
.refine((s) => s === "1" || s === "0")
.transform((s) => s === "1")
.default("0"),
ENABLE_PROFILE_CONSENT: z
.string()
.refine((s) => s === "true" || s === "false")
.transform((s) => s === "true")
.default("false"),
RELEASE_TAG: z
.string()
.optional()
@@ -155,6 +160,7 @@ export const env = createEnv({
DTMC_ENTRA_ID_SECRET: process.env.DTMC_ENTRA_ID_SECRET,
CHATBOT_LIVE_LANGS: process.env.CHATBOT_LIVE_LANGS,
SEO_INERT: process.env.SEO_INERT,
ENABLE_PROFILE_CONSENT: process.env.ENABLE_PROFILE_CONSENT,
RELEASE_TAG: process.env.NEXT_PUBLIC_RELEASE_TAG,
},
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 847 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 4.4 MiB

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