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

@@ -25,6 +25,8 @@
"./utils/chunk": "./utils/chunk.ts",
"./utils/isDefined": "./utils/isDefined.ts",
"./utils/maskValue": "./utils/maskValue.ts",
"./utils/dateFormatting": "./utils/dateFormatting.ts",
"./utils/rangeArray": "./utils/rangeArray.ts",
"./utils/zod/*": "./utils/zod/*.ts",
"./constants/language": "./constants/language.ts",
"./constants/membershipLevels": "./constants/membershipLevels.ts",

View File

@@ -0,0 +1,21 @@
import { dt } from "../dt/dt"
import type { Lang } from "../constants/language"
/**
* Get the localized month name for a given month index and language
* @param monthIndex - The month index (1-12)
* @param lang - the language to use, Lang enum
* @returns The localized month name
*/
export function getLocalizedMonthName(monthIndex: number, lang: Lang) {
const monthName = new Date(2024, monthIndex - 1).toLocaleString(lang, {
month: "long",
})
return monthName.charAt(0).toUpperCase() + monthName.slice(1)
}
export function getNumberOfNights(startDate: string, endDate: string) {
return dt(endDate).diff(startDate, "day")
}

View File

@@ -0,0 +1,6 @@
export function rangeArray(start: number, stop: number, step: number = 1) {
return Array.from(
{ length: (stop - start) / step + 1 },
(_, index) => start + index * step
)
}

View File

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

View File

@@ -0,0 +1,21 @@
import { Lang } from '@scandic-hotels/common/constants/language'
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> {
labels: {
day: string
month: string
year: string
errorMessage?: string
}
lang: Lang
name: string
registerOptions?: RegisterOptions
}

View File

@@ -0,0 +1,190 @@
'use client'
import { parseDate } from '@internationalized/date'
import { useEffect } from 'react'
import { useController, useFormContext, useWatch } from 'react-hook-form'
import { useMediaQuery } from 'usehooks-ts'
import { dt } from '@scandic-hotels/common/dt'
import { logger } from '@scandic-hotels/common/logger'
import { getLocalizedMonthName } from '@scandic-hotels/common/utils/dateFormatting'
import { rangeArray } from '@scandic-hotels/common/utils/rangeArray'
import { ErrorMessage } from '../../Form/ErrorMessage'
import { Select } from '../../Select'
import { DateName, type DateProps } from './date'
import styles from './date.module.css'
export default function DateSelect({
labels,
lang,
name,
registerOptions = {},
}: DateProps) {
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={labels.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={labels.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={labels.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={labels.errorMessage}
/>
</>
)
}
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

@@ -16,6 +16,7 @@
"./Footnote": "./dist/components/Footnote/index.js",
"./Form/Checkbox": "./dist/components/Form/Checkbox/index.js",
"./Form/Country": "./dist/components/Form/Country/index.js",
"./Form/Date": "./dist/components/Form/Date/index.js",
"./Form/ErrorMessage": "./dist/components/Form/ErrorMessage/index.js",
"./Form/Phone": "./dist/components/Form/Phone/index.js",
"./Form/RadioCard": "./dist/components/Form/RadioCard/index.js",
@@ -149,12 +150,14 @@
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"@internationalized/date": "^3.8.0",
"@radix-ui/react-slot": "^1.2.2",
"react": "^19.1.0",
"react-aria-components": "^1.8.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.2",
"react-international-phone": "^4.5.0"
"react-international-phone": "^4.5.0",
"usehooks-ts": "3.1.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",