Merged in chore/SW-3145-move-date-to-design-system (pull request #2556)

feat: SW-3145 Moved date component to design system

* chore: SW-3145 Moved date component to design system


Approved-by: Anton Gunnarsson
Approved-by: Matilda Landström
This commit is contained in:
Hrishikesh Vaipurkar
2025-07-28 09:05:25 +00:00
parent 36e8ac11d1
commit 5ff4234552
15 changed files with 120 additions and 64 deletions

View File

@@ -1,15 +0,0 @@
.container {
display: flex;
gap: var(--Spacing-x2);
user-select: none;
}
@media screen and (width < 400px) {
.container {
flex-direction: column;
}
}
.segment {
flex: 1;
}

View File

@@ -1,136 +0,0 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { User } from "@react-aria/test-utils"
import { userEvent } from "@testing-library/user-event"
import { FormProvider, useForm } from "react-hook-form"
import { afterEach, describe, expect, test } from "vitest"
import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt"
import { cleanup, render, screen } from "@/tests/utils"
import { getLocalizedMonthName } from "@/utils/dateFormatting"
import Date from "./index"
const testUtilUser = new User({ interactionType: "touch" })
interface FormWrapperProps {
defaultValues: Record<string, unknown>
children: React.ReactNode
onSubmit: (event: unknown) => void
}
function FormWrapper({ defaultValues, children, onSubmit }: FormWrapperProps) {
const methods = useForm({
defaultValues,
})
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{children}
<button type="submit">Submit</button>
</form>
</FormProvider>
)
}
function selectOption(name: string, value: string) {
const selectTester = testUtilUser.createTester("Select", {
root: screen.getByTestId(name),
interactionType: "touch",
})
selectTester.selectOption({ option: value })
}
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", () => {
afterEach(cleanup)
test.each(testCases)(
"$description",
async ({ defaultValue, dateOfBirth, expectedOutput }) => {
const user = userEvent.setup()
render(
<FormWrapper
defaultValues={{ dateOfBirth: defaultValue }}
onSubmit={(data) => {
expect(data).toEqual(expectedOutput)
}}
>
<Date name="dateOfBirth" />
</FormWrapper>
)
const date = dt(dateOfBirth).toDate()
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
selectOption("year", year.toString())
selectOption("month", getLocalizedMonthName(month, Lang.en))
selectOption("day", day.toString())
const submitButton = screen.getByRole("button", { name: /submit/i })
await user.click(submitButton)
}
)
})

View File

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

@@ -1,200 +0,0 @@
"use client"
import { parseDate } from "@internationalized/date"
import { useEffect } from "react"
import { useController, useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { dt } from "@scandic-hotels/common/dt"
import { logger } from "@scandic-hotels/common/logger"
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
import { Select } from "@scandic-hotels/design-system/Select"
import useLang from "@/hooks/useLang"
import { getLocalizedMonthName } from "@/utils/dateFormatting"
import { getErrorMessage } from "@/utils/getErrorMessage"
import { rangeArray } from "@/utils/rangeArray"
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 isDesktop = useMediaQuery("(min-width: 768px)", {
initializeWithValue: false,
})
const { control, setValue, formState, watch } = useFormContext()
const { field, fieldState } = useController({
control,
name,
rules: registerOptions,
})
const currentDateValue: string = 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}`,
}))
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) {
logger.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 (
<>
<div className={styles.container}>
<div className={styles.segment}>
<Select
items={days}
label={intl.formatMessage({
defaultMessage: "Day",
})}
name={DateName.day}
onSelectionChange={(key) => setValue(DateName.day, Number(key))}
isRequired
enableFiltering={isDesktop}
isInvalid={fieldState.invalid}
onBlur={field.onBlur}
defaultSelectedKey={dateValue?.day}
inputMode="numeric"
data-testid={DateName.day}
/>
</div>
<div className={styles.segment}>
<Select
items={months}
label={intl.formatMessage({
defaultMessage: "Month",
})}
name={DateName.month}
onSelectionChange={(key) => setValue(DateName.month, Number(key))}
isRequired
enableFiltering={isDesktop}
isInvalid={fieldState.invalid}
onBlur={field.onBlur}
defaultSelectedKey={dateValue?.month}
data-testid={DateName.month}
/>
</div>
<div className={styles.segment}>
<Select
items={years}
label={intl.formatMessage({
defaultMessage: "Year",
})}
name={DateName.year}
onSelectionChange={(key) => setValue(DateName.year, Number(key))}
isRequired
enableFiltering={isDesktop}
isInvalid={fieldState.invalid}
onBlur={field.onBlur}
defaultSelectedKey={dateValue?.year}
inputMode="numeric"
data-testid={DateName.year}
/>
</div>
</div>
<ErrorMessage
errors={formState.errors}
name={field.name}
messageLabel={getErrorMessage(
intl,
formState.errors[field.name]?.message?.toString()
)}
/>
</>
)
}
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()
}