Compare commits
2 Commits
master
...
fix/3727-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089bbe7c4f | ||
|
|
c62999879f |
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ export default function Form({ user }: EditFormProps) {
|
||||
} else {
|
||||
router.push(profile[lang])
|
||||
}
|
||||
router.refresh() // Can be removed on NextJs 15
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -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,6 +293,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{enableProfileConsent && (
|
||||
<section className={styles.personalization}>
|
||||
<header>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
@@ -321,6 +327,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
||||
})}
|
||||
</TextLinkButton>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={styles.terms}>
|
||||
<header>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)**
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
6
apps/scandic-web/env/server.ts
vendored
6
apps/scandic-web/env/server.ts
vendored
@@ -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 |
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LinkChips } from "./LinkChips"
|
||||
export type { LinkChipsProps } from "./types"
|
||||
6
packages/design-system/lib/components/LinkChips/types.ts
Normal file
6
packages/design-system/lib/components/LinkChips/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface LinkChipsProps {
|
||||
chips: {
|
||||
url: string
|
||||
title: string
|
||||
}[]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user