4 Commits

Author SHA1 Message Date
Anton Gunnarsson
1dffeb6be7 Merged in fix/loy-603-fix-edit-profile-redirect (pull request #3543)
fix(LOY-603): Remove refresh preventing redirect

* Remove refresh preventing redirect


Approved-by: Matilda Landström
2026-02-05 07:45:19 +00:00
Anton Gunnarsson
1f1ed2e4f3 Merged in chore/update-sas-banner-image (pull request #3545)
fix(LOY-621): Replace scandic banner image and reduce size

* Replace scandic banner image and reduce size


Approved-by: Emma Zettervall
Approved-by: Matilda Landström
2026-02-05 07:44:54 +00:00
Linus Flood
bc9eaf6706 Merged in fix/robots-txt (pull request #3547)
feat(robots): fixed robots.txt file

* feat(robots): fixed robots.txt file
2026-02-05 07:38:15 +00:00
Matilda Landström
549265cd34 Merged in feat/LOY-615-cleanup-env-prof-consent (pull request #3537)
feat(LOY-615): cleanup profiling consent env var

* feat(LOY-615): cleanup profiling consent env var


Approved-by: Anton Gunnarsson
2026-02-04 16:51:06 +00:00
29 changed files with 72 additions and 1072 deletions

View File

@@ -1,12 +1,7 @@
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import {
getEurobonusMembership,
scandicMembershipTypes,
} from "@scandic-hotels/trpc/routers/user/helpers"
import { getEurobonusMembership } from "@scandic-hotels/trpc/routers/user/helpers"
import { env } from "@/env/server"
import {
getBasicProfileSafely,
getProfileSafely,
getProfilingConsent,
} from "@/lib/trpc/memoizedRequests"
@@ -26,15 +21,7 @@ type MyPagesLayoutProps = React.PropsWithChildren<{
breadcrumbs: React.ReactNode
}>
export default async function MyPagesLayout(props: MyPagesLayoutProps) {
if (env.ENABLE_PROFILE_CONSENT) {
return <MyPagesLayoutWithConsent {...props} />
}
return <MyPagesLayoutBase {...props} />
}
async function MyPagesLayoutWithConsent({
export default async function MyPagesLayout({
breadcrumbs,
children,
}: MyPagesLayoutProps) {
@@ -84,25 +71,3 @@ async function MyPagesLayoutWithConsent({
</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,24 +1,13 @@
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,5 +1,3 @@
import { env } from "@/env/server"
import SignupForm from "@/components/Forms/Signup"
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
@@ -7,10 +5,5 @@ import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicCo
export default async function SignupFormWrapper({
dynamic_content,
}: SignupFormWrapperProps) {
return (
<SignupForm
{...dynamic_content}
enableProfileConsent={env.ENABLE_PROFILE_CONSENT}
/>
)
return <SignupForm {...dynamic_content} />
}

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,7 +7,6 @@ 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"
@@ -18,6 +17,7 @@ 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,7 +3,6 @@ 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"
@@ -17,6 +16,7 @@ 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,7 +107,6 @@ export default function Form({ user }: EditFormProps) {
} else {
router.push(profile[lang])
}
router.refresh() // Can be removed on NextJs 15
}
break
}

View File

@@ -48,14 +48,9 @@ import styles from "./form.module.css"
interface SignUpFormProps {
title: string
enableProfileConsent?: boolean
}
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) {
export default function SignupForm({ title }: SignUpFormProps) {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
@@ -140,7 +135,7 @@ export default function SignupForm({
return (
<div className={styles.formWrapper}>
{enableProfileConsent && <ProfilingConsentModalReadOnly />}
<ProfilingConsentModalReadOnly />
{title ? (
<Typography variant="Title/md">
<h2>{title}</h2>
@@ -293,7 +288,6 @@ export default function SignupForm({
/>
</section>
{enableProfileConsent && (
<section className={styles.personalization}>
<header>
<Typography variant="Title/Subtitle/md">
@@ -327,7 +321,6 @@ export default function SignupForm({
})}
</TextLinkButton>
</section>
)}
<section className={styles.terms}>
<header>

View File

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

View File

@@ -1,6 +1,5 @@
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"
@@ -9,8 +8,6 @@ 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. The feature is controlled by the `ENABLE_PROFILE_CONSENT` environment variable.
Profiling consent allows users to opt in/out of personalized experiences.
## User Journey
@@ -121,11 +121,9 @@ 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

@@ -0,0 +1,29 @@
"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,11 +96,6 @@ 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()
@@ -160,7 +155,6 @@ 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: 847 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 208 KiB

View File

@@ -1,144 +0,0 @@
/* 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

@@ -1,148 +0,0 @@
/* 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

@@ -1,85 +0,0 @@
/* 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

@@ -1,86 +0,0 @@
/* 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

@@ -1,116 +0,0 @@
/* 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

@@ -1,104 +0,0 @@
/* 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

@@ -1,105 +0,0 @@
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

@@ -1,43 +0,0 @@
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

@@ -1,92 +0,0 @@
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

@@ -1,23 +0,0 @@
"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

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

View File

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

View File

@@ -20,7 +20,6 @@
"./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",