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

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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" />
}

View File

@@ -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);
}

View File

@@ -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"> {}

View File

@@ -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>
}

View File

@@ -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,
}))

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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)
)
}
)
})

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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>}
/>
)
}

View File

@@ -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" />
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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);
}

View File

@@ -0,0 +1,4 @@
export interface AriaInputWithLabelProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label: string
}

View File

@@ -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>
)}
/>
)
}

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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>
}

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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,
},
})

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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[]
}

View File

@@ -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"
}

View File

@@ -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;
}

View File

@@ -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}
/>
)
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
.chevron {
display: flex;
}
div[data-rac][data-open="true"] .chevron {
transform: rotate(180deg);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)}
/>
)
}

View File

@@ -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
}

View File

@@ -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);
}