Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions
@@ -0,0 +1,62 @@
.accordionItem {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.accordionItem.card {
padding: var(--Spacing-x1);
}
.accordionItem.sidepeek {
padding: var(--Spacing-x1) 0;
}
.summary {
position: relative;
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half) var(--Spacing-x1);
cursor: pointer;
color: var(--Base-Text-High-contrast);
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
transition: background-color 0.3s;
}
.summary.card:hover {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.accordionItem.light .summary:hover {
background-color: var(--Base-Surface-Primary-light-Hover);
}
.accordionItem.subtle .summary:hover {
background-color: var(--Base-Surface-Primary-light-Normal);
}
.accordionItem.card .summary {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
border-radius: var(--Corner-radius-Medium);
}
.accordionItem.sidepeek .summary {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x1);
align-items: center;
}
.title {
flex-grow: 1;
}
.content {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) var(--Spacing-x1);
overflow: hidden;
max-height: 0;
transition: max-height 0.3s;
}
.chevron {
transition: transform 0.3s;
flex-shrink: 0;
}
details[open] .chevron {
transform: rotate(180deg);
}
@@ -0,0 +1,13 @@
import type { VariantProps } from "class-variance-authority"
import type { IconName } from "@/types/components/icon"
import type { accordionItemVariants } from "./variants"
export interface AccordionItemProps
extends React.HtmlHTMLAttributes<HTMLDetailsElement>,
VariantProps<typeof accordionItemVariants> {
title: string
icon?: IconName
trackingId?: string
subtitle?: string
}
@@ -0,0 +1,95 @@
"use client"
import { useRef } from "react"
import { ChevronDownIcon } from "@/components/Icons"
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
import { trackAccordionClick } from "@/utils/tracking"
import Body from "../../Text/Body"
import Subtitle from "../../Text/Subtitle"
import { accordionItemVariants } from "./variants"
import styles from "./accordionItem.module.css"
import type { AccordionItemProps } from "./accordionItem"
export default function AccordionItem({
children,
icon,
title,
theme,
variant,
className,
trackingId,
subtitle,
}: AccordionItemProps) {
const contentRef = useRef<HTMLDivElement>(null)
const detailsRef = useRef<HTMLDetailsElement>(null)
const IconComp = getIconByIconName(icon)
function toggleAccordion() {
const details = detailsRef.current
const content = contentRef.current
if (details && content) {
if (details.open) {
content.style.maxHeight = `${content.scrollHeight}px`
content.addEventListener(
"transitionend",
() => {
// Remove maxHeight after transition to allow content to transition multiple times
content.style.maxHeight = "none"
},
{ once: true }
)
if (trackingId) {
trackAccordionClick(trackingId)
}
} else {
content.style.maxHeight = "0"
}
}
}
return (
<li className={accordionItemVariants({ className, variant, theme })}>
<details ref={detailsRef} onToggle={toggleAccordion}>
<summary className={styles.summary}>
{IconComp && <IconComp color="baseTextHighcontrast" />}
{variant === "sidepeek" ? (
<Subtitle
className={styles.title}
type="two"
color="baseTextHighContrast"
>
{title}
</Subtitle>
) : (
<div className={styles.title}>
{subtitle ? (
<Subtitle type="two" color="baseTextHighContrast">
{title}
</Subtitle>
) : (
<Body textTransform="bold" color="baseTextHighContrast">
{title}
</Body>
)}
{subtitle && <Body color="baseTextHighContrast">{subtitle}</Body>}
</div>
)}
<ChevronDownIcon
className={styles.chevron}
color="burgundy"
width={20}
height={20}
/>
</summary>
<div ref={contentRef} className={styles.content}>
{children}
</div>
</details>
</li>
)
}
@@ -0,0 +1,21 @@
import { cva } from "class-variance-authority"
import styles from "./accordionItem.module.css"
export const accordionItemVariants = cva(styles.accordionItem, {
variants: {
variant: {
card: styles.card,
sidepeek: styles.sidepeek,
},
theme: {
default: styles.default,
light: styles.light,
subtle: styles.subtle,
},
},
defaultVariants: {
variant: "card",
theme: "default",
},
})
@@ -0,0 +1,25 @@
.accordion {
list-style: none;
}
.accordion.card {
border-radius: var(--Corner-radius-Medium);
}
.accordion.light {
background-color: var(--Base-Surface-Primary-light-Normal);
}
.accordion.subtle {
background-color: var(--Base-Background-Primary-Normal);
}
.accordion li:last-child {
border: none;
}
.accordion details > summary {
list-style: none;
}
.accordion details > summary::-webkit-details-marker {
display: none;
}
@@ -0,0 +1,7 @@
import type { VariantProps } from "class-variance-authority"
import type { accordionVariants } from "./variants"
export interface AccordionProps
extends React.HtmlHTMLAttributes<HTMLUListElement>,
VariantProps<typeof accordionVariants> {}
@@ -0,0 +1,25 @@
import { Children, cloneElement, isValidElement } from "react"
import { accordionVariants } from "./variants"
import type { AccordionProps } from "./accordion"
import type { AccordionItemProps } from "./AccordionItem/accordionItem"
export default function Accordion({
children,
className,
theme,
variant,
}: AccordionProps) {
return (
<ul className={accordionVariants({ className, variant, theme })}>
{Children.map(children, (child) => {
if (isValidElement<AccordionItemProps>(child)) {
return cloneElement(child, { variant, theme })
} else {
return child
}
})}
</ul>
)
}
@@ -0,0 +1,21 @@
import { cva } from "class-variance-authority"
import styles from "./accordion.module.css"
export const accordionVariants = cva(styles.accordion, {
variants: {
variant: {
card: styles.card,
sidepeek: styles.sidepeek,
},
theme: {
default: styles.default,
light: styles.light,
subtle: styles.subtle,
},
},
defaultVariants: {
variant: "card",
theme: "default",
},
})
@@ -0,0 +1,48 @@
"use client"
import { useState } from "react"
import { ChevronRightIcon } from "@/components/Icons"
import JsonToHtml from "@/components/JsonToHtml"
import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "../../SidePeek"
import styles from "./sidepeek.module.css"
import type { AlertSidepeekProps } from "./sidepeek"
export default function AlertSidepeek({
ctaText,
sidePeekContent,
}: AlertSidepeekProps) {
const [sidePeekIsOpen, setSidePeekIsOpen] = useState(false)
const { heading, content } = sidePeekContent
return (
<div className={styles.alertSidepeek}>
<Button
onPress={() => setSidePeekIsOpen(true)}
theme="base"
variant="icon"
intent="text"
size="small"
wrapping
>
{ctaText}
<ChevronRightIcon height={20} width={20} />
</Button>
<SidePeek
title={heading}
isOpen={sidePeekIsOpen}
handleClose={() => setSidePeekIsOpen(false)}
>
<JsonToHtml
nodes={content.json.children}
embeds={content.embedded_itemsConnection.edges}
/>
</SidePeek>
</div>
)
}
@@ -0,0 +1,3 @@
.alertSidepeek {
flex-shrink: 0;
}
@@ -0,0 +1,6 @@
import type { SidepeekContent } from "@/types/trpc/routers/contentstack/siteConfig"
export interface AlertSidepeekProps {
ctaText: string
sidePeekContent: NonNullable<SidepeekContent>
}
@@ -0,0 +1,97 @@
.alert {
overflow: hidden;
}
.iconWrapper {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.content {
width: 100%;
max-width: var(--max-width-page);
margin: 0 auto;
display: flex;
gap: var(--Spacing-x2);
}
.innerContent {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2) 0;
flex-grow: 1;
}
.textWrapper {
display: grid;
gap: var(--Spacing-x-half);
}
/* Intent: inline */
.inline {
border-radius: var(--Corner-radius-Large);
border: 1px solid var(--Base-Border-Subtle);
background-color: var(--Base-Surface-Primary-light-Normal);
}
.inline .innerContent {
padding-right: var(--Spacing-x3);
}
.inline .iconWrapper {
padding: var(--Spacing-x-one-and-half);
}
.inline.alarm .iconWrapper {
background-color: var(--UI-Semantic-Error);
}
.inline.warning .iconWrapper {
background-color: var(--UI-Semantic-Warning);
}
.inline.info .iconWrapper {
background-color: var(--UI-Semantic-Information);
}
.inline .icon,
.inline .icon * {
fill: var(--Base-Surface-Primary-light-Normal);
}
/* Intent: banner */
.banner {
border-left-width: 6px;
border-left-style: solid;
}
.banner.alarm {
border-left-color: var(--UI-Semantic-Error);
background-color: var(--Scandic-Red-00);
}
.banner.warning {
border-left-color: var(--UI-Semantic-Warning);
background-color: var(--Scandic-Yellow-00);
}
.banner.info {
border-left-color: var(--UI-Semantic-Information);
background-color: var(--Scandic-Blue-00);
}
.banner.alarm .icon,
.banner.alarm .icon * {
fill: var(--UI-Semantic-Error);
}
.banner.warning .icon,
.banner.warning .icon * {
fill: var(--UI-Semantic-Warning);
}
.banner.info .icon,
.banner.info .icon * {
fill: var(--UI-Semantic-Information);
}
@media screen and (min-width: 768px) {
.innerContent {
flex-direction: row;
align-items: center;
gap: var(--Spacing-x2);
}
}
@@ -0,0 +1,24 @@
import type { VariantProps } from "class-variance-authority"
import type { AlertTypeEnum } from "@/types/enums/alert"
import type { SidepeekContent } from "@/types/trpc/routers/contentstack/siteConfig"
import type { alertVariants } from "./variants"
export interface AlertProps extends VariantProps<typeof alertVariants> {
className?: string
type: AlertTypeEnum
heading?: string | null
text?: string | null
phoneContact?: {
displayText: string
phoneNumber?: string
footnote?: string | null
} | null
sidepeekContent?: SidepeekContent | null
sidepeekCtaText?: string | null
link?: {
url: string
title: string
keepSearchParams?: boolean
} | null
}
@@ -0,0 +1,86 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import Link from "../Link"
import AlertSidepeek from "./Sidepeek"
import { getIconByAlertType } from "./utils"
import { alertVariants } from "./variants"
import styles from "./alert.module.css"
import type { AlertProps } from "./alert"
export default function Alert({
className,
variant,
type,
heading,
text,
link,
phoneContact,
sidepeekCtaText,
sidepeekContent,
}: AlertProps) {
const classNames = alertVariants({
className,
variant,
type,
})
const Icon = getIconByAlertType(type)
if (!text && !heading) {
return null
}
return (
<section className={classNames}>
<div className={styles.content}>
<span className={styles.iconWrapper}>
<Icon className={styles.icon} width={24} height={24} />
</span>
<div className={styles.innerContent}>
<div className={styles.textWrapper}>
{heading ? (
<Body textTransform="bold" asChild>
<h2>{heading}</h2>
</Body>
) : null}
{text ? (
<Body>
{text}
{phoneContact?.phoneNumber ? (
<>
<span> {phoneContact.displayText} </span>
<Link
color="burgundy"
href={`tel:${phoneContact.phoneNumber.replace(/ /g, "")}`}
>
{phoneContact.phoneNumber}
</Link>
{phoneContact.footnote ? (
<span>. ({phoneContact.footnote})</span>
) : null}
</>
) : null}
</Body>
) : null}
</div>
{link ? (
<Link
color="burgundy"
textDecoration="underline"
href={link.url}
keepSearchParams={link.keepSearchParams}
>
{link.title}
</Link>
) : null}
{!link && sidepeekCtaText && sidepeekContent ? (
<AlertSidepeek
ctaText={sidepeekCtaText}
sidePeekContent={sidepeekContent}
/>
) : null}
</div>
</div>
</section>
)
}
@@ -0,0 +1,17 @@
import { InfoCircleIcon } from "@/components/Icons"
import CrossCircleIcon from "@/components/Icons/CrossCircle"
import WarningTriangleIcon from "@/components/Icons/WarningTriangle"
import { AlertTypeEnum } from "@/types/enums/alert"
export function getIconByAlertType(alertType: AlertTypeEnum) {
switch (alertType) {
case AlertTypeEnum.Alarm:
return CrossCircleIcon
case AlertTypeEnum.Warning:
return WarningTriangleIcon
case AlertTypeEnum.Info:
default:
return InfoCircleIcon
}
}
@@ -0,0 +1,23 @@
import { cva } from "class-variance-authority"
import styles from "./alert.module.css"
import { AlertTypeEnum } from "@/types/enums/alert"
export const alertVariants = cva(styles.alert, {
variants: {
variant: {
inline: styles.inline,
banner: styles.banner,
},
type: {
[AlertTypeEnum.Info]: styles.info,
[AlertTypeEnum.Warning]: styles.warning,
[AlertTypeEnum.Alarm]: styles.alarm,
},
},
defaultVariants: {
variant: "inline",
type: AlertTypeEnum.Info,
},
})
@@ -0,0 +1,28 @@
.ancillaryCard {
display: flex;
flex-direction: column;
}
.imageContainer {
position: relative;
width: 100%;
aspect-ratio: 16/9;
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
}
.image {
object-fit: cover;
}
.price {
display: flex;
gap: var(--Spacing-x1);
}
.contentContainer {
padding: var(--Spacing-x-one-and-half) 0 0 0;
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
@@ -0,0 +1,64 @@
import { useIntl } from "react-intl"
import Image from "@/components/Image"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./ancillaryCard.module.css"
import type { AncillaryCardProps } from "@/types/components/ancillaryCard"
export function AncillaryCard({ ancillary }: AncillaryCardProps) {
const intl = useIntl()
return (
<article className={styles.ancillaryCard}>
<div className={styles.imageContainer}>
<Image
className={styles.image}
src={ancillary.imageUrl}
alt={ancillary.title}
fill
style={{ opacity: ancillary.imageOpacity ?? 1 }}
/>
</div>
<div className={styles.contentContainer}>
<div>
<Body textTransform="bold" color="uiTextHighContrast">
{ancillary.title}
</Body>
<div className={styles.price}>
<Body color="uiTextHighContrast">
{ancillary.price.included
? intl.formatMessage({ id: "Included" })
: `${formatPrice(
intl,
ancillary.price.total,
ancillary.price.currency
)} ${ancillary.price.text ?? ""}`}
</Body>
{ancillary.points && (
<>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Body textAlign="right" color="uiTextHighContrast">
{ancillary.points} {intl.formatMessage({ id: "Points" })}
</Body>
</>
)}
</div>
</div>
{ancillary.description && (
<Caption asChild color="uiTextHighContrast">
<div dangerouslySetInnerHTML={{ __html: ancillary.description }} />
</Caption>
)}
</div>
</article>
)
}
@@ -0,0 +1,57 @@
.backToTopButton {
border-radius: var(--Corner-radius-Rounded);
cursor: pointer;
display: flex;
align-items: flex-end;
position: fixed;
bottom: 20px;
z-index: var(--back-to-top-button);
background-color: var(--Base-Surface-Primary-light-Normal);
color: var(--Base-Button-Secondary-On-Fill-Normal);
border: 2px solid var(--Base-Button-Secondary-On-Fill-Normal);
gap: var(--Spacing-x-half);
padding: var(--Spacing-x1);
text-align: center;
transition:
background-color 300ms ease,
color 300ms ease;
font-family: var(--typography-Body-Bold-fontFamily);
font-weight: 500;
font-size: var(--typography-Caption-Bold-fontSize);
line-height: var(--typography-Caption-Bold-lineHeight);
letter-spacing: 0.084px;
text-decoration: none;
}
.backToTopButtonText {
display: none;
}
.left {
left: 32px;
}
.right {
right: 32px;
}
.center {
left: 50%;
transform: translateX(-50%);
}
@media (min-width: 768px) {
.backToTopButtonText {
display: initial;
}
.backToTopButton:hover {
background-color: var(--Base-Button-Tertiary-Fill-Normal);
color: var(--Base-Button-Tertiary-On-Fill-Hover);
}
.backToTopButton:hover > svg * {
fill: var(--Base-Button-Tertiary-On-Fill-Hover);
}
.backToTopButton {
padding: calc(var(--Spacing-x1) + 2px) var(--Spacing-x2);
}
}
@@ -0,0 +1,31 @@
"use client"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { ArrowUpIcon } from "@/components/Icons"
import { backToTopButtonVariants } from "./variants"
import styles from "./backToTopButton.module.css"
export function BackToTopButton({
onClick,
position,
}: {
onClick: () => void
position: "left" | "right" | "center"
}) {
const intl = useIntl()
return (
<ButtonRAC
className={backToTopButtonVariants({ position })}
onPress={onClick}
>
<ArrowUpIcon color="burgundy" />
<span className={styles.backToTopButtonText}>
{intl.formatMessage({ id: "Back to top" })}
</span>
</ButtonRAC>
)
}
@@ -0,0 +1,16 @@
import { cva } from "class-variance-authority"
import styles from "./backToTopButton.module.css"
export const backToTopButtonVariants = cva(styles.backToTopButton, {
variants: {
position: {
left: styles.left,
right: styles.right,
center: styles.center,
},
},
defaultVariants: {
position: "right",
},
})
@@ -0,0 +1,30 @@
import { ChevronRightIcon, HouseIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "@/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
export default function BreadcrumbsSkeleton() {
return (
<nav className={styles.breadcrumbs}>
<ul className={styles.list}>
<li className={styles.listItem}>
<span className={styles.homeLink} color="peach80">
<HouseIcon color="peach80" />
</span>
<ChevronRightIcon
aria-hidden="true"
color="peach80"
height={20}
width={20}
/>
</li>
<li className={styles.listItem}>
<Footnote color="burgundy" type="bold">
<SkeletonShimmer width={"12ch"} />
</Footnote>
</li>
</ul>
</nav>
)
}
@@ -0,0 +1,43 @@
.breadcrumbs {
display: block;
padding: var(--Spacing-x4) 0 var(--Spacing-x3);
margin: 0 auto;
width: 100%;
}
.contentWidth.breadcrumbs {
background-color: var(--Base-Surface-Subtle-Normal);
padding-bottom: 0;
}
.headerWidth.breadcrumbs {
max-width: min(var(--max-width-page), calc(100% - var(--max-width-spacing)));
}
.fullWidth .list {
max-width: var(--max-width-page);
}
.contentWidth .list {
max-width: var(--max-width-content);
}
.list {
align-items: center;
display: flex;
gap: var(--Spacing-x-quarter);
justify-content: flex-start;
list-style: none;
margin: 0 auto;
max-width: var(--max-width-page);
}
.listItem {
align-items: center;
display: flex;
gap: var(--Spacing-x-quarter);
}
.homeLink {
display: flex;
}
@@ -0,0 +1,15 @@
import type { VariantProps } from "class-variance-authority"
import type { breadcrumbsVariants } from "./variants"
type Breadcrumb = {
title: string
uid: string
href?: string
}
export interface BreadcrumbsProps
extends VariantProps<typeof breadcrumbsVariants> {
subpageTitle?: string
breadcrumbs: Breadcrumb[]
}
@@ -0,0 +1,70 @@
import { HouseIcon } from "@/components/Icons"
import ChevronRightSmallIcon from "@/components/Icons/ChevronRightSmall"
import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import { breadcrumbsVariants } from "./variants"
import styles from "./breadcrumbs.module.css"
import type { BreadcrumbsProps } from "@/components/TempDesignSystem/Breadcrumbs/breadcrumbs"
export default function Breadcrumbs({
breadcrumbs,
variant,
}: BreadcrumbsProps) {
if (!breadcrumbs?.length) {
return null
}
const classNames = breadcrumbsVariants({
variant,
})
const homeBreadcrumb = breadcrumbs.shift()
return (
<nav className={classNames}>
<ul className={styles.list}>
{homeBreadcrumb ? (
<li className={styles.listItem}>
<Link
className={styles.homeLink}
color="peach80"
href={homeBreadcrumb.href!}
variant="breadcrumb"
aria-label={homeBreadcrumb.title}
>
<HouseIcon width={16} height={16} color="peach80" />
</Link>
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
</li>
) : null}
{breadcrumbs.map((breadcrumb, index) => {
if (breadcrumb.href && index < breadcrumbs.length - 1) {
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Link
color="peach80"
href={breadcrumb.href}
variant="breadcrumb"
>
{breadcrumb.title}
</Link>
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
</li>
)
}
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Footnote color="burgundy" type="bold">
{breadcrumb.title}
</Footnote>
</li>
)
})}
</ul>
</nav>
)
}
@@ -0,0 +1,26 @@
import { cva } from "class-variance-authority"
import styles from "./breadcrumbs.module.css"
import { PageContentTypeEnum } from "@/types/requests/contentType"
export const breadcrumbsVariants = cva(styles.breadcrumbs, {
variants: {
variant: {
[PageContentTypeEnum.accountPage]: styles.fullWidth,
[PageContentTypeEnum.contentPage]: styles.contentWidth,
[PageContentTypeEnum.collectionPage]: styles.contentWidth,
[PageContentTypeEnum.destinationOverviewPage]: styles.fullWidth,
[PageContentTypeEnum.destinationCountryPage]: styles.fullWidth,
[PageContentTypeEnum.destinationCityPage]: styles.fullWidth,
[PageContentTypeEnum.hotelPage]: styles.headerWidth,
[PageContentTypeEnum.loyaltyPage]: styles.fullWidth,
[PageContentTypeEnum.startPage]: styles.contentWidth,
hotelSubpage: styles.contentWidth,
default: styles.fullWidth,
},
},
defaultVariants: {
variant: "default",
},
})
@@ -0,0 +1,843 @@
.btn {
background: none;
border-radius: var(--Corner-radius-Rounded);
cursor: pointer;
margin: 0;
padding: 0;
text-align: center;
transition:
background-color 300ms ease,
color 300ms ease;
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
line-height: var(--typography-Body-Bold-lineHeight);
letter-spacing: 0.084px;
text-decoration: none;
}
.wrapping {
padding-left: 0 !important;
padding-right: 0 !important;
}
.fullWidth {
width: 100%;
}
/* INTENT */
.primary,
a.primary {
border: none;
}
.secondary,
a.secondary {
background: none;
border-style: solid;
border-width: 2px;
}
.tertiary,
a.tertiary {
border: none;
}
.inverted,
a.inverted {
border: none;
}
.text,
a.text {
background: none;
border: none;
outline: none;
}
/* TODO: The variants for combinations of size/text/wrapping should be looked at and iterated on */
.text:not(.wrapping) {
padding: 0 !important;
}
/* VARIANTS */
.default,
a.default {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: center;
}
.btn.icon:is(.small, .medium, .large) {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: center;
}
/* SIZES */
.btn.small {
font-size: var(--typography-Caption-Bold-fontSize);
line-height: var(--typography-Caption-Bold-lineHeight);
gap: var(--Spacing-x-quarter);
padding: calc(var(--Spacing-x1) + 2px) var(--Spacing-x2);
/* Special case padding to adjust the missing border */
}
.btn.small.secondary {
padding: var(--Spacing-x1) var(--Spacing-x2);
}
.btn.medium {
gap: var(--Spacing-x-half);
padding: calc(var(--Spacing-x-one-and-half) + 2px) var(--Spacing-x2);
/* Special case padding to adjust the missing border */
}
.medium.secondary {
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
}
.btn.large {
gap: var(--Spacing-x-half);
padding: calc(var(--Spacing-x2) + 2px) var(--Spacing-x3);
/* Special case padding to adjust the missing border */
}
.large.secondary {
gap: var(--Spacing-x-half);
padding: var(--Spacing-x2) var(--Spacing-x3);
}
/* DISABLED */
.btn:disabled {
background-color: var(--disabled-background-color);
color: var(--disabled-color);
cursor: not-allowed;
}
/* THEMES */
.basePrimary {
background-color: var(--Base-Button-Primary-Fill-Normal);
color: var(--Base-Button-Primary-On-Fill-Normal);
}
.basePrimary:active,
.basePrimary:focus,
.basePrimary:hover {
background-color: var(--Base-Button-Primary-Fill-Hover);
color: var(--Base-Button-Primary-On-Fill-Hover);
}
.basePrimary:disabled {
background-color: var(--Base-Button-Primary-Fill-Disabled);
color: var(--Base-Button-Primary-On-Fill-Disabled);
}
.icon.basePrimary svg,
.icon.basePrimary svg * {
fill: var(--Base-Button-Primary-On-Fill-Normal);
}
.icon.basePrimary:active svg,
.icon.basePrimary:focus svg,
.icon.basePrimary:hover svg,
.icon.basePrimary:active svg *,
.icon.basePrimary:focus svg *,
.icon.basePrimary:hover svg * {
fill: var(--Base-Button-Primary-On-Fill-Hover);
}
.icon.basePrimary:disabled *,
.icon.basePrimary:disabled svg * {
fill: var(--Base-Button-Primary-On-Fill-Disabled);
}
.baseSecondary {
background-color: var(--Base-Button-Secondary-Fill-Normal);
border-color: var(--Base-Button-Secondary-Border-Normal);
color: var(--Base-Button-Secondary-On-Fill-Normal);
}
.baseSecondary:active,
.baseSecondary:focus,
.baseSecondary:hover {
background-color: var(--Base-Button-Secondary-Fill-Hover);
border-color: var(--Base-Button-Secondary-Border-Hover);
color: var(--Base-Button-Secondary-On-Fill-Hover);
}
.baseSecondary:disabled {
background-color: var(--Base-Button-Secondary-Fill-Disabled);
border-color: var(--Base-Button-Secondary-Border-Disabled);
color: var(--Base-Button-Secondary-On-Fill-Disabled);
}
.icon.baseSecondary svg,
.icon.baseSecondary svg * {
fill: var(--Base-Button-Secondary-On-Fill-Normal);
}
.icon.baseSecondary:active svg,
.icon.baseSecondary:focus svg,
.icon.baseSecondary:hover svg,
.icon.baseSecondary:active svg *,
.icon.baseSecondary:focus svg *,
.icon.baseSecondary:hover svg * {
fill: var(--Base-Button-Secondary-On-Fill-Hover);
}
.icon.baseSecondary:disabled svg,
.icon.baseSecondary:disabled svg * {
fill: var(--Base-Button-Secondary-On-Fill-Disabled);
}
.btn.baseTertiary {
background-color: var(--Base-Button-Tertiary-Fill-Normal);
color: var(--Base-Button-Tertiary-On-Fill-Normal);
}
.btn.baseTertiary:active,
.btn.baseTertiary:focus,
.btn.baseTertiary:hover {
background-color: var(--Base-Button-Tertiary-Fill-Hover);
color: var(--Base-Button-Tertiary-On-Fill-Hover);
}
.btn.baseTertiary:disabled {
background-color: var(--Base-Button-Tertiary-Fill-Disabled);
color: var(--Base-Button-Tertiary-On-Fill-Disabled);
}
.icon.baseTertiary svg,
.icon.baseTertiary svg * {
fill: var(--Base-Button-Tertiary-On-Fill-Normal);
}
.icon.baseTertiary:active svg,
.icon.baseTertiary:focus svg,
.icon.baseTertiary:hover svg,
.icon.baseTertiary:active svg *,
.icon.baseTertiary:focus svg *,
.icon.baseTertiary:hover svg * {
fill: var(--Base-Button-Tertiary-On-Fill-Hover);
}
.icon.baseTertiary:disabled svg,
.icon.baseTertiary:disabled svg * {
fill: var(--Base-Button-Tertiary-On-Fill-Disabled);
}
.baseInverted {
background-color: var(--Base-Button-Inverted-Fill-Normal);
color: var(--Base-Button-Inverted-On-Fill-Normal);
}
.baseInverted:active,
.baseInverted:focus,
.baseInverted:hover {
background-color: var(--Base-Button-Inverted-Fill-Hover);
color: var(--Base-Button-Inverted-On-Fill-Hover);
}
.baseInverted:disabled {
background-color: var(--Base-Button-Inverted-Fill-Disabled);
color: var(--Base-Button-Inverted-On-Fill-Disabled);
}
.icon.baseInverted svg,
.icon.baseInverted svg * {
fill: var(--Base-Button-Inverted-On-Fill-Normal);
}
.icon.baseInverted:active svg,
.icon.baseInverted:focus svg,
.icon.baseInverted:hover svg,
.icon.baseInverted:active svg *,
.icon.baseInverted:focus svg *,
.icon.baseInverted:hover svg * {
fill: var(--Base-Button-Inverted-On-Fill-Hover);
}
.icon.baseInverted:disabled svg,
.icon.baseInverted:disabled svg * {
fill: var(--Base-Button-Inverted-On-Fill-Disabled);
}
.baseText {
color: var(--Base-Button-Text-On-Fill-Normal);
}
.baseTextInverted {
color: var(--Base-Button-Primary-On-Fill-Normal);
}
.baseText:active,
.baseText:focus,
.baseText:hover {
color: var(--Base-Button-Text-On-Fill-Hover);
text-decoration: underline;
}
.baseText:disabled {
color: var(--Base-Button-Text-On-Fill-Disabled);
}
.icon.baseText svg,
.icon.baseText svg * {
fill: var(--Base-Button-Text-On-Fill-Normal);
}
.icon.baseText:active svg,
.icon.baseText:focus svg,
.icon.baseText:hover svg,
.icon.baseText:active svg *,
.icon.baseText:focus svg *,
.icon.baseText:hover svg * {
fill: var(--Base-Button-Text-On-Fill-Hover);
text-decoration: underline;
}
.icon.baseText:disabled svg,
.icon.baseText:disabled svg * {
fill: var(--Base-Button-Text-On-Fill-Disabled);
}
.primaryStrongPrimary {
background-color: var(--Primary-Strong-Button-Primary-Fill-Normal);
color: var(--Primary-Strong-Button-Primary-On-Fill-Normal);
}
.primaryStrongPrimary:active,
.primaryStrongPrimary:focus,
.primaryStrongPrimary:hover {
background-color: var(--Primary-Strong-Button-Primary-Fill-Hover);
color: var(--Primary-Strong-Button-Primary-On-Fill-Hover);
}
.primaryStrongPrimary:disabled {
background-color: var(--Primary-Strong-Button-Primary-Fill-Disabled);
color: var(--Primary-Strong-Button-Primary-On-Fill-Disabled);
}
.icon.primaryStrongPrimary svg,
.icon.primaryStrongPrimary svg * {
fill: var(--Primary-Strong-Button-Primary-On-Fill-Normal);
}
.icon.primaryStrongPrimary:active svg,
.icon.primaryStrongPrimary:focus svg,
.icon.primaryStrongPrimary:hover svg,
.icon.primaryStrongPrimary:active svg *,
.icon.primaryStrongPrimary:focus svg *,
.icon.primaryStrongPrimary:hover svg * {
fill: var(--Primary-Strong-Button-Primary-On-Fill-Hover);
}
.icon.primaryStrongPrimary:disabled svg,
.icon.primaryStrongPrimary:disabled svg * {
fill: var(--Primary-Strong-Button-Primary-On-Fill-Disabled);
}
.primaryStrongSecondary {
background-color: var(--Primary-Strong-Button-Secondary-Fill-Normal);
border-color: var(--Primary-Strong-Button-Secondary-Border-Normal);
color: var(--Primary-Strong-Button-Secondary-On-Fill-Normal);
}
.primaryStrongSecondary:active,
.primaryStrongSecondary:focus,
.primaryStrongSecondary:hover {
background-color: var(--Primary-Strong-Button-Secondary-Fill-Hover);
border-color: var(--Primary-Strong-Button-Secondary-Border-Hover);
color: var(--Primary-Strong-Button-Secondary-On-Fill-Hover);
}
.primaryStrongSecondary:disabled {
background-color: var(--Primary-Strong-Button-Secondary-Fill-Disabled);
border-color: var(--Primary-Strong-Button-Secondary-Border-Disabled);
color: var(--Primary-Strong-Button-Secondary-On-Fill-Disabled);
}
.icon.primaryStrongSecondary svg,
.icon.primaryStrongSecondary svg * {
fill: var(--Primary-Strong-Button-Secondary-On-Fill-Normal);
}
.icon.primaryStrongSecondary:active svg,
.icon.primaryStrongSecondary:focus svg,
.icon.primaryStrongSecondary:hover svg,
.icon.primaryStrongSecondary:active svg *,
.icon.primaryStrongSecondary:focus svg *,
.icon.primaryStrongSecondary:hover svg * {
fill: var(--Primary-Strong-Button-Secondary-On-Fill-Hover);
}
.icon.primaryStrongSecondary:disabled svg,
.icon.primaryStrongSecondary:disabled svg * {
fill: var(--Primary-Strong-Button-Secondary-On-Fill-Disabled);
}
.primaryDarkPrimary {
background-color: var(--Primary-Dark-Button-Primary-Fill-Normal);
color: var(--Primary-Dark-Button-Primary-On-Fill-Normal);
}
.primaryDarkPrimary:active,
.primaryDarkPrimary:focus,
.primaryDarkPrimary:hover {
background-color: var(--Primary-Dark-Button-Primary-Fill-Hover);
color: var(--Primary-Dark-Button-Primary-On-Fill-Hover);
}
.primaryDarkPrimary:disabled {
background-color: var(--Primary-Dark-Button-Primary-Fill-Disabled);
color: var(--Primary-Dark-Button-Primary-On-Fill-Disabled);
}
.icon.primaryDarkPrimary svg,
.icon.primaryDarkPrimary svg * {
fill: var(--Primary-Dark-Button-Primary-On-Fill-Normal);
}
.icon.primaryDarkPrimary:active svg,
.icon.primaryDarkPrimary:focus svg,
.icon.primaryDarkPrimary:hover svg,
.icon.primaryDarkPrimary:active svg *,
.icon.primaryDarkPrimary:focus svg *,
.icon.primaryDarkPrimary:hover svg * {
fill: var(--Primary-Dark-Button-Primary-On-Fill-Hover);
}
.icon.primaryDarkPrimary:disabled svg,
.icon.primaryDarkPrimary:disabled svg * {
fill: var(--Primary-Dark-Button-Primary-On-Fill-Disabled);
}
.primaryDarkSecondary {
background-color: var(--Primary-Dark-Button-Secondary-Fill-Normal);
border-color: var(--Primary-Dark-Button-Secondary-Border-Normal);
color: var(--Primary-Dark-Button-Secondary-On-Fill-Normal);
}
.primaryDarkSecondary:active,
.primaryDarkSecondary:focus,
.primaryDarkSecondary:hover {
background-color: var(--Primary-Dark-Button-Secondary-Fill-Hover);
border-color: var(--Primary-Dark-Button-Secondary-Border-Hover);
color: var(--Primary-Dark-Button-Secondary-On-Fill-Hover);
}
.primaryDarkSecondary:disabled {
background-color: var(--Primary-Dark-Button-Secondary-Fill-Disabled);
border-color: var(--Primary-Dark-Button-Secondary-Border-Disabled);
color: var(--Primary-Dark-Button-Secondary-On-Fill-Disabled);
}
.icon.primaryDarkSecondary svg,
.icon.primaryDarkSecondary svg * {
fill: var(--Primary-Dark-Button-Secondary-On-Fill-Normal);
}
.icon.primaryDarkSecondary:active svg,
.icon.primaryDarkSecondary:focus svg,
.icon.primaryDarkSecondary:hover svg,
.icon.primaryDarkSecondary:active svg *,
.icon.primaryDarkSecondary:focus svg *,
.icon.primaryDarkSecondary:hover svg * {
fill: var(--Primary-Dark-Button-Secondary-On-Fill-Hover);
}
.icon.primaryDarkSecondary:disabled svg,
.icon.primaryDarkSecondary:disabled svg * {
fill: var(--Primary-Dark-Button-Secondary-On-Fill-Disabled);
}
.primaryLightPrimary {
background-color: var(--Primary-Light-Button-Primary-Fill-Normal);
color: var(--Primary-Light-Button-Primary-On-Fill-Normal);
}
.primaryLightPrimary:active,
.primaryLightPrimary:focus,
.primaryLightPrimary:hover {
background-color: var(--Primary-Light-Button-Primary-Fill-Hover);
color: var(--Primary-Light-Button-Primary-On-Fill-Hover);
}
.primaryLightPrimary:disabled {
background-color: var(--Primary-Light-Button-Primary-Fill-Disabled);
color: var(--Primary-Light-Button-Primary-On-Fill-Disabled);
}
.icon.primaryLightPrimary svg,
.icon.primaryLightPrimary svg * {
fill: var(--Primary-Light-Button-Primary-On-Fill-Normal);
}
.icon.primaryLightPrimary:active svg,
.icon.primaryLightPrimary:focus svg,
.icon.primaryLightPrimary:hover svg,
.icon.primaryLightPrimary:active svg *,
.icon.primaryLightPrimary:focus svg *,
.icon.primaryLightPrimary:hover svg * {
fill: var(--Primary-Light-Button-Primary-On-Fill-Hover);
}
.icon.primaryLightPrimary:disabled svg,
.icon.primaryLightPrimary:disabled svg * {
fill: var(--Primary-Light-Button-Primary-On-Fill-Disabled);
}
.primaryLightSecondary {
background-color: var(--Primary-Light-Button-Secondary-Fill-Normal);
border-color: var(--Primary-Light-Button-Secondary-Border-Normal);
color: var(--Primary-Light-Button-Secondary-On-Fill-Normal);
}
.primaryLightSecondary:active,
.primaryLightSecondary:focus,
.primaryLightSecondary:hover {
background-color: var(--Primary-Light-Button-Secondary-Fill-Hover);
border-color: var(--Primary-Light-Button-Secondary-Border-Hover);
color: var(--Primary-Light-Button-Secondary-On-Fill-Hover);
}
.primaryLightSecondary:disabled {
background-color: var(--Primary-Light-Button-Secondary-Fill-Disabled);
border-color: var(--Primary-Light-Button-Secondary-Border-Disabled);
color: var(--Primary-Light-Button-Secondary-On-Fill-Disabled);
}
.icon.primaryLightSecondary svg,
.icon.primaryLightSecondary svg * {
fill: var(--Primary-Light-Button-Secondary-On-Fill-Normal);
}
.icon.primaryLightSecondary:active svg,
.icon.primaryLightSecondary:focus svg,
.icon.primaryLightSecondary:hover svg,
.icon.primaryLightSecondary:active svg *,
.icon.primaryLightSecondary:focus svg *,
.icon.primaryLightSecondary:hover svg * {
fill: var(--Primary-Light-Button-Secondary-On-Fill-Hover);
}
.icon.primaryLightSecondary:disabled svg,
.icon.primaryLightSecondary:disabled svg * {
fill: var(--Primary-Light-Button-Secondary-On-Fill-Disabled);
}
.secondaryDarkPrimary {
background-color: var(--Secondary-Dark-Button-Primary-Fill-Normal);
color: var(--Secondary-Dark-Button-Primary-On-Fill-Normal);
}
.secondaryDarkPrimary:active,
.secondaryDarkPrimary:focus,
.secondaryDarkPrimary:hover {
background-color: var(--Secondary-Dark-Button-Primary-Fill-Hover);
color: var(--Secondary-Dark-Button-Primary-On-Fill-Hover);
}
.secondaryDarkPrimary:disabled {
background-color: var(--Secondary-Dark-Button-Primary-Fill-Disabled);
color: var(--Secondary-Dark-Button-Primary-On-Fill-Disabled);
}
.icon.secondaryDarkPrimary svg,
.icon.secondaryDarkPrimary svg * {
fill: var(--Secondary-Dark-Button-Primary-On-Fill-Normal);
}
.icon.secondaryDarkPrimary:active svg,
.icon.secondaryDarkPrimary:focus svg,
.icon.secondaryDarkPrimary:hover svg,
.icon.secondaryDarkPrimary:active svg *,
.icon.secondaryDarkPrimary:focus svg *,
.icon.secondaryDarkPrimary:hover svg * {
fill: var(--Secondary-Dark-Button-Primary-On-Fill-Hover);
}
.icon.secondaryDarkPrimary:disabled svg,
.icon.secondaryDarkPrimary:disabled svg * {
fill: var(--Secondary-Dark-Button-Primary-On-Fill-Disabled);
}
.secondaryDarkSecondary {
background-color: var(--Secondary-Dark-Button-Secondary-Fill-Normal);
border-color: var(--Secondary-Dark-Button-Secondary-Border-Normal);
color: var(--Secondary-Dark-Button-Secondary-On-Fill-Normal);
}
.secondaryDarkSecondary:active,
.secondaryDarkSecondary:focus,
.secondaryDarkSecondary:hover {
background-color: var(--Secondary-Dark-Button-Secondary-Fill-Hover);
border-color: var(--Secondary-Dark-Button-Secondary-Border-Hover);
color: var(--Secondary-Dark-Button-Secondary-On-Fill-Hover);
}
.secondaryDarkSecondary:disabled {
background-color: var(--Secondary-Dark-Button-Secondary-Fill-Disabled);
border-color: var(--Secondary-Dark-Button-Secondary-Border-Disabled);
color: var(--Secondary-Dark-Button-Secondary-On-Fill-Disabled);
}
.icon.secondaryDarkSecondary svg,
.icon.secondaryDarkSecondary svg * {
fill: var(--Secondary-Dark-Button-Secondary-On-Fill-Normal);
}
.icon.secondaryDarkSecondary:active svg,
.icon.secondaryDarkSecondary:focus svg,
.icon.secondaryDarkSecondary:hover svg,
.icon.secondaryDarkSecondary:active svg *,
.icon.secondaryDarkSecondary:focus svg *,
.icon.secondaryDarkSecondary:hover svg * {
fill: var(--Secondary-Dark-Button-Secondary-On-Fill-Hover);
}
.icon.secondaryDarkSecondary:disabled svg,
.icon.secondaryDarkSecondary:disabled svg * {
fill: var(--Secondary-Dark-Button-Secondary-On-Fill-Disabled);
}
.secondaryLightPrimary {
background-color: var(--Secondary-Light-Button-Primary-Fill-Normal);
color: var(--Secondary-Light-Button-Primary-On-Fill-Normal);
}
.secondaryLightPrimary:active,
.secondaryLightPrimary:focus,
.secondaryLightPrimary:hover {
background-color: var(--Secondary-Light-Button-Primary-Fill-Hover);
color: var(--Secondary-Light-Button-Primary-On-Fill-Hover);
}
.secondaryLightPrimary:disabled {
background-color: var(--Secondary-Light-Button-Primary-Fill-Disabled);
color: var(--Secondary-Light-Button-Primary-On-Fill-Disabled);
}
.icon.secondaryLightPrimary svg,
.icon.secondaryLightPrimary svg * {
fill: var(--Secondary-Light-Button-Primary-On-Fill-Normal);
}
.icon.secondaryLightPrimary:active svg,
.icon.secondaryLightPrimary:focus svg,
.icon.secondaryLightPrimary:hover svg,
.icon.secondaryLightPrimary:active svg *,
.icon.secondaryLightPrimary:focus svg *,
.icon.secondaryLightPrimary:hover svg * {
fill: var(--Secondary-Light-Button-Primary-On-Fill-Hover);
}
.icon.secondaryLightPrimary:disabled svg,
.icon.secondaryLightPrimary:disabled svg * {
fill: var(--Secondary-Light-Button-Primary-On-Fill-Disabled);
}
.secondaryLightSecondary {
background-color: var(--Secondary-Light-Button-Secondary-Fill-Normal);
border-color: var(--Secondary-Light-Button-Secondary-Border-Normal);
color: var(--Secondary-Light-Button-Secondary-On-Fill-Normal);
}
.secondaryLightSecondary:active,
.secondaryLightSecondary:focus,
.secondaryLightSecondary:hover {
background-color: var(--Secondary-Light-Button-Secondary-Fill-Hover);
border-color: var(--Secondary-Light-Button-Secondary-Border-Hover);
color: var(--Secondary-Light-Button-Secondary-On-Fill-Hover);
}
.secondaryLightSecondary:disabled {
background-color: var(--Secondary-Light-Button-Secondary-Fill-Disabled);
border-color: var(--Secondary-Light-Button-Secondary-Border-Disabled);
color: var(--Secondary-Light-Button-Secondary-On-Fill-Disabled);
}
.icon.secondaryLightSecondary svg,
.icon.secondaryLightSecondary svg * {
fill: var(--Secondary-Light-Button-Secondary-On-Fill-Normal);
}
.icon.secondaryLightSecondary:active svg,
.icon.secondaryLightSecondary:focus svg,
.icon.secondaryLightSecondary:hover svg,
.icon.secondaryLightSecondary:active svg *,
.icon.secondaryLightSecondary:focus svg *,
.icon.secondaryLightSecondary:hover svg * {
fill: var(--Secondary-Light-Button-Secondary-On-Fill-Hover);
}
.icon.secondaryLightSecondary:disabled svg,
.icon.secondaryLightSecondary:disabled svg * {
fill: var(--Secondary-Light-Button-Secondary-On-Fill-Disabled);
}
.tertiaryDarkPrimary {
background-color: var(--Tertiary-Dark-Button-Primary-Fill-Normal);
color: var(--Tertiary-Dark-Button-Primary-On-Fill-Normal);
}
.tertiaryDarkPrimary:active,
.tertiaryDarkPrimary:focus,
.tertiaryDarkPrimary:hover {
background-color: var(--Tertiary-Dark-Button-Primary-Fill-Hover);
color: var(--Tertiary-Dark-Button-Primary-On-Fill-Hover);
}
.tertiaryDarkPrimary:disabled {
background-color: var(--Tertiary-Dark-Button-Primary-Fill-Disabled);
color: var(--Tertiary-Dark-Button-Primary-On-Fill-Disabled);
}
.icon.tertiaryDarkPrimary svg,
.icon.tertiaryDarkPrimary svg * {
fill: var(--Tertiary-Dark-Button-Primary-On-Fill-Normal);
}
.icon.tertiaryDarkPrimary:active svg,
.icon.tertiaryDarkPrimary:focus svg,
.icon.tertiaryDarkPrimary:hover svg,
.icon.tertiaryDarkPrimary:active svg *,
.icon.tertiaryDarkPrimary:focus svg *,
.icon.tertiaryDarkPrimary:hover svg * {
fill: var(--Tertiary-Dark-Button-Primary-On-Fill-Hover);
}
.icon.tertiaryDarkPrimary:disabled svg,
.icon.tertiaryDarkPrimary:disabled svg * {
fill: var(--Tertiary-Dark-Button-Primary-On-Fill-Disabled);
}
.tertiaryDarkSecondary {
background-color: var(--Tertiary-Dark-Button-Secondary-Fill-Normal);
border-color: var(--Tertiary-Dark-Button-Secondary-Border-Normal);
color: var(--Tertiary-Dark-Button-Secondary-On-Fill-Normal);
}
.tertiaryDarkSecondary:active,
.tertiaryDarkSecondary:focus,
.tertiaryDarkSecondary:hover {
background-color: var(--Tertiary-Dark-Button-Secondary-Fill-Hover);
border-color: var(--Tertiary-Dark-Button-Secondary-Border-Hover);
color: var(--Tertiary-Dark-Button-Secondary-On-Fill-Hover);
}
.tertiaryDarkSecondary:disabled {
background-color: var(--Tertiary-Dark-Button-Secondary-Fill-Disabled);
border-color: var(--Tertiary-Dark-Button-Secondary-Border-Disabled);
color: var(--Tertiary-Dark-Button-Secondary-On-Fill-Disabled);
}
.icon.tertiaryDarkSecondary svg,
.icon.tertiaryDarkSecondary svg * {
fill: var(--Tertiary-Dark-Button-Secondary-On-Fill-Normal);
}
.icon.tertiaryDarkSecondary:active svg,
.icon.tertiaryDarkSecondary:focus svg,
.icon.tertiaryDarkSecondary:hover svg,
.icon.tertiaryDarkSecondary:active svg *,
.icon.tertiaryDarkSecondary:focus svg *,
.icon.tertiaryDarkSecondary:hover svg * {
fill: var(--Tertiary-Dark-Button-Secondary-On-Fill-Hover);
}
.icon.tertiaryDarkSecondary:disabled svg,
.icon.tertiaryDarkSecondary:disabled svg * {
fill: var(--Tertiary-Dark-Button-Secondary-On-Fill-Disabled);
}
.tertiaryLightPrimary {
background-color: var(--Tertiary-Light-Button-Primary-Fill-Normal);
color: var(--Tertiary-Light-Button-Primary-On-Fill-Normal);
}
.tertiaryLightPrimary:active,
.tertiaryLightPrimary:focus,
.tertiaryLightPrimary:hover {
background-color: var(--Tertiary-Light-Button-Primary-Fill-Hover);
color: var(--Tertiary-Light-Button-Primary-On-Fill-Hover);
}
.tertiaryLightPrimary:disabled {
background-color: var(--Tertiary-Light-Button-Primary-Fill-Disabled);
color: var(--Tertiary-Light-Button-Primary-On-Fill-Disabled);
}
.icon.tertiaryLightPrimary svg,
.icon.tertiaryLightPrimary svg * {
fill: var(--Tertiary-Light-Button-Primary-On-Fill-Normal);
}
.icon.tertiaryLightPrimary:active svg,
.icon.tertiaryLightPrimary:focus svg,
.icon.tertiaryLightPrimary:hover svg,
.icon.tertiaryLightPrimary:active svg *,
.icon.tertiaryLightPrimary:focus svg *,
.icon.tertiaryLightPrimary:hover svg * {
fill: var(--Tertiary-Light-Button-Primary-On-Fill-Hover);
}
.icon.tertiaryLightPrimary:disabled svg,
.icon.tertiaryLightPrimary:disabled svg * {
fill: var(--Tertiary-Light-Button-Primary-On-Fill-Disabled);
}
.tertiaryLightSecondary {
background-color: var(--Tertiary-Light-Button-Secondary-Fill-Normal);
border-color: var(--Tertiary-Light-Button-Secondary-Border-Normal);
color: var(--Tertiary-Light-Button-Secondary-On-Fill-Normal);
}
.tertiaryLightSecondary:active,
.tertiaryLightSecondary:focus,
.tertiaryLightSecondary:hover {
background-color: var(--Tertiary-Light-Button-Secondary-Fill-Hover);
border-color: var(--Tertiary-Light-Button-Secondary-Border-Hover);
color: var(--Tertiary-Light-Button-Secondary-On-Fill-Hover);
}
.tertiaryLightSecondary:disabled {
background-color: var(--Tertiary-Light-Button-Secondary-Fill-Disabled);
border-color: var(--Tertiary-Light-Button-Secondary-Border-Disabled);
color: var(--Tertiary-Light-Button-Secondary-On-Fill-Disabled);
}
.icon.tertiaryLightSecondary svg,
.icon.tertiaryLightSecondary svg * {
fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Normal);
}
.icon.tertiaryLightSecondary:active svg,
.icon.tertiaryLightSecondary:focus svg,
.icon.tertiaryLightSecondary:hover svg,
.icon.tertiaryLightSecondary:active svg *,
.icon.tertiaryLightSecondary:focus svg *,
.icon.tertiaryLightSecondary:hover svg * {
fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Hover);
}
.icon.tertiaryLightSecondary:disabled svg,
.icon.tertiaryLightSecondary:disabled svg * {
fill: var(--Tertiary-Light-Button-Secondary-On-Fill-Disabled);
}
button.btn.clean {
background: none;
background-color: unset;
border: none;
border-color: unset;
border-radius: unset;
color: unset;
gap: unset;
margin: 0;
padding: 0;
}
@@ -0,0 +1,20 @@
import type { VariantProps } from "class-variance-authority"
import type { ButtonProps as ReactAriaButtonProps } from "react-aria-components"
import type { buttonVariants } from "./variants"
export interface ButtonPropsRAC
extends Omit<ReactAriaButtonProps, "isDisabled">,
VariantProps<typeof buttonVariants> {
asChild?: false | undefined | never
disabled?: ReactAriaButtonProps["isDisabled"]
onClick?: ReactAriaButtonProps["onPress"]
}
export interface ButtonPropsSlot
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild: true
}
export type ButtonProps = ButtonPropsSlot | ButtonPropsRAC
@@ -0,0 +1,48 @@
"use client"
import { Slot } from "@radix-ui/react-slot"
import { Button as ButtonRAC } from "react-aria-components"
import { buttonVariants } from "./variants"
import type { ButtonProps } from "./button"
export default function Button(props: ButtonProps) {
const {
className,
clean,
intent,
size,
theme,
fullWidth,
wrapping,
variant,
...restProps
} = props
const classNames = buttonVariants({
className,
clean,
intent,
size,
theme,
fullWidth,
wrapping,
variant,
})
if (restProps.asChild) {
const { asChild, ...slotProps } = restProps
return <Slot className={classNames} {...slotProps} />
}
const { asChild, onClick, disabled, ...racProps } = restProps
return (
<ButtonRAC
className={classNames}
isDisabled={disabled}
onPress={onClick}
{...racProps}
/>
)
}
@@ -0,0 +1,154 @@
import { cva } from "class-variance-authority"
import styles from "./button.module.css"
export const buttonVariants = cva(styles.btn, {
variants: {
intent: {
inverted: styles.inverted,
primary: styles.primary,
secondary: styles.secondary,
tertiary: styles.tertiary,
text: styles.text,
textInverted: styles.text,
},
size: {
small: styles.small,
medium: styles.medium,
large: styles.large,
},
theme: {
base: "",
primaryDark: "",
primaryStrong: "",
primaryLight: "",
secondaryLight: "",
secondaryDark: "",
tertiaryLight: "",
tertiaryDark: "",
},
variant: {
clean: styles.clean,
default: styles.default,
icon: styles.icon,
},
wrapping: {
true: styles.wrapping,
},
clean: {
true: styles.clean,
},
fullWidth: {
true: styles.fullWidth,
},
},
defaultVariants: {
intent: "primary",
size: "medium",
theme: "primaryLight",
variant: "default",
},
compoundVariants: [
{
className: styles.basePrimary,
intent: "primary",
theme: "base",
},
{
className: styles.baseSecondary,
intent: "secondary",
theme: "base",
},
{
className: styles.baseTertiary,
intent: "tertiary",
theme: "base",
},
{
className: styles.baseInverted,
intent: "inverted",
theme: "base",
},
{
className: styles.primaryDarkPrimary,
intent: "primary",
theme: "primaryDark",
},
{
className: styles.primaryDarkSecondary,
intent: "secondary",
theme: "primaryDark",
},
{
className: styles.primaryLightPrimary,
intent: "primary",
theme: "primaryLight",
},
{
className: styles.primaryLightSecondary,
intent: "secondary",
theme: "primaryLight",
},
{
className: styles.primaryStrongPrimary,
intent: "primary",
theme: "primaryStrong",
},
{
className: styles.primaryStrongSecondary,
intent: "secondary",
theme: "primaryStrong",
},
{
className: styles.secondaryDarkPrimary,
intent: "primary",
theme: "secondaryDark",
},
{
className: styles.secondaryDarkSecondary,
intent: "secondary",
theme: "secondaryDark",
},
{
className: styles.secondaryLightPrimary,
intent: "primary",
theme: "secondaryLight",
},
{
className: styles.secondaryLightSecondary,
intent: "secondary",
theme: "secondaryLight",
},
{
className: styles.tertiaryDarkPrimary,
intent: "primary",
theme: "tertiaryDark",
},
{
className: styles.tertiaryDarkSecondary,
intent: "secondary",
theme: "tertiaryDark",
},
{
className: styles.tertiaryLightPrimary,
intent: "primary",
theme: "tertiaryLight",
},
{
className: styles.tertiaryLightSecondary,
intent: "secondary",
theme: "tertiaryLight",
},
{
className: styles.baseText,
intent: "text",
theme: "base",
},
{
className: styles.baseTextInverted,
intent: "textInverted",
theme: "base",
},
],
})
@@ -0,0 +1,22 @@
.image {
object-fit: cover;
overflow: hidden;
width: 100%;
min-height: 180px; /* Fixed height from Figma */
border-radius: var(--Corner-radius-Medium);
}
.imageContainer {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--Spacing-x-quarter);
}
.card {
height: 254px; /* Fixed height from Figma */
}
.container {
display: grid;
gap: var(--Spacing-x-quarter);
}
@@ -0,0 +1,35 @@
import Image from "@/components/Image"
import Card from ".."
import styles from "./cardImage.module.css"
import type { CardImageProps } from "@/types/components/cardImage"
export default function CardImage({
card,
imageCards,
className,
}: CardImageProps) {
return (
<article className={`${styles.container} ${className}`}>
<div className={styles.imageContainer}>
{imageCards?.map(
({ backgroundImage }) =>
backgroundImage && (
<Image
key={backgroundImage.id}
src={backgroundImage.url}
className={styles.image}
alt={backgroundImage.title}
width={180}
height={180}
focalPoint={backgroundImage.focalPoint}
/>
)
)}
</div>
<Card {...card} className={styles.card} />
</article>
)
}
@@ -0,0 +1,107 @@
.container {
position: relative;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
border-radius: var(--Corner-radius-Medium);
margin-right: var(--Spacing-x2);
text-align: center;
width: 100%;
text-wrap: balance;
overflow: hidden;
}
.fixed {
height: 320px; /* Fixed height from Figma */
}
.dynamic {
height: 100%;
}
.imageWrapper {
display: flex;
width: 100%;
height: 100%;
}
.imageWrapper::after {
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 50%,
rgba(0, 0, 0, 0.75) 100%
);
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.image {
object-fit: cover;
width: 100%;
height: 100%;
min-height: 320px; /* Fixed height from Figma */
}
.content {
margin: var(--Spacing-x0) var(--Spacing-x4);
position: absolute;
display: grid;
gap: var(--Spacing-x2);
}
.themeOne {
background: var(--Primary-Light-Surface-Normal);
}
.themeTwo {
background: var(--Secondary-Light-Surface-Normal);
}
.themeThree {
background: var(--Tertiary-Light-Surface-Normal);
}
.themePrimaryDark {
background: var(--Primary-Dark-Surface-Normal);
}
.themePrimaryDim {
background: var(--Primary-Dim-Surface-Normal);
}
.themePrimaryInverted {
background: var(--Base-Surface-Primary-light-Normal);
}
.themePrimaryStrong {
background: var(--Primary-Strong-Surface-Normal);
}
.themeImage {
}
.themeImage .content {
position: absolute;
}
.scriptContainer {
display: grid;
gap: var(--Spacing-x1);
}
.scriptedTitle {
padding: var(--Spacing-x1);
margin: 0;
}
.buttonContainer {
display: flex;
gap: var(--Spacing-x1);
justify-content: center;
}
@@ -0,0 +1,33 @@
import type { VariantProps } from "class-variance-authority"
import type { ApiImage } from "@/types/components/image"
import type { ImageVaultAsset } from "@/types/components/imageVault"
import type { cardVariants } from "./variants"
export interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {
primaryButton?: {
href: string
title: string
openInNewTab?: boolean
isExternal?: boolean
scrollOnClick?: boolean
} | null
secondaryButton?: {
href: string
title: string
openInNewTab?: boolean
isExternal?: boolean
scrollOnClick?: boolean
} | null
scriptedTopTitle?: string | null
heading?: string | null
bodyText?: string | null
imageHeight?: number
imageWidth?: number
imageGradient?: boolean
onPrimaryButtonClick?: () => void
onSecondaryButtonClick?: () => void
backgroundImage?: ImageVaultAsset | ApiImage
}
@@ -0,0 +1,134 @@
import Link from "next/link"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import {
getBodyFontColor,
getButtonTheme,
getScriptFontColor,
getTitleFontColor,
} from "./utils"
import { cardVariants } from "./variants"
import styles from "./card.module.css"
import type { CardProps } from "./card"
export default function Card({
primaryButton,
secondaryButton,
scriptedTopTitle,
heading,
bodyText,
className,
theme,
backgroundImage,
imageHeight,
imageWidth,
imageGradient,
onPrimaryButtonClick,
onSecondaryButtonClick,
height,
}: CardProps) {
const buttonTheme = getButtonTheme(theme)
const titleFontColor = getTitleFontColor(theme)
const scriptFontColor = getScriptFontColor(theme)
const bodyFontColor = getBodyFontColor(theme)
imageHeight = imageHeight || 320
imageWidth =
imageWidth ||
(backgroundImage?.dimensions
? backgroundImage.dimensions.aspectRatio >= 1
? backgroundImage.dimensions.aspectRatio * imageHeight
: imageHeight / backgroundImage.dimensions.aspectRatio
: 420)
return (
<article
className={cardVariants({
theme,
height,
className,
})}
>
{backgroundImage && (
<div className={imageGradient ? styles.imageWrapper : ""}>
<Image
src={backgroundImage.url}
className={styles.image}
alt={backgroundImage.meta.alt || backgroundImage.title}
width={imageWidth}
height={imageHeight}
focalPoint={backgroundImage.focalPoint}
/>
</div>
)}
<div className={styles.content}>
{scriptedTopTitle ? (
<section className={styles.scriptContainer}>
<BiroScript
className={styles.scriptedTitle}
type="two"
tilted="small"
color={scriptFontColor}
>
{scriptedTopTitle}
</BiroScript>
</section>
) : null}
<Title
as="h3"
level="h3"
textAlign="center"
textTransform="regular"
color={titleFontColor}
>
{heading}
</Title>
{bodyText ? (
<Body textAlign="center" color={bodyFontColor}>
{bodyText}
</Body>
) : null}
<div className={styles.buttonContainer}>
{primaryButton ? (
<Button asChild theme={buttonTheme} size="small">
<Link
href={primaryButton.href}
target={primaryButton.openInNewTab ? "_blank" : undefined}
onClick={onPrimaryButtonClick}
scroll={primaryButton.scrollOnClick ?? true}
>
{primaryButton.title}
</Link>
</Button>
) : null}
{secondaryButton ? (
<Button
asChild
theme={buttonTheme}
size="small"
intent="secondary"
disabled
>
<Link
href={secondaryButton.href}
target={secondaryButton.openInNewTab ? "_blank" : undefined}
onClick={onSecondaryButtonClick}
scroll={secondaryButton.scrollOnClick ?? true}
>
{secondaryButton.title}
</Link>
</Button>
) : null}
</div>
</div>
</article>
)
}
@@ -0,0 +1,97 @@
import type { VariantProps } from "class-variance-authority"
import type { ButtonProps } from "@/components/TempDesignSystem/Button/button"
import type { CardProps } from "@/components/TempDesignSystem/Card/card"
import type { biroScriptVariants } from "@/components/TempDesignSystem/Text/BiroScript/variants"
import type { bodyVariants } from "@/components/TempDesignSystem/Text/Body/variants"
import type { headingVariants } from "@/components/TempDesignSystem/Text/Title/variants"
export function getTitleFontColor(
theme: CardProps["theme"]
): VariantProps<typeof headingVariants>["color"] {
switch (theme) {
case "one":
return "primaryLight"
case "two":
return "secondaryLight"
case "three":
return "tertiaryLight"
case "primaryDark":
return "primaryDark"
case "primaryDim":
return "primaryDim"
case "primaryInverted":
return "primaryLight"
case "primaryStrong":
return "primaryStrong"
case "image":
return "baseText"
}
}
export function getScriptFontColor(
theme: CardProps["theme"]
): VariantProps<typeof biroScriptVariants>["color"] {
switch (theme) {
case "one":
return "primaryLightOnSurfaceAccent"
case "two":
return "secondaryLightAccent"
case "three":
return "tertiaryLightAccent"
case "primaryDark":
return "pink"
case "primaryDim":
return "primaryDimAccent"
case "primaryInverted":
return "primaryLightOnSurfaceAccent"
case "primaryStrong":
return "primaryStrongAccent"
case "image":
return "baseText"
}
}
export function getBodyFontColor(
theme: CardProps["theme"]
): VariantProps<typeof bodyVariants>["color"] {
switch (theme) {
case "one":
return "primaryLight"
case "two":
return "secondaryLight"
case "three":
return "tertiaryLight"
case "primaryDark":
return "primaryDark"
case "primaryDim":
return "primaryDim"
case "primaryInverted":
return "primaryLight"
case "primaryStrong":
return "primaryStrong"
case "image":
return "baseText"
}
}
export function getButtonTheme(
theme: CardProps["theme"]
): ButtonProps["theme"] {
switch (theme) {
case "two":
return "secondaryLight"
case "three":
return "tertiaryLight"
case "primaryDark":
return "primaryDark"
case "primaryStrong":
case "image":
return "primaryStrong"
case "one":
case "primaryDim":
case "primaryInverted":
default:
return "primaryLight"
}
}
@@ -0,0 +1,28 @@
import { cva } from "class-variance-authority"
import styles from "./card.module.css"
export const cardVariants = cva(styles.container, {
variants: {
theme: {
one: styles.themeOne,
two: styles.themeTwo,
three: styles.themeThree,
primaryDark: styles.themePrimaryDark,
primaryDim: styles.themePrimaryDim,
primaryInverted: styles.themePrimaryInverted,
primaryStrong: styles.themePrimaryStrong,
image: styles.themeImage,
},
height: {
fixed: styles.fixed,
dynamic: styles.dynamic,
},
},
defaultVariants: {
theme: "one",
height: "fixed",
},
})
@@ -0,0 +1,43 @@
div.chip {
--chip-text-color: var(--Base-Text-High-contrast);
--chip-background-color: var(--Base-Surface-Primary-light-Normal);
align-items: center;
color: var(--chip-text-color);
background-color: var(--chip-background-color);
border-radius: var(--Corner-radius-Small);
display: flex;
gap: var(--Spacing-x-half);
justify-content: center;
}
.chip.small {
padding: var(--Spacing-x-quarter) var(--Spacing-x-half);
}
.chip.medium {
padding: var(--Spacing-x-half) var(--Spacing-x1);
}
.chip *,
.chip svg * {
fill: var(--chip-text-color);
}
.chip.burgundy {
--chip-text-color: var(--Primary-Dark-On-Surface-Text);
--chip-background-color: var(--Base-Text-High-contrast);
}
.chip.transparent {
--chip-text-color: var(--UI-Input-Controls-On-Fill-Normal);
--chip-background-color: rgba(64, 57, 55, 0.9);
}
.chip.tag {
--chip-background-color: var(--Base-Surface-Subtle-Hover);
}
.chip.uiTextHighContrast {
--chip-background-color: var(--UI-Text-High-contrast);
--chip-text-color: var(--UI-Input-Controls-On-Fill-Normal);
}
@@ -0,0 +1,7 @@
import type { VariantProps } from "class-variance-authority"
import type { chipVariants } from "./variants"
export interface ChipProps
extends React.HtmlHTMLAttributes<HTMLDivElement>,
VariantProps<typeof chipVariants> {}
@@ -0,0 +1,23 @@
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import { chipVariants } from "./variants"
import type { ChipProps } from "./chip"
export default function Chip({
children,
className,
size,
variant,
}: ChipProps) {
const classNames = chipVariants({
className,
size,
variant,
})
return (
<Footnote asChild>
<div className={classNames}>{children}</div>
</Footnote>
)
}
@@ -0,0 +1,23 @@
import { cva } from "class-variance-authority"
import styles from "./chip.module.css"
export const chipVariants = cva(styles.chip, {
variants: {
size: {
small: styles.small,
medium: styles.medium,
},
variant: {
default: styles.default,
burgundy: styles.burgundy,
transparent: styles.transparent,
tag: styles.tag,
uiTextHighContrast: styles.uiTextHighContrast,
},
},
defaultVariants: {
size: "medium",
variant: "default",
},
})
@@ -0,0 +1,57 @@
.divider {
pointer-events: none;
}
.horizontal {
height: 1px;
width: 100%;
}
.vertical {
height: 100%;
width: 1px;
}
.burgundy {
background-color: var(--Scandic-Brand-Burgundy);
}
.pale {
background-color: var(--Primary-Dark-On-Surface-Text);
}
.peach {
background-color: var(--Primary-Light-On-Surface-Divider);
}
.beige {
background-color: var(--Scandic-Beige-20);
}
.white {
background-color: var(--UI-Opacity-White-100);
}
.subtle {
background-color: var(--Base-Border-Subtle);
}
.primaryLightSubtle {
background-color: var(--Primary-Light-On-Surface-Divider-subtle);
}
.baseSurfaceSubtleNormal {
background-color: var(--Base-Surface-Subtle-Normal);
}
.opacity100 {
opacity: 1;
}
.opacity8 {
opacity: 0.08;
}
.baseSurfaceSubtleHover {
background-color: var(--Base-Surface-Subtle-Hover);
}
@@ -0,0 +1,7 @@
import type { VariantProps } from "class-variance-authority"
import type { dividerVariants } from "./variants"
export interface DividerProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
VariantProps<typeof dividerVariants> {}
@@ -0,0 +1,13 @@
import { dividerVariants } from "./variants"
import type { DividerProps } from "./divider"
export default function Divider({
className,
color,
opacity,
variant,
}: DividerProps) {
const classNames = dividerVariants({ className, color, opacity, variant })
return <div className={classNames} />
}
@@ -0,0 +1,32 @@
import { cva } from "class-variance-authority"
import styles from "./divider.module.css"
export const dividerVariants = cva(styles.divider, {
variants: {
color: {
baseSurfaceSubtleNormal: styles.baseSurfaceSubtleNormal,
beige: styles.beige,
burgundy: styles.burgundy,
pale: styles.pale,
peach: styles.peach,
primaryLightSubtle: styles.primaryLightSubtle,
subtle: styles.subtle,
white: styles.white,
baseSurfaceSubtleHover: styles.baseSurfaceSubtleHover,
},
opacity: {
100: styles.opacity100,
8: styles.opacity8,
},
variant: {
horizontal: styles.horizontal,
vertical: styles.vertical,
},
},
defaultVariants: {
color: "burgundy",
opacity: 100,
variant: "horizontal",
},
})
@@ -0,0 +1,44 @@
.container {
display: flex;
flex-direction: column;
color: var(--text-color);
cursor: pointer;
}
.container[data-selected] .checkbox {
border: none;
background: var(--UI-Input-Controls-Fill-Selected);
}
.container[data-disabled] .checkbox {
border: 1px solid var(--UI-Input-Controls-Border-Disabled);
background: var(--UI-Input-Controls-Surface-Disabled);
}
.checkboxContainer {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
background: var(--UI-Input-Controls-Surface-Normal);
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: 4px;
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
forced-color-adjust: none;
}
.error {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;
}
@@ -0,0 +1,54 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import { useController, useFormContext } from "react-hook-form"
import { InfoCircleIcon } from "@/components/Icons"
import CheckIcon from "@/components/Icons/Check"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./checkbox.module.css"
import type { CheckboxProps } from "@/types/components/checkbox"
export default function Checkbox({
className,
name,
children,
registerOptions,
}: React.PropsWithChildren<CheckboxProps>) {
const { control } = useFormContext()
const { field, fieldState } = useController({
control,
name,
rules: registerOptions,
})
return (
<AriaCheckbox
className={`${styles.container} ${className}`}
isSelected={field.value}
onChange={field.onChange}
data-testid={name}
isDisabled={registerOptions?.disabled}
excludeFromTabOrder
>
{({ isSelected }) => (
<>
<span className={styles.checkboxContainer}>
<span className={styles.checkbox} tabIndex={0}>
{isSelected && <CheckIcon color="white" />}
</span>
{children}
</span>
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</>
)}
</AriaCheckbox>
)
}
@@ -0,0 +1,7 @@
import Card from "./_Card"
import type { RadioProps } from "./_Card/card"
export default function RadioCard(props: RadioProps) {
return <Card {...props} type="radio" />
}
@@ -0,0 +1,76 @@
.label {
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large);
cursor: pointer;
display: grid;
grid-template-columns: 1fr auto;
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
transition: all 200ms ease;
width: min(100%, 600px);
grid-column-gap: var(--Spacing-x2);
}
.label:hover {
background-color: var(--Base-Surface-Secondary-light-Hover);
}
.label:has(:checked) {
background-color: var(--Primary-Light-Surface-Normal);
border-color: var(--Base-Border-Hover);
}
.icon {
align-self: center;
grid-column: 2/3;
grid-row: 1/3;
justify-self: flex-end;
transition: fill 200ms ease;
}
.label:hover .icon,
.label:hover .icon *,
.label:has(:checked) .icon,
.label:has(:checked) .icon * {
fill: var(--Base-Text-Medium-contrast);
}
.label[data-declined="true"]:hover .icon,
.label[data-declined="true"]:hover .icon *,
.label[data-declined="true"]:has(:checked) .icon,
.label[data-declined="true"]:has(:checked) .icon * {
fill: var(--Base-Text-Disabled);
}
.subtitle {
grid-column: 1 / 2;
grid-row: 2;
}
.title {
grid-column: 1 / 2;
}
.label .text {
margin-top: var(--Spacing-x1);
grid-column: 1/-1;
}
.listItem {
align-items: center;
display: flex;
gap: var(--Spacing-x-quarter);
grid-column: 1/-1;
}
.listItem:first-of-type {
margin-top: var(--Spacing-x1);
}
.listItem:nth-of-type(n + 2) {
margin-top: var(--Spacing-x-quarter);
}
.highlight {
color: var(--Scandic-Brand-Scandic-Red);
}
@@ -0,0 +1,51 @@
import type { IconProps } from "@/types/components/icon"
interface BaseCardProps
extends Omit<React.LabelHTMLAttributes<HTMLLabelElement>, "title"> {
Icon?: (props: IconProps) => JSX.Element
declined?: boolean
highlightSubtitle?: boolean
iconHeight?: number
iconWidth?: number
name: string
subtitle?: React.ReactNode
title: React.ReactNode
type: "checkbox" | "radio"
value?: string
}
interface ListCardProps extends BaseCardProps {
list: {
title: string
}[]
text?: never
}
interface TextCardProps extends BaseCardProps {
list?: never
text: React.ReactNode
}
interface CleanCardProps extends BaseCardProps {
list?: never
text?: never
}
export type CardProps = ListCardProps | TextCardProps | CleanCardProps
export type CheckboxProps =
| Omit<ListCardProps, "type">
| Omit<TextCardProps, "type">
export type RadioProps =
| Omit<ListCardProps, "type">
| Omit<TextCardProps, "type">
| Omit<CleanCardProps, "type">
export interface ListProps extends Pick<ListCardProps, "declined"> {
list?: ListCardProps["list"]
}
export interface SubtitleProps
extends Pick<BaseCardProps, "highlightSubtitle" | "subtitle"> {}
export interface TextProps extends Pick<TextCardProps, "text"> {}
@@ -0,0 +1,115 @@
"use client"
import { useFormContext } from "react-hook-form"
import { CheckIcon, CloseIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./card.module.css"
import type { CardProps, ListProps, SubtitleProps, TextProps } from "./card"
export default function Card({
Icon,
iconHeight = 32,
iconWidth = 32,
declined = false,
highlightSubtitle = false,
id,
list,
name,
subtitle,
text,
title,
type,
value,
}: CardProps) {
const { register, setValue } = useFormContext()
function onLabelClick(event: React.MouseEvent) {
// Preventing click event on label elements firing twice: https://github.com/facebook/react/issues/14295
event.preventDefault()
setValue(name, value)
}
return (
<label
className={styles.label}
data-declined={declined}
onClick={onLabelClick}
tabIndex={0}
>
<Caption className={styles.title} color="burgundy" type="label" uppercase>
{title}
</Caption>
<Subtitle highlightSubtitle={highlightSubtitle} subtitle={subtitle} />
{Icon ? (
<Icon
className={styles.icon}
color="uiTextHighContrast"
height={iconHeight}
width={iconWidth}
/>
) : null}
<List declined={declined} list={list} />
<Text text={text} />
<input
{...register(name)}
aria-hidden
id={id || name}
hidden
type={type}
value={value}
/>
</label>
)
}
function List({ declined, list }: ListProps) {
if (!list) {
return null
}
return list.map((listItem) => (
<span key={listItem.title} className={styles.listItem}>
{declined ? (
<CloseIcon color="uiTextMediumContrast" height={20} width={20} />
) : (
<CheckIcon color="baseIconLowContrast" height={20} width={20} />
)}
<Footnote color="uiTextMediumContrast">{listItem.title}</Footnote>
</span>
))
}
function Subtitle({ highlightSubtitle, subtitle }: SubtitleProps) {
if (!subtitle) {
return null
}
return (
<Caption
className={styles.subtitle}
color={highlightSubtitle ? "baseTextAccent" : "uiTextMediumContrast"}
type="label"
uppercase
>
{subtitle}
</Caption>
)
}
function Text({ text }: TextProps) {
if (!text) {
return null
}
return (
<Footnote className={styles.text} color="uiTextMediumContrast">
{text}
</Footnote>
)
}
export function Highlight({ children }: React.PropsWithChildren) {
return <span className={styles.highlight}>{children}</span>
}
@@ -0,0 +1,259 @@
export const countriesMap = {
Afghanistan: "AF",
Albania: "AL",
Algeria: "DZ",
"American Samoa": "AS",
Andorra: "AD",
Angola: "AO",
Anguilla: "AI",
Antarctica: "AQ",
"Antigua and Barbuda": "AG",
Argentina: "AR",
Armenia: "AM",
Aruba: "AW",
Australia: "AU",
Austria: "AT",
Azerbaijan: "AZ",
Bahamas: "BS",
Bahrain: "BH",
Bangladesh: "BD",
Barbados: "BB",
Belarus: "BY",
Belgium: "BE",
Belize: "BZ",
Benin: "BJ",
Bermuda: "BM",
Bhutan: "BT",
Bolivia: "BO",
Bonaire: "BQ",
"Bosnia and Herzegovina": "BA",
Botswana: "BW",
"Bouvet Island": "BV",
Brazil: "BR",
"British Indian Ocean Territory": "IO",
"Brunei Darussalam": "BN",
Bulgaria: "BG",
"Burkina Faso": "BF",
Burundi: "BI",
Cambodia: "KH",
Cameroon: "CM",
Canada: "CA",
"Cape Verde": "CV",
"Cayman Islands": "KY",
"Central African Republic": "CF",
Chad: "TD",
Chile: "CL",
China: "CN",
"Christmas Island": "CX",
"Cocos (Keeling) Islands": "CC",
Colombia: "CO",
Comoros: "KM",
Congo: "CG",
"Congo, The Democratic Republic of the": "CD",
"Cook Islands": "CK",
"Costa Rica": "CR",
"Côte d'Ivoire": "CI",
Croatia: "HR",
Cuba: "CU",
Curacao: "CW",
Cyprus: "CY",
"Czech Republic": "CZ",
Denmark: "DK",
Djibouti: "DJ",
Dominica: "DM",
"Dominican Republic": "DO",
Ecuador: "EC",
Egypt: "EG",
"El Salvador": "SV",
"Equatorial Guinea": "GQ",
Eritrea: "ER",
Estonia: "EE",
Eswatini: "SZ",
Ethiopia: "ET",
"Falkland Islands (Malvinas)": "FK",
"Faroe Islands": "FO",
Fiji: "FJ",
Finland: "FI",
France: "FR",
"French Guiana": "GF",
"French Polynesia": "PF",
"French Southern Territories": "TF",
Gabon: "GA",
Gambia: "GM",
Georgia: "GE",
Germany: "DE",
Ghana: "GH",
Gibraltar: "GI",
Greece: "GR",
Greenland: "GL",
Grenada: "GD",
Guadeloupe: "GP",
Guam: "GU",
Guatemala: "GT",
Guernsey: "GG",
Guinea: "GN",
"Guinea-Bissau": "GW",
Guyana: "GY",
Haiti: "HT",
"Heard Island and Mcdonald Islands": "HM",
"Holy See (Vatican City State)": "VA",
Honduras: "HN",
"Hong Kong": "HK",
Hungary: "HU",
Iceland: "IS",
India: "IN",
Indonesia: "ID",
"Iran, Islamic Republic Of": "IR",
Iraq: "IQ",
Ireland: "IE",
"Isle of Man": "IM",
Israel: "IL",
Italy: "IT",
Jamaica: "JM",
Japan: "JP",
Jersey: "JE",
Jordan: "JO",
Kazakhstan: "KZ",
Kenya: "KE",
Kiribati: "KI",
'Korea, Democratic People"S Republic of': "KP",
"Korea, Republic of": "KR",
Kuwait: "KW",
Kyrgyzstan: "KG",
'Lao People"S Democratic Republic': "LA",
Laos: "LA",
Latvia: "LV",
Lebanon: "LB",
Lesotho: "LS",
Liberia: "LR",
"Libyan Arab Jamahiriya": "LY",
Liechtenstein: "LI",
Lithuania: "LT",
Luxembourg: "LU",
Macao: "MO",
"Macedonia, The Former Yugoslav Republic of": "MK",
Madagascar: "MG",
Malawi: "MW",
Malaysia: "MY",
Maldives: "MV",
Mali: "ML",
Malta: "MT",
"Marshall Islands": "MH",
Martinique: "MQ",
Mauritania: "MR",
Mauritius: "MU",
Mayotte: "YT",
Mexico: "MX",
"Micronesia, Federated States of": "FM",
"Moldova, Republic of": "MD",
Monaco: "MC",
Mongolia: "MN",
Montenegro: "ME",
Montserrat: "MS",
Morocco: "MA",
Mozambique: "MZ",
Myanmar: "MM",
Namibia: "NA",
Nauru: "NR",
Nepal: "NP",
Netherlands: "NL",
"Netherlands Antilles": "AN",
"New Caledonia": "NC",
"New Zealand": "NZ",
Nicaragua: "NI",
Niger: "NE",
Nigeria: "NG",
Niue: "NU",
"Norfolk Island": "NF",
"Northern Mariana Islands": "MP",
Norway: "NO",
Oman: "OM",
Pakistan: "PK",
Palau: "PW",
Palestine: "PS",
Panama: "PA",
"Papua New Guinea": "PG",
Paraguay: "PY",
Peru: "PE",
Philippines: "PH",
Pitcairn: "PN",
Poland: "PL",
Portugal: "PT",
"Puerto Rico": "PR",
Qatar: "QA",
RWANDA: "RW",
Reunion: "RE",
Romania: "RO",
"Russian Federation": "RU",
"Saint Barthelemy": "BL",
"Saint Helena": "SH",
"Saint Kitts and Nevis": "KN",
"Saint Lucia": "LC",
"Saint Martin": "MF",
"Saint Pierre and Miquelon": "PM",
"Saint Vincent and the Grenadines": "VC",
Samoa: "WS",
"San Marino": "SM",
"Sao Tome and Principe": "ST",
"Saudi Arabia": "SA",
Senegal: "SN",
Serbia: "RS",
Seychelles: "SC",
"Sierra Leone": "SL",
Singapore: "SG",
"Sint Maarten": "SX",
Slovakia: "SK",
Slovenia: "SI",
"Solomon Islands": "SB",
Somalia: "SO",
"South Africa": "ZA",
"South Georgia and the South Sandwich Islands": "GS",
"South Sudan": "SS",
Spain: "ES",
"Sri Lanka": "LK",
Sudan: "SD",
Suriname: "SR",
"Svalbard and Jan Mayen": "SJ",
Sweden: "SE",
Switzerland: "CH",
"Syrian Arab Republic": "SY",
Taiwan: "TW",
Tajikistan: "TJ",
"Tanzania, United Republic of": "TZ",
Thailand: "TH",
"Timor-Leste": "TL",
Togo: "TG",
Tokelau: "TK",
Tonga: "TO",
"Trinidad and Tobago": "TT",
Tunisia: "TN",
Turkey: "TR",
Turkmenistan: "TM",
"Turks and Caicos Islands": "TC",
Tuvalu: "TV",
Uganda: "UG",
Ukraine: "UA",
"United Arab Emirates": "AE",
"United Kingdom": "GB",
"United States": "US",
"United States Minor Outlying Islands": "UM",
Uruguay: "UY",
Uzbekistan: "UZ",
Vanuatu: "VU",
Venezuela: "VE",
"Viet Nam": "VN",
Vietnam: "VN",
"Virgin Islands, British": "VG",
"Virgin Islands, U.S.": "VI",
"Wallis and Futuna": "WF",
"Western Sahara": "EH",
Yemen: "YE",
Zambia: "ZM",
Zimbabwe: "ZW",
"Åland Islands": "AX",
} as const
export const countries = Object.keys(countriesMap).map((country) => ({
code: countriesMap[country as keyof typeof countriesMap],
name: country as keyof typeof countriesMap,
}))
@@ -0,0 +1,80 @@
.container {
position: relative;
}
.comboBoxContainer {
position: relative;
height: 60px;
}
.label {
position: absolute;
left: var(--Spacing-x2);
top: var(--Spacing-x-one-and-half);
pointer-events: none;
}
.input {
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-radius: var(--Corner-radius-Medium);
border-style: solid;
border-width: 1px;
padding: var(--Spacing-x4) var(--Spacing-x2) var(--Spacing-x1);
width: 100%;
height: 100%;
&[aria-invalid="true"],
&[data-invalid="true"] {
border-color: var(--Scandic-Red-60);
}
}
.input,
.listBoxItem {
color: var(--Main-Grey-100);
}
.button {
background: none;
border: none;
cursor: pointer;
grid-area: chevron;
height: 100%;
justify-self: flex-end;
padding-left: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
position: absolute;
right: 0;
bottom: 0;
outline: none;
}
.popover {
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Medium);
left: 0px;
max-height: 400px;
overflow: auto;
padding: var(--Spacing-x2);
top: calc(60px + var(--Spacing-x1));
width: 100%;
}
.listBoxItem {
padding: var(--Spacing-x1) var(--Spacing-x1) var(--Spacing-x1)
var(--Spacing-x2);
}
.listBoxItem[data-focused="true"],
.listBoxItem[data-focus-visible="true"],
.listBoxItem[data-selected="true"],
.listBoxItem:hover {
background-color: var(--Scandic-Blue-00);
border-radius: var(--Corner-radius-Medium);
cursor: pointer;
outline: none;
}
@@ -0,0 +1,13 @@
import type { RegisterOptions } from "react-hook-form"
export type CountryProps = {
className?: string
label: string
name?: string
placeholder?: string
readOnly?: boolean
registerOptions?: RegisterOptions
}
export type CountryPortalContainer = HTMLDivElement | undefined
export type CountryPortalContainerArgs = HTMLDivElement | null
@@ -0,0 +1,136 @@
"use client"
import { useState } from "react"
import {
Button,
ComboBox,
Input,
type Key,
ListBox,
ListBoxItem,
Popover,
} from "react-aria-components"
import { useController, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Label from "@/components/TempDesignSystem/Form/Label"
import SelectChevron from "@/components/TempDesignSystem/Form/SelectChevron"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import ErrorMessage from "../ErrorMessage"
import { countries } from "./countries"
import styles from "./country.module.css"
import type {
CountryPortalContainer,
CountryPortalContainerArgs,
CountryProps,
} from "./country"
export default function CountrySelect({
className = "",
label,
name = "country",
readOnly = false,
registerOptions = {},
}: CountryProps) {
const lang = useLang()
const intl = useIntl()
const [rootDiv, setRootDiv] = useState<CountryPortalContainer>(undefined)
function setRef(node: CountryPortalContainerArgs) {
if (node) {
setRootDiv(node)
}
}
const { control, setValue } = useFormContext()
const { field, formState } = useController({
control,
name,
rules: registerOptions,
})
function handleChange(country: Key | null) {
setValue(name, country ?? "")
}
const selectCountryLabel = intl.formatMessage({ id: "Select a country" })
const collator = new Intl.Collator(lang)
return (
<div className={`${styles.container} ${className}`} ref={setRef}>
<ComboBox
aria-label={intl.formatMessage({ id: "Select country of residence" })}
isReadOnly={readOnly}
isRequired={!!registerOptions?.required}
name={field.name}
onBlur={field.onBlur}
onSelectionChange={handleChange}
ref={field.ref}
selectedKey={field.value}
data-testid={name}
>
<div className={styles.comboBoxContainer}>
<Label
className={styles.label}
size="small"
required={!!registerOptions.required}
>
{label}
</Label>
<Body asChild fontOnly>
<Input
aria-label={selectCountryLabel}
className={styles.input}
placeholder={selectCountryLabel}
/>
</Body>
<Button className={styles.button}>
<SelectChevron />
</Button>
</div>
<ErrorMessage errors={formState.errors} name={name} />
<Popover
className={styles.popover}
placement="bottom"
shouldFlip={false}
shouldUpdatePosition={false}
/**
* react-aria uses portals to render Popover in body
* unless otherwise specified. We need it to be contained
* by this component to both access css variables assigned
* on the container as well as to not overflow it at any time.
*/
UNSTABLE_portalContainer={rootDiv}
>
<ListBox>
{countries
.map((country) => ({
...country,
localizedDisplayName:
intl.formatDisplayName(country.code, { type: "region" }) ||
country.name,
}))
.sort((a, b) =>
collator.compare(a.localizedDisplayName, b.localizedDisplayName)
)
.map((country, idx) => {
return (
<Body asChild fontOnly key={`${country.code}-${idx}`}>
<ListBoxItem
aria-label={country.name}
className={styles.listBoxItem}
id={country.code}
>
{country.localizedDisplayName}
</ListBoxItem>
</Body>
)
})}
</ListBox>
</Popover>
</ComboBox>
</div>
)
}
@@ -0,0 +1,35 @@
.container {
display: grid;
gap: var(--Spacing-x2);
grid-template-areas: "year month day";
grid-template-columns: 1fr 1fr 1fr;
width: var(--width);
}
@media (max-width: 350px) {
.container {
display: flex;
flex-direction: column;
}
}
.day {
grid-area: day;
}
.month {
grid-area: month;
}
.year {
grid-area: year;
}
/* TODO: Handle this in Select component.
- out of scope for now.
*/
.day.invalid > div > div,
.month.invalid > div > div,
.year.invalid > div > div {
border-color: var(--Scandic-Red-60);
}
@@ -0,0 +1,145 @@
import { describe, expect, test } from "@jest/globals" // importing because of type conflict with globals from Cypress
import { render, screen } from "@testing-library/react"
import { type UserEvent, userEvent } from "@testing-library/user-event"
import { FormProvider, useForm } from "react-hook-form"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { getLocalizedMonthName } from "@/utils/dateFormatting"
import Date from "./index"
jest.mock("react-intl", () => ({
useIntl: () => ({
formatMessage: (message: { id: string }) => message.id,
formatNumber: (value: number) => value,
}),
}))
interface FormWrapperProps {
defaultValues: Record<string, unknown>
children: React.ReactNode
onSubmit: (data: unknown) => void
}
function FormWrapper({ defaultValues, children, onSubmit }: FormWrapperProps) {
const methods = useForm({
defaultValues,
})
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((data) => onSubmit(data))}>
{children}
<button type="submit">Submit</button>
</form>
</FormProvider>
)
}
async function selectOption(user: UserEvent, name: RegExp, value: string) {
// since its not a proper Select element selectOptions from userEvent doesn't work
const select = screen.queryByRole("button", { name })
if (select) {
await user.click(select)
const option = screen.queryByRole("option", { name: value })
if (option) {
await user.click(option)
} else {
await user.click(select) // click select again to close it
}
}
}
const testCases = [
{
description: "date is set and submitted successfully",
defaultValue: "",
dateOfBirth: "1987-12-05",
expectedOutput: {
dateOfBirth: "1987-12-05",
year: 1987,
month: 12,
day: 5,
},
},
{
description: "sets default value and submits successfully",
defaultValue: "2000-01-01",
dateOfBirth: "",
expectedOutput: {
dateOfBirth: "2000-01-01",
year: 2000,
month: 1,
day: 1,
},
},
{
description: "accepts date exactly 18 years old",
defaultValue: "",
dateOfBirth: dt().subtract(18, "year").format("YYYY-MM-DD"),
expectedOutput: {
dateOfBirth: dt().subtract(18, "year").format("YYYY-MM-DD"),
},
},
{
description: "rejects date below 18 years old - by year",
defaultValue: "",
dateOfBirth: dt().subtract(17, "year").format("YYYY-MM-DD"),
expectedOutput: {
dateOfBirth: "",
},
},
{
description: "rejects date below 18 years old - by month",
defaultValue: "",
dateOfBirth: dt().subtract(18, "year").add(1, "month").format("YYYY-MM-DD"),
expectedOutput: {
dateOfBirth: "",
},
},
{
description: "rejects date below 18 years old - by day",
defaultValue: "",
dateOfBirth: dt().subtract(18, "year").add(1, "day").format("YYYY-MM-DD"),
expectedOutput: {
dateOfBirth: "",
},
},
]
describe("Date input", () => {
test.each(testCases)(
"$description",
async ({ defaultValue, dateOfBirth, expectedOutput }) => {
const user = userEvent.setup()
const handleSubmit = jest.fn()
render(
<FormWrapper
defaultValues={{ dateOfBirth: defaultValue }}
onSubmit={handleSubmit}
>
<Date name="dateOfBirth" />
</FormWrapper>
)
const date = dt(dateOfBirth).toDate()
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
await selectOption(user, /year/i, year.toString())
await selectOption(user, /month/i, getLocalizedMonthName(month, Lang.en))
await selectOption(user, /day/i, day.toString())
const submitButton = screen.getByRole("button", { name: /submit/i })
await user.click(submitButton)
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining(expectedOutput)
)
}
)
})
@@ -0,0 +1,13 @@
import type { RegisterOptions } from "react-hook-form"
export const enum DateName {
date = "date",
day = "day",
month = "month",
year = "year",
}
export interface DateProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {
name: string
registerOptions?: RegisterOptions
}
@@ -0,0 +1,216 @@
"use client"
import { parseDate } from "@internationalized/date"
import { useEffect } from "react"
import { DateInput, DatePicker, Group, type Key } from "react-aria-components"
import { useController, useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Select from "@/components/TempDesignSystem/Select"
import useLang from "@/hooks/useLang"
import { getLocalizedMonthName } from "@/utils/dateFormatting"
import { rangeArray } from "@/utils/rangeArray"
import ErrorMessage from "../ErrorMessage"
import { DateName, type DateProps } from "./date"
import styles from "./date.module.css"
export default function DateSelect({ name, registerOptions = {} }: DateProps) {
const intl = useIntl()
const lang = useLang()
const { control, setValue, formState, watch } = useFormContext()
const { field, fieldState } = useController({
control,
name,
rules: registerOptions,
})
const currentDateValue = useWatch({ name })
const year = watch(DateName.year)
const month = watch(DateName.month)
const day = watch(DateName.day)
const minAgeDate = dt().subtract(18, "year").toDate() // age 18
const minAgeYear = minAgeDate.getFullYear()
const minAgeMonth = year === minAgeYear ? minAgeDate.getMonth() + 1 : null
const minAgeDay =
Number(year) === minAgeYear && Number(month) === minAgeMonth
? minAgeDate.getDate()
: null
const months = rangeArray(1, minAgeMonth ?? 12).map((month) => ({
value: month,
label: getLocalizedMonthName(month, lang),
}))
const years = rangeArray(1900, minAgeYear)
.reverse()
.map((year) => ({ value: year, label: year.toString() }))
// Calculate available days based on selected year and month
const daysInMonth = getDaysInMonth(
year ? Number(year) : null,
month ? Number(month) - 1 : null
)
const days = rangeArray(1, minAgeDay ?? daysInMonth).map((day) => ({
value: day,
label: `${day}`,
}))
const dayLabel = intl.formatMessage({ id: "Day" })
const monthLabel = intl.formatMessage({ id: "Month" })
const yearLabel = intl.formatMessage({ id: "Year" })
useEffect(() => {
if (formState.isSubmitting) return
if (month && day) {
const maxDays = getDaysInMonth(
year ? Number(year) : null,
Number(month) - 1
)
const adjustedDay = Number(day) > maxDays ? maxDays : Number(day)
if (adjustedDay !== Number(day)) {
setValue(DateName.day, adjustedDay)
}
}
const newDate = dt()
.year(Number(year))
.month(Number(month) - 1)
.date(Number(day))
if (newDate.isValid()) {
setValue(name, newDate.format("YYYY-MM-DD"), {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
}, [year, month, day, setValue, name, formState.isSubmitting])
let dateValue = null
try {
/**
* parseDate throws when its not a valid
* date, but we can't check isNan since
* we recieve the date as "1999-01-01"
*/
dateValue = dt(currentDateValue).isValid()
? parseDate(currentDateValue)
: null
} catch (error) {
console.warn("Known error for parse date in DateSelect: ", error)
}
useEffect(() => {
if (formState.isSubmitting) return
if (!(day && month && year) && dateValue) {
setValue(DateName.day, Number(dateValue.day))
setValue(DateName.month, Number(dateValue.month))
setValue(DateName.year, Number(dateValue.year))
}
}, [setValue, formState.isSubmitting, dateValue, day, month, year])
return (
<DatePicker
aria-label={intl.formatMessage({ id: "Select date of birth" })}
isRequired={!!registerOptions.required}
isInvalid={!formState.isValid}
name={name}
ref={field.ref}
value={dateValue}
data-testid={name}
>
<Group>
<DateInput className={styles.container}>
{(segment) => {
switch (segment.type) {
case "day":
return (
<div
className={`${styles.day} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select
aria-label={dayLabel}
items={days}
label={dayLabel}
name={DateName.day}
onSelect={(key: Key) =>
setValue(DateName.day, Number(key))
}
required
tabIndex={3}
value={segment.isPlaceholder ? undefined : segment.value}
/>
</div>
)
case "month":
return (
<div
className={`${styles.month} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select
aria-label={monthLabel}
items={months}
label={monthLabel}
name={DateName.month}
onSelect={(key: Key) =>
setValue(DateName.month, Number(key))
}
required
tabIndex={2}
value={segment.isPlaceholder ? undefined : segment.value}
/>
</div>
)
case "year":
return (
<div
className={`${styles.year} ${fieldState.invalid ? styles.invalid : ""}`}
>
<Select
aria-label={yearLabel}
items={years}
label={yearLabel}
name={DateName.year}
onSelect={(key: Key) =>
setValue(DateName.year, Number(key))
}
required
tabIndex={1}
value={segment.isPlaceholder ? undefined : segment.value}
/>
</div>
)
default:
/** DateInput forces return of ReactElement */
return <></>
}
}}
</DateInput>
</Group>
<ErrorMessage errors={formState.errors} name={field.name} />
</DatePicker>
)
}
function getDaysInMonth(year: number | null, month: number | null): number {
if (month === null) {
return 31
}
// If month is February and no year selected, return minimum.
if (month === 1 && !year) {
return 28
}
const yearToUse = year ?? new Date().getFullYear()
return dt(`${yearToUse}-${month + 1}-01`).daysInMonth()
}
@@ -0,0 +1,13 @@
import { InfoCircleIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./error.module.css"
export default function Error({ children }: React.PropsWithChildren) {
return (
<Caption className={styles.message} fontOnly>
<InfoCircleIcon color="red" />
{children}
</Caption>
)
}
@@ -0,0 +1,7 @@
.message {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;
}
@@ -0,0 +1,18 @@
import type { FieldValuesFromFieldErrors } from "@hookform/error-message"
import type {
FieldErrors,
FieldName,
FieldValues,
Message,
MultipleFieldErrors,
} from "react-hook-form"
export type ErrorMessageProps<TFieldErrors> = {
errors?: FieldErrors<FieldValues>
name: FieldName<FieldValuesFromFieldErrors<TFieldErrors>>
message?: Message
render?: (data: {
message: Message
messages?: MultipleFieldErrors
}) => React.ReactNode
}
@@ -0,0 +1,18 @@
import { ErrorMessage as RHFErrorMessage } from "@hookform/error-message"
import Error from "./Error"
import type { ErrorMessageProps } from "./errorMessage"
export default function ErrorMessage<T>({
errors,
name,
}: ErrorMessageProps<T>) {
return (
<RHFErrorMessage
errors={errors}
name={name}
render={({ message }) => <Error>{message}</Error>}
/>
)
}
@@ -0,0 +1,7 @@
import Chip from "./_Chip"
import type { FilterChipCheckboxProps } from "@/types/components/form/filterChip"
export default function CheckboxChip(props: FilterChipCheckboxProps) {
return <Chip {...props} type="checkbox" />
}
@@ -0,0 +1,40 @@
.label {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
padding: calc(var(--Spacing-x1) - 2px) var(--Spacing-x-one-and-half);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Small);
background-color: var(--Base-Surface-Secondary-light-Normal);
cursor: pointer;
height: 32px;
background-color: var(--Base-Surface-Secondary-light-Normal);
}
.label[data-selected="true"],
.label[data-selected="true"]:hover {
background-color: var(--Primary-Light-Surface-Normal);
border-color: var(--Base-Border-Hover);
}
.label:hover {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
border-color: var(--Base-Border-Subtle);
}
.label[data-disabled="true"] {
background-color: var(--UI-Input-Controls-Surface-Disabled);
border-color: var(--UI-Input-Controls-Border-Disabled);
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.caption {
display: none;
}
@media (min-width: 768px) {
.caption {
display: block;
}
}
@@ -0,0 +1,63 @@
import { useMemo } from "react"
import { useFormContext } from "react-hook-form"
import { HeartIcon, InfoCircleIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./chip.module.css"
import type { FilterChipProps } from "@/types/components/form/filterChip"
export default function FilterChip({
Icon = HeartIcon,
iconHeight = 20,
iconWidth = 20,
id,
name,
label,
type,
value,
selected,
disabled,
hasTooltip,
}: FilterChipProps) {
const { register } = useFormContext()
const color = useMemo(() => {
if (selected) return "burgundy"
if (disabled) return "disabled"
return "uiTextPlaceholder"
}, [selected, disabled])
return (
<label
className={styles.label}
data-selected={selected}
data-disabled={disabled}
>
<Icon
className={styles.icon}
color={color}
height={iconHeight}
width={iconWidth}
/>
<Caption type="bold" color={color} className={styles.caption}>
{label}
</Caption>
{hasTooltip && (
<InfoCircleIcon color={color} height={iconHeight} width={iconWidth} />
)}
<input
aria-hidden
id={id || name}
hidden
type={type}
value={value}
disabled={disabled}
{...register(name)}
/>
</label>
)
}
@@ -0,0 +1,28 @@
import { type ForwardedRef, forwardRef, useId } from "react"
import { Input as AriaInput, Label as AriaLabel } from "react-aria-components"
import Label from "@/components/TempDesignSystem/Form/Label"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./input.module.css"
import type { AriaInputWithLabelProps } from "./input"
const AriaInputWithLabel = forwardRef(function AriaInputWithLabelComponent(
{ label, ...props }: AriaInputWithLabelProps,
ref: ForwardedRef<HTMLInputElement>
) {
const uniqueId = useId()
const inputId = `${uniqueId}-${props.name}`
return (
<AriaLabel className={styles.container} htmlFor={inputId}>
<Body asChild fontOnly>
<AriaInput {...props} className={styles.input} ref={ref} id={inputId} />
</Body>
<Label required={!!props.required}>{label}</Label>
</AriaLabel>
)
})
export default AriaInputWithLabel
@@ -0,0 +1,61 @@
.container {
align-content: center;
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Medium);
display: grid;
min-width: 0; /* allow shrinkage */
height: 60px;
padding: var(--Spacing-x1) var(--Spacing-x2);
transition: border-color 200ms ease;
}
.container:has(.input:active, .input:focus) {
border-color: var(--Scandic-Blue-90);
}
.container:has(.input:disabled) {
background-color: var(--Main-Grey-10);
border: none;
color: var(--Main-Grey-40);
}
.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) {
border-color: var(--Scandic-Red-60);
}
.input {
background: none;
border: none;
color: var(--Main-Grey-100);
height: 18px;
margin: 0;
order: 2;
overflow: visible;
padding: 0;
}
.container:has(.input:not(:placeholder-shown)) {
align-content: space-around;
gap: var(--Spacing-x-half);
}
.input:not(:active, :focus):placeholder-shown {
height: 0px;
transition: height 150ms ease;
}
.input:focus,
.input:focus:placeholder-shown,
.input:active,
.input:active:placeholder-shown {
height: 18px;
transition: height 150ms ease;
outline: none;
}
.input:disabled {
color: var(--Main-Grey-40);
}
@@ -0,0 +1,4 @@
export interface AriaInputWithLabelProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label: string
}
@@ -0,0 +1,83 @@
"use client"
import { Text, TextField } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./input.module.css"
import type { HTMLAttributes, WheelEvent } from "react"
import type { InputProps } from "./input"
export default function Input({
"aria-label": ariaLabel,
className = "",
disabled = false,
helpText = "",
label,
maxLength,
name,
placeholder = "",
readOnly = false,
registerOptions = {},
type = "text",
}: InputProps) {
const { control } = useFormContext()
let numberAttributes: HTMLAttributes<HTMLInputElement> = {}
if (type === "number") {
numberAttributes.onWheel = function (evt: WheelEvent<HTMLInputElement>) {
evt.currentTarget.blur()
}
}
return (
<Controller
disabled={disabled}
control={control}
name={name}
rules={registerOptions}
render={({ field, fieldState }) => (
<TextField
aria-label={ariaLabel}
className={className}
isDisabled={field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
>
<AriaInputWithLabel
{...field}
aria-labelledby={field.name}
id={field.name}
label={label}
maxLength={maxLength}
placeholder={placeholder}
readOnly={readOnly}
required={!!registerOptions.required}
type={type}
/>
{helpText && !fieldState.error ? (
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<CheckIcon height={20} width={30} />
{helpText}
</Text>
</Caption>
) : null}
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</TextField>
)}
/>
)
}
@@ -0,0 +1,13 @@
.helpText {
align-items: flex-start;
display: flex;
gap: var(--Spacing-x-half);
}
.error {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;
}
@@ -0,0 +1,9 @@
import type { RegisterOptions } from "react-hook-form"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
helpText?: string
label: string
name: string
registerOptions?: RegisterOptions
}
@@ -0,0 +1,18 @@
import { labelVariants } from "./variants"
import type { LabelProps } from "./label"
export default function Label({
children,
className,
required,
size,
}: LabelProps) {
const classNames = labelVariants({
className,
size,
required,
})
return <span className={classNames}>{children}</span>
}
@@ -0,0 +1,73 @@
.label {
color: var(--UI-Grey-60);
font-family: "fira sans";
font-weight: 400;
letter-spacing: 0.03px;
line-height: 120%;
text-align: left;
transition: font-size 100ms ease;
}
span.small {
display: block;
font-size: 12px;
}
span.regular {
font-size: 16px;
order: 1;
}
span.discreet {
color: var(--Base-Text-High-contrast);
font-weight: 500;
order: unset;
}
span.required:after {
content: " *";
}
/* Handle input and textarea fields */
input:active ~ .label,
input:not(:placeholder-shown) ~ .label,
textarea:active ~ .label,
textarea:not(:placeholder-shown) ~ .label {
display: block;
font-size: 12px;
}
input:focus ~ .label,
textarea:focus ~ .label {
font-size: 12px;
}
input:placeholder-shown ~ .label,
textarea:placeholder-shown ~ .label {
grid-row: 1/-1;
}
input:placeholder-shown:focus ~ .label,
input:placeholder-shown:active ~ .label,
textarea:placeholder-shown.label,
textarea:placeholder-shown:active ~ .label {
margin-bottom: var(--Spacing-x-half);
}
input:disabled ~ .label,
textarea:disabled ~ .label,
:global(.select-container)[data-disabled] .label {
color: var(--Main-Grey-40);
}
/* Handle select fields */
:global(.select-button) .label {
order: unset;
}
:global(.select-container)[data-open="true"] .label:not(.discreet),
:global(.react-aria-SelectValue):has(:nth-child(2)) .label:not(.discreet),
:global(.select-button):active .label:not(.discreet) {
font-size: 12px;
margin-bottom: var(--Spacing-x-half);
}
@@ -0,0 +1,9 @@
import type { VariantProps } from "class-variance-authority"
import type { labelVariants } from "./variants"
export interface LabelProps
extends React.PropsWithChildren<React.HTMLAttributes<HTMLSpanElement>>,
VariantProps<typeof labelVariants> {
required?: boolean
}
@@ -0,0 +1,21 @@
import { cva } from "class-variance-authority"
import styles from "./label.module.css"
export const labelVariants = cva(styles.label, {
variants: {
size: {
small: styles.small,
regular: styles.regular,
discreet: styles.discreet,
},
required: {
true: styles.required,
false: "",
},
},
defaultVariants: {
size: "regular",
required: false,
},
})
@@ -0,0 +1,167 @@
"use client"
import { useState } from "react"
import { Text, TextField } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import {
CheckIcon,
CloseIcon,
EyeHideIcon,
EyeShowIcon,
InfoCircleIcon,
} from "@/components/Icons"
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { passwordValidators } from "@/utils/zod/passwordValidator"
import Button from "../../Button"
import { type IconProps, type NewPasswordProps } from "./newPassword"
import styles from "./newPassword.module.css"
import type { PasswordValidatorKey } from "@/types/components/form/newPassword"
export default function NewPassword({
name = "newPassword",
"aria-label": ariaLabel,
disabled = false,
placeholder = "",
registerOptions = {},
visibilityToggleable = true,
}: NewPasswordProps) {
const { control } = useFormContext()
const intl = useIntl()
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
return (
<Controller
disabled={disabled}
control={control}
name={name}
rules={registerOptions}
render={({ field, fieldState, formState }) => {
const errors = Object.values(formState.errors[name]?.types ?? []).flat()
return (
<TextField
aria-label={ariaLabel}
isDisabled={field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
type={
visibilityToggleable && isPasswordVisible ? "text" : "password"
}
>
<div className={styles.inputWrapper}>
<AriaInputWithLabel
{...field}
aria-labelledby={field.name}
id={field.name}
label={intl.formatMessage({ id: "New password" })}
placeholder={placeholder}
type={
visibilityToggleable && isPasswordVisible
? "text"
: "password"
}
/>
{visibilityToggleable ? (
<Button
className={styles.eyeIcon}
type="button"
variant="icon"
size="small"
intent="tertiary"
onClick={() => setIsPasswordVisible((value) => !value)}
>
{isPasswordVisible ? <EyeHideIcon /> : <EyeShowIcon />}
</Button>
) : null}
</div>
<PasswordValidation value={field.value} errors={errors} />
{!field.value && fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</TextField>
)
}}
/>
)
}
function Icon({ errorMessage, errors }: IconProps) {
return errors.includes(errorMessage) ? (
<CloseIcon color="red" height={20} width={20} />
) : (
<CheckIcon color="green" height={20} width={20} />
)
}
function PasswordValidation({
value,
errors,
}: {
value: string
errors: string[]
}) {
const intl = useIntl()
if (!value) return null
function getErrorMessage(key: PasswordValidatorKey) {
switch (key) {
case "length":
return intl.formatMessage(
{
id: "{min} to {max} characters",
},
{
min: 10,
max: 40,
}
)
case "hasUppercase":
return intl.formatMessage(
{ id: "{count} uppercase letter" },
{ count: 1 }
)
case "hasLowercase":
return intl.formatMessage(
{ id: "{count} lowercase letter" },
{ count: 1 }
)
case "hasNumber":
return intl.formatMessage({ id: "{count} number" }, { count: 1 })
case "hasSpecialChar":
return intl.formatMessage(
{ id: "{count} special character" },
{ count: 1 }
)
}
}
return (
<div className={styles.errors}>
{Object.entries(passwordValidators).map(([key, { message }]) => (
<Caption asChild color="black" key={key}>
<Text className={styles.helpText} slot="description">
<Icon errorMessage={message} errors={errors} />
{getErrorMessage(key as PasswordValidatorKey)}
</Text>
</Caption>
))}
</div>
)
}
@@ -0,0 +1,88 @@
.container {
align-content: center;
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Medium);
display: grid;
height: 60px;
padding: var(--Spacing-x1) var(--Spacing-x2);
transition: border-color 200ms ease;
position: relative;
}
.container:has(.input:active, .input:focus) {
border-color: var(--Scandic-Blue-90);
}
.container:has(.input:disabled) {
background-color: var(--Main-Grey-10);
border: none;
color: var(--Main-Grey-40);
}
.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) {
border-color: var(--Scandic-Red-60);
}
.input {
background: none;
border: none;
color: var(--Main-Grey-100);
height: 18px;
margin: 0;
order: 2;
overflow: visible;
padding: 0;
}
.input:not(:active, :focus):placeholder-shown {
height: 0px;
transition: height 150ms ease;
}
.input:focus,
.input:focus:placeholder-shown,
.input:active,
.input:active:placeholder-shown {
height: 18px;
transition: height 150ms ease;
outline: none;
}
.input:disabled {
color: var(--Main-Grey-40);
}
.helpText {
align-items: flex-start;
display: flex;
gap: var(--Spacing-x-half);
}
.error {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;
}
.errors {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x-one-and-half) var(--Spacing-x1);
padding-top: var(--Spacing-x1);
}
.eyeIcon {
position: absolute;
right: var(--Spacing-x2);
top: 50%;
transform: translateY(-50%);
}
.inputWrapper {
position: relative;
}
@@ -0,0 +1,13 @@
import type { RegisterOptions } from "react-hook-form"
export interface NewPasswordProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
registerOptions?: RegisterOptions
visibilityToggleable?: boolean
}
export interface IconProps {
errorMessage: string
errors: string[]
}
@@ -0,0 +1,166 @@
"use client"
import "react-international-phone/style.css"
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js"
import { TextField } from "react-aria-components"
import { useController, useFormContext, useWatch } from "react-hook-form"
import {
CountrySelector,
DialCodePreview,
type ParsedCountry,
usePhoneInput,
} from "react-international-phone"
import { useIntl } from "react-intl"
import { ChevronDownIcon } from "@/components/Icons"
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
import Label from "@/components/TempDesignSystem/Form/Label"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import styles from "./phone.module.css"
import type { ChangeEvent } from "react"
import type {
LowerCaseCountryCode,
PhoneProps,
} from "@/types/components/form/phone"
import type { Lang } from "@/constants/languages"
export default function Phone({
ariaLabel = "Phone number input",
className = "",
disabled = false,
label,
name = "phoneNumber",
placeholder = "",
readOnly = false,
registerOptions = {
required: true,
},
}: PhoneProps) {
const intl = useIntl()
const lang = useLang()
const { control, setValue, trigger } = useFormContext()
const phone = useWatch({ name })
const { field, fieldState, formState } = useController({
control,
disabled,
name,
rules: registerOptions,
})
const defaultPhoneNumber = formState.defaultValues?.phoneNumber
// If defaultPhoneNumber exists and is valid, parse it to get the country code,
// otherwise set the default country from the lang.
const defaultCountry = isValidPhoneNumber(defaultPhoneNumber)
? parsePhoneNumber(defaultPhoneNumber).country?.toLowerCase()
: getDefaultCountryFromLang(lang)
const { country, handlePhoneValueChange, inputValue, setCountry } =
usePhoneInput({
defaultCountry,
disableDialCodeAndPrefix: true,
forceDialCode: true,
value: phone,
onChange: (value) => {
// If not checked trigger(name) forces validation on mount
// which shows error message before user even can see the form
if (value.inputValue) {
setValue(name, value.phone)
trigger(name)
} else {
setValue(name, "")
}
},
})
function handleSelectCountry(value: ParsedCountry) {
setCountry(value.iso2)
}
function handleChange(evt: ChangeEvent<HTMLInputElement>) {
handlePhoneValueChange(evt)
}
return (
<div className={`${styles.phone} ${className}`}>
<CountrySelector
disabled={readOnly}
dropdownArrowClassName={styles.arrow}
flagClassName={styles.flag}
onSelect={handleSelectCountry}
preferredCountries={["de", "dk", "fi", "no", "se", "gb"]}
selectedCountry={country.iso2}
renderButtonWrapper={(props) => (
<button
{...props.rootProps}
className={styles.select}
tabIndex={0}
type="button"
data-testid="country-selector"
>
<Label required={!!registerOptions.required} size="small">
{intl.formatMessage({ id: "Country code" })}
</Label>
<span className={styles.selectContainer}>
{props.children}
<Body asChild fontOnly>
<DialCodePreview
className={styles.dialCode}
dialCode={country.dialCode}
prefix="+"
/>
</Body>
<ChevronDownIcon
className={styles.chevron}
color="grey80"
height={18}
width={18}
/>
</span>
</button>
)}
/>
<TextField
aria-label={ariaLabel}
defaultValue={field.value}
isDisabled={disabled ?? field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions?.required}
isReadOnly={readOnly}
name={field.name}
type="tel"
>
<AriaInputWithLabel
{...field}
id={field.name}
label={label}
onChange={handleChange}
placeholder={placeholder}
readOnly={readOnly}
required={!!registerOptions.required}
type="tel"
value={inputValue}
/>
<ErrorMessage errors={formState.errors} name={field.name} />
</TextField>
</div>
)
}
function getDefaultCountryFromLang(lang: Lang): LowerCaseCountryCode {
const countryMap: Record<Lang, LowerCaseCountryCode> = {
sv: "se",
da: "dk",
fi: "fi",
no: "no",
de: "de",
en: "se", // Default to Sweden for English
}
return countryMap[lang] || "se"
}
@@ -0,0 +1,108 @@
.phone {
display: grid;
grid-template-columns: 1fr;
gap: var(--Spacing-x2);
--react-international-phone-background-color: var(--Main-Grey-White);
--react-international-phone-border-color: var(--Scandic-Beige-40);
--react-international-phone-dropdown-preferred-list-divider-color: var(
--Scandic-Brand-Pale-Peach
);
--react-international-phone-selected-dropdown-item-background-color: var(
--Scandic-Blue-00
);
--react-international-phone-text-color: var(--Main-Grey-100);
--react-international-phone-dropdown-preferred-list-divider-margin: 8px;
--react-international-phone-height: 60px;
--react-international-phone-dropdown-top: calc(
var(--react-international-phone-height) + var(--Spacing-x1)
);
--react-international-phone-dial-code-preview-font-size: var(
--typography-Body-Regular-fontSize
);
}
@media (min-width: 385px) {
.phone {
grid-template-columns: minmax(124px, 164px) 1fr;
}
}
.phone:has(.input:active, .input:focus) {
--react-international-phone-border-color: var(--Scandic-Blue-90);
}
.phone :global(.react-international-phone-country-selector-dropdown) {
background: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Medium);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.08);
gap: var(--Spacing-x1);
outline: none;
padding: var(--Spacing-x2);
}
.phone
:global(.react-international-phone-country-selector-dropdown__list-item) {
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x1) var(--Spacing-x1) var(--Spacing-x1)
var(--Spacing-x-one-and-half);
}
.phone
:global(.react-international-phone-country-selector-button__button-content) {
align-self: center;
}
.select {
align-content: center;
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Medium);
display: grid;
gap: var(--Spacing-x-half);
grid-template-rows: auto auto;
height: 60px;
padding: var(--Spacing-x1) var(--Spacing-x2);
transition: border-color 200ms ease;
}
.select {
width: 100%;
}
.select[aria-expanded="true"] .chevron {
transform: rotate(180deg);
}
.selectContainer {
background-color: var(--Main-Grey-White);
border: none;
display: grid;
gap: var(--Spacing-x1);
grid-template-columns: auto 1fr auto;
height: 18px;
justify-content: flex-start;
order: 2;
}
.arrow {
display: none;
}
.flag {
height: 18px;
margin: 0;
width: 18px;
}
.select .dialCode {
border: none;
color: var(--UI-Text-High-contrast);
line-height: 1;
justify-self: flex-start;
padding: 0;
}
@@ -0,0 +1,41 @@
"use client"
import { useController, useFormContext } from "react-hook-form"
import ReactAriaSelect from "@/components/TempDesignSystem/Select"
import type { SelectProps } from "./select"
export default function Select({
className,
items,
label,
disabled,
name,
isNestedInModal = false,
registerOptions = {},
defaultSelectedKey,
}: SelectProps) {
const { control } = useFormContext()
const { field } = useController({
control,
name,
rules: registerOptions,
})
return (
<ReactAriaSelect
className={className}
defaultSelectedKey={defaultSelectedKey || field.value}
disabled={disabled || field.disabled}
items={items}
label={label}
aria-label={label}
name={field.name}
onBlur={field.onBlur}
onSelect={field.onChange}
value={field.value}
data-testid={name}
isNestedInModal={isNestedInModal}
/>
)
}
@@ -0,0 +1,12 @@
import type { RegisterOptions } from "react-hook-form"
import type { SelectProps as ReactAriaSelectProps } from "@/components/TempDesignSystem/Select/select"
export interface SelectProps
extends Omit<
React.SelectHTMLAttributes<HTMLSelectElement>,
"name" | "onSelect" | "placeholder"
>,
Omit<ReactAriaSelectProps, "onSelect" | "ref" | "value"> {
registerOptions?: RegisterOptions
}
@@ -0,0 +1,7 @@
.chevron {
display: flex;
}
div[data-rac][data-open="true"] .chevron {
transform: rotate(180deg);
}
@@ -0,0 +1,13 @@
import { ChevronDownIcon } from "@/components/Icons"
import styles from "./chevron.module.css"
import type { IconProps } from "@/types/components/icon"
export default function SelectChevron(props: IconProps) {
return (
<span aria-hidden="true" className={styles.chevron}>
<ChevronDownIcon color="grey80" width={20} height={20} {...props} />
</span>
)
}
@@ -0,0 +1,45 @@
"use client"
import { Switch as AriaSwitch } from "react-aria-components"
import { useController, useFormContext } from "react-hook-form"
import { InfoCircleIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./switch.module.css"
import type { SwitchProps } from "@/types/components/switch"
export default function Switch({
className,
name,
children,
registerOptions,
}: React.PropsWithChildren<SwitchProps>) {
const { control } = useFormContext()
const { field, fieldState } = useController({
control,
name,
rules: registerOptions,
})
return (
<AriaSwitch
className={`${styles.container} ${className}`}
isSelected={field.value}
onChange={field.onChange}
data-testid={name}
isDisabled={registerOptions?.disabled}
excludeFromTabOrder
>
{children}
<span className={styles.switch} tabIndex={0}></span>
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</AriaSwitch>
)
}
@@ -0,0 +1,46 @@
.container {
display: flex;
flex-direction: row;
color: var(--text-color);
cursor: pointer;
width: 100%;
justify-content: space-between;
}
.switch {
width: 40px;
height: 24px;
border: 2px solid var(--UI-Input-Controls-Border-Normal);
background: var(--UI-Input-Controls-Surface-Normal);
border-radius: 24px;
transition: all 200ms;
display: block;
&:before {
content: "";
display: block;
margin: 2px;
width: 16px;
height: 16px;
background: var(--UI-Input-Controls-Border-Normal);
border-radius: 100%;
transition: all 200ms;
}
}
.container[data-selected] {
.switch {
border-color: var(--UI-Input-Controls-Fill-Selected);
background: var(--UI-Input-Controls-Fill-Selected);
&:before {
background: var(--UI-Input-Controls-Surface-Normal);
transform: translateX(100%);
}
}
}
.container[data-focus-visible] .switch {
outline: 2px solid var(--focus-ring-color);
outline-offset: 2px;
}
@@ -0,0 +1,82 @@
"use client"
import {
Label as AriaLabel,
Text,
TextArea as AriaTextArea,
TextField,
} from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
import Label from "@/components/TempDesignSystem/Form/Label"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Body from "../../Text/Body"
import styles from "./textarea.module.css"
import type { TextAreaProps } from "./input"
export default function TextArea({
"aria-label": ariaLabel,
className = "",
disabled = false,
helpText = "",
label,
name,
placeholder = "",
readOnly = false,
registerOptions = {},
}: TextAreaProps) {
const { control } = useFormContext()
return (
<Controller
disabled={disabled}
control={control}
name={name}
rules={registerOptions}
render={({ field, fieldState }) => (
<TextField
aria-label={ariaLabel}
className={className}
isDisabled={field.disabled}
isRequired={!!registerOptions.required}
onBlur={field.onBlur}
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
>
<AriaLabel className={styles.container} htmlFor={name}>
<Body asChild fontOnly>
<AriaTextArea
{...field}
aria-labelledby={field.name}
id={name}
placeholder={placeholder}
readOnly={readOnly}
required={!!registerOptions.required}
className={styles.textarea}
/>
</Body>
<Label required={!!registerOptions.required}>{label}</Label>
</AriaLabel>
{helpText && !fieldState.error ? (
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<CheckIcon height={20} width={30} />
{helpText}
</Text>
</Caption>
) : null}
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</TextField>
)}
/>
)
}
@@ -0,0 +1,9 @@
import type { RegisterOptions } from "react-hook-form"
export interface TextAreaProps
extends React.InputHTMLAttributes<HTMLTextAreaElement> {
helpText?: string
label: string
name: string
registerOptions?: RegisterOptions
}
@@ -0,0 +1,72 @@
.helpText {
align-items: flex-start;
display: flex;
gap: var(--Spacing-x-half);
}
.error {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;
}
.container {
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Medium);
display: grid;
min-width: 0; /* allow shrinkage */
grid-template-rows: auto 1fr;
height: 138px;
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
transition: border-color 200ms ease;
}
.container:has(.textarea:active, .textarea:focus) {
border-color: var(--Scandic-Blue-90);
}
.container:has(.textarea:disabled) {
background-color: var(--Main-Grey-10);
border: none;
color: var(--Main-Grey-40);
}
.container:has(.textarea[data-invalid="true"], .textarea[aria-invalid="true"]) {
border-color: var(--Scandic-Red-60);
}
.textarea {
background: none;
border: none;
color: var(--Main-Grey-100);
height: 100%;
width: 100%;
margin: 0;
order: 2;
overflow: visible;
padding: 0;
resize: none;
}
.textarea:not(:active, :focus):placeholder-shown {
height: 88px;
transition: height 150ms ease;
}
.textarea:focus,
.textarea:focus:placeholder-shown,
.textarea:active,
.textarea:active:placeholder-shown {
height: 94px;
transition: height 150ms ease;
outline: none;
}
.textarea:disabled {
color: var(--Main-Grey-40);
}
@@ -0,0 +1,14 @@
import { Slot } from "@radix-ui/react-slot"
import { itemVariants } from "./variants"
import type { ItemProps } from "./item"
export default function Item({ columns, order, rows, ...props }: ItemProps) {
const classNames = itemVariants({
columns,
order,
rows,
})
return <Slot {...props} className={classNames} />
}
@@ -0,0 +1,372 @@
.item {
margin: 0;
padding: 0;
}
.c2r1 {
grid-column: span 4;
grid-row: span 1;
}
.c2r2 {
grid-column: span 4;
grid-row: span 1;
}
.c2r3 {
grid-column: span 4;
grid-row: span 1;
}
.c2r6 {
grid-column: span 4;
grid-row: span 1;
}
.c3r1 {
grid-column: span 4;
grid-row: span 1;
}
.c3r2 {
grid-column: span 4;
grid-row: span 1;
}
.c3r3 {
grid-column: span 4;
grid-row: span 1;
}
.c3r6 {
grid-column: span 4;
grid-row: span 1;
}
.c4r1 {
grid-column: span 4;
grid-row: span 1;
}
.c4r2 {
grid-column: span 4;
grid-row: span 1;
}
.c4r3 {
grid-column: span 4;
grid-row: span 1;
}
.c4r6 {
grid-column: span 4;
grid-row: span 1;
}
.c6r1 {
grid-column: span 4;
grid-row: span 1;
}
.c6r2 {
grid-column: span 4;
grid-row: span 1;
}
.c6r3 {
grid-column: span 4;
grid-row: span 1;
}
.c6r6 {
grid-column: span 4;
grid-row: span 1;
}
.c8r1 {
grid-column: span 4;
grid-row: span 1;
}
.c8r2 {
grid-column: span 4;
grid-row: span 1;
}
.c8r3 {
grid-column: span 4;
grid-row: span 1;
}
.c8r6 {
grid-column: span 4;
grid-row: span 1;
}
.order1 {
order: 1;
}
.order2 {
order: 4;
}
.order3 {
order: 7;
}
.order4 {
order: 2;
}
.order5 {
order: 5;
}
.order6 {
order: 8;
}
.order7 {
order: 3;
}
.order8 {
order: 6;
}
.order9 {
order: 9;
}
@media screen and (min-width: 768px) {
.c2r1 {
grid-column: span 4;
grid-row: span 1;
}
.c2r2 {
grid-column: span 4;
grid-row: span 1;
}
.c2r3 {
grid-column: span 4;
grid-row: span 1;
}
.c2r6 {
grid-column: span 4;
grid-row: span 1;
}
.c3r1 {
grid-column: span 4;
grid-row: span 1;
}
.c3r2 {
grid-column: span 4;
grid-row: span 1;
}
.c3r3 {
grid-column: span 4;
grid-row: span 1;
}
.c3r6 {
grid-column: span 4;
grid-row: span 1;
}
.c4r1 {
grid-column: span 4;
grid-row: span 1;
}
.c4r2 {
grid-column: span 4;
grid-row: span 1;
}
.c4r3 {
grid-column: span 4;
grid-row: span 1;
}
.c4r6 {
grid-column: span 4;
grid-row: span 1;
}
.c6r1 {
grid-column: span 8;
grid-row: span 1;
}
.c6r2 {
grid-column: span 8;
grid-row: span 1;
}
.c6r3 {
grid-column: span 8;
grid-row: span 1;
}
.c6r6 {
grid-column: span 8;
grid-row: span 1;
}
.c8r1 {
grid-column: span 8;
grid-row: span 1;
}
.c8r2 {
grid-column: span 8;
grid-row: span 1;
}
.c8r3 {
grid-column: span 8;
grid-row: span 1;
}
.c8r6 {
grid-column: span 8;
grid-row: span 1;
}
}
@media screen and (min-width: 1367px) {
.c2r1 {
grid-column: span 2;
grid-row: span 1;
}
.c2r2 {
grid-column: span 2;
grid-row: span 2;
}
.c2r3 {
grid-column: span 2;
grid-row: span 3;
}
.c2r6 {
grid-column: span 2;
grid-row: span 6;
}
.c3r1 {
grid-column: span 3;
grid-row: span 1;
}
.c3r2 {
grid-column: span 3;
grid-row: span 2;
}
.c3r3 {
grid-column: span 3;
grid-row: span 3;
}
.c3r6 {
grid-column: span 3;
grid-row: span 6;
}
.c4r1 {
grid-column: span 4;
grid-row: span 1;
}
.c4r2 {
grid-column: span 4;
grid-row: span 2;
}
.c4r3 {
grid-column: span 4;
grid-row: span 3;
}
.c4r6 {
grid-column: span 4;
grid-row: span 6;
}
.c6r1 {
grid-column: span 6;
grid-row: span 1;
}
.c6r2 {
grid-column: span 6;
grid-row: span 2;
}
.c6r3 {
grid-column: span 6;
grid-row: span 3;
}
.c6r6 {
grid-column: span 6;
grid-row: span 6;
}
.c8r1 {
grid-column: span 8;
grid-row: span 1;
}
.c8r2 {
grid-column: span 8;
grid-row: span 2;
}
.c8r3 {
grid-column: span 8;
grid-row: span 3;
}
.c8r6 {
grid-column: span 8;
grid-row: span 6;
}
.order2 {
order: 2;
}
.order3 {
order: 3;
}
.order4 {
order: 4;
}
.order5 {
order: 5;
}
.order6 {
order: 6;
}
.order7 {
order: 7;
}
.order8 {
order: 8;
}
}
@@ -0,0 +1,7 @@
import type { VariantProps } from "class-variance-authority"
import type { itemVariants } from "./variants"
export interface ItemProps
extends React.HTMLAttributes<HTMLElement>,
VariantProps<typeof itemVariants> {}
@@ -0,0 +1,138 @@
import { cva } from "class-variance-authority"
import styles from "./item.module.css"
export const itemVariants = cva(styles.item, {
variants: {
order: {
1: styles.order1,
2: styles.order2,
3: styles.order3,
4: styles.order4,
5: styles.order5,
6: styles.order6,
7: styles.order7,
8: styles.order8,
9: styles.order9,
},
// These are only placeholder classes
// so that we can use compoundVariants
columns: {
2: styles.colTwo,
3: styles.colThree,
4: styles.colFour,
6: styles.colSix,
8: styles.colEight,
},
// These are only placeholder classes
// so that we can use compoundVariants
rows: {
1: styles.rowOne,
2: styles.rowTwo,
3: styles.rowThree,
6: styles.rowSix,
},
},
compoundVariants: [
{
class: styles.c2r1,
columns: 2,
rows: 1,
},
{
class: styles.c2r2,
columns: 2,
rows: 2,
},
{
class: styles.c2r3,
columns: 2,
rows: 3,
},
{
class: styles.c2r6,
columns: 2,
rows: 6,
},
{
class: styles.c3r1,
columns: 3,
rows: 1,
},
{
class: styles.c3r2,
columns: 3,
rows: 2,
},
{
class: styles.c3r3,
columns: 3,
rows: 3,
},
{
class: styles.c3r6,
columns: 3,
rows: 6,
},
{
class: styles.c4r1,
columns: 4,
rows: 1,
},
{
class: styles.c4r2,
columns: 4,
rows: 2,
},
{
class: styles.c4r3,
columns: 4,
rows: 3,
},
{
class: styles.c4r6,
columns: 4,
rows: 6,
},
{
class: styles.c6r1,
columns: 6,
rows: 1,
},
{
class: styles.c6r2,
columns: 6,
rows: 2,
},
{
class: styles.c6r3,
columns: 6,
rows: 3,
},
{
class: styles.c6r6,
columns: 6,
rows: 6,
},
{
class: styles.c8r1,
columns: 8,
rows: 1,
},
{
class: styles.c8r2,
columns: 8,
rows: 2,
},
{
class: styles.c8r3,
columns: 8,
rows: 3,
},
{
class: styles.c8r6,
columns: 8,
rows: 6,
},
],
})
@@ -0,0 +1,18 @@
.grid {
display: grid;
gap: var(--Spacing-x2);
grid-auto-flow: dense;
grid-template-columns: repeat(4, 1fr);
}
@media screen and (min-width: 768px) {
.grid {
grid-template-columns: repeat(8, 1fr);
}
}
@media screen and (min-width: 1367px) {
.grid {
grid-template-columns: repeat(12, 1fr);
}
}
@@ -0,0 +1,30 @@
import type { Edges } from "@/types/requests/utils/edges"
import type { TypenameInterface } from "@/types/requests/utils/typename"
export type Order = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
export type ColSpan = 2 | 3 | 4 | 6 | 8
export type RowSpan = 1 | 2 | 3 | 6
// TODO: Extend query and fix type accordingly
export interface Row extends TypenameInterface<"Card"> {
title: string
}
type Column = {
span: ColSpan
rows: {
rowConnection: Edges<Row>
}[]
}
export interface Grid {
columns: Column[]
}
export interface GridProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
children: (row: Row) => React.ReactNode
items: Grid
tag?: "aside" | "article" | "div" | "main" | "section"
}
@@ -0,0 +1,66 @@
import Item from "./Item"
import styles from "./dynamic.module.css"
import type { GridProps, Order, RowSpan } from "./dynamic"
export default function Grid({ children, items, tag = "section" }: GridProps) {
const Elm = tag
const allRows: number[] = items.columns.map((col) => col.rows.length)
const onlySingleRows = allRows.every((row) => row === 1)
const oneColumnWithTwoRows = allRows.includes(2)
const oneColumnWithThreeRows = allRows.includes(3)
/**
* When we have one column with 2 rows and another with 3,
* we have to reach the closest denominator which is 6 to be
* able to have the column with 2 rows span half the grid each.
*/
const doubleTheRows = oneColumnWithTwoRows && oneColumnWithThreeRows
return (
<Elm className={styles.grid}>
{items.columns.map((column, columnIndex) => {
const rows = column.rows.map((row) => row.rowConnection.edges).flat()
let rowSpan: RowSpan = 1
if (!onlySingleRows) {
if (doubleTheRows) {
switch (rows.length) {
case 1:
rowSpan = 6
break
case 2:
rowSpan = 3
break
case 3:
rowSpan = 2
break
}
} else if (oneColumnWithTwoRows) {
if (rows.length === 1) {
rowSpan = 2
}
} else if (oneColumnWithThreeRows) {
if (rows.length === 1) {
rowSpan = 3
}
}
}
return rows.map(({ node: row }, rowIndex) => {
const order = (columnIndex +
1 +
rowIndex * items.columns.length) as Order
return (
<Item
key={order}
columns={column.span}
order={order}
rows={rowSpan}
>
{children(row)}
</Item>
)
})
})}
</Elm>
)
}
@@ -0,0 +1,12 @@
import { stackableGridVariants } from "./variants"
import type { StackableGridProps } from "./stackable"
export default function Stackable({
children,
className,
columns,
}: React.PropsWithChildren<StackableGridProps>) {
const classNames = stackableGridVariants({ className, columns })
return <section className={classNames}>{children}</section>
}
@@ -0,0 +1,23 @@
.container {
display: grid;
gap: var(--Spacing-x2);
}
/* Hide Scrollbar Chrome, Safari and Opera */
.container::-webkit-scrollbar {
display: none;
}
@media (min-width: 768px) {
.threeColumns {
grid-template-columns: repeat(3, 1fr);
}
.twoColumns {
grid-template-columns: repeat(2, 1fr);
}
.oneColumn {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,7 @@
import type { VariantProps } from "class-variance-authority"
import type { stackableGridVariants } from "./variants"
export interface StackableGridProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof stackableGridVariants> {}

Some files were not shown because too many files have changed in this diff Show More