Merge branch 'develop' into feature/tracking
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.container[data-selected] .checkbox {
|
||||
border: none;
|
||||
background: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-grow: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border: 2px solid var(--UI-Input-Controls-Border-Normal);
|
||||
border-radius: 4px;
|
||||
transition: all 200ms;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 200ms;
|
||||
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;
|
||||
}
|
||||
7
components/TempDesignSystem/Form/Checkbox/checkbox.ts
Normal file
7
components/TempDesignSystem/Form/Checkbox/checkbox.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
51
components/TempDesignSystem/Form/Checkbox/index.tsx
Normal file
51
components/TempDesignSystem/Form/Checkbox/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"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 { CheckboxProps } from "./checkbox"
|
||||
|
||||
import styles from "./checkbox.module.css"
|
||||
|
||||
export default function Checkbox({
|
||||
name,
|
||||
children,
|
||||
registerOptions,
|
||||
}: React.PropsWithChildren<CheckboxProps>) {
|
||||
const { control } = useFormContext()
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name,
|
||||
rules: registerOptions,
|
||||
})
|
||||
|
||||
return (
|
||||
<AriaCheckbox
|
||||
className={styles.container}
|
||||
isSelected={field.value}
|
||||
onChange={field.onChange}
|
||||
data-testid={name}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
<div className={styles.checkboxContainer}>
|
||||
<div className={styles.checkbox}>
|
||||
{isSelected && <CheckIcon color="white" />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{fieldState.error ? (
|
||||
<Caption className={styles.error} fontOnly>
|
||||
<InfoCircleIcon color="red" />
|
||||
{fieldState.error.message}
|
||||
</Caption>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</AriaCheckbox>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { CheckIcon, CloseIcon, HeartIcon } from "@/components/Icons"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
@@ -23,6 +25,8 @@ export default function Card({
|
||||
type,
|
||||
value,
|
||||
}: CardProps) {
|
||||
const { register } = useFormContext()
|
||||
|
||||
return (
|
||||
<label className={styles.label} data-declined={declined}>
|
||||
<Caption className={styles.title} textTransform="bold" uppercase>
|
||||
@@ -68,9 +72,9 @@ export default function Card({
|
||||
aria-hidden
|
||||
id={id || name}
|
||||
hidden
|
||||
name={name}
|
||||
type={type}
|
||||
value={value}
|
||||
{...register(name)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function CountrySelect({
|
||||
onSelectionChange={handleChange}
|
||||
ref={field.ref}
|
||||
selectedKey={field.value}
|
||||
data-testid={name}
|
||||
>
|
||||
<div className={styles.comboBoxContainer}>
|
||||
<Label
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
import { parseDate } from "@internationalized/date"
|
||||
import { useState } from "react"
|
||||
import { DateInput, DatePicker, Group } from "react-aria-components"
|
||||
import { useController, useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
@@ -18,7 +19,7 @@ import type { Key } from "react-aria-components"
|
||||
import type { DateProps } from "./date"
|
||||
|
||||
export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
const { formatMessage } = useIntl()
|
||||
const intl = useIntl()
|
||||
const d = useWatch({ name })
|
||||
const { control, setValue } = useFormContext()
|
||||
const { field } = useController({
|
||||
@@ -26,6 +27,19 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
name,
|
||||
rules: registerOptions,
|
||||
})
|
||||
|
||||
const [dateSegments, setDateSegment] = useState<{
|
||||
year: number | null
|
||||
month: number | null
|
||||
date: number | null
|
||||
daysInMonth: number
|
||||
}>({
|
||||
year: null,
|
||||
month: null,
|
||||
date: null,
|
||||
daysInMonth: 31,
|
||||
})
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const months = rangeArray(1, 12).map((month) => ({
|
||||
value: month,
|
||||
@@ -41,17 +55,38 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
* must subtract by 1 to get the selected month
|
||||
*/
|
||||
return (select: Key) => {
|
||||
if (selector === DateName.month) {
|
||||
select = Number(select) - 1
|
||||
const value =
|
||||
selector === DateName.month ? Number(select) - 1 : Number(select)
|
||||
const newSegments = { ...dateSegments, [selector]: value }
|
||||
|
||||
/**
|
||||
* Update daysInMonth when year or month changes
|
||||
* to ensure the user can't select a date that doesn't exist.
|
||||
*/
|
||||
if (selector === DateName.year || selector === DateName.month) {
|
||||
const year = selector === DateName.year ? value : newSegments.year
|
||||
const month = selector === DateName.month ? value : newSegments.month
|
||||
if (year !== null && month !== null) {
|
||||
newSegments.daysInMonth = dt().year(year).month(month).daysInMonth()
|
||||
}
|
||||
}
|
||||
const newDate = dt(d).set(selector, Number(select))
|
||||
setValue(name, newDate.format("YYYY-MM-DD"))
|
||||
|
||||
if (Object.values(newSegments).every((val) => val !== null)) {
|
||||
const newDate = dt()
|
||||
.utc()
|
||||
.set("year", newSegments.year!)
|
||||
.set("month", newSegments.month!)
|
||||
.set("date", Math.min(newSegments.date!, newSegments.daysInMonth))
|
||||
|
||||
setValue(name, newDate.format("YYYY-MM-DD"))
|
||||
}
|
||||
setDateSegment(newSegments)
|
||||
}
|
||||
}
|
||||
|
||||
const dayLabel = formatMessage({ id: "Day" })
|
||||
const monthLabel = formatMessage({ id: "Month" })
|
||||
const yearLabel = formatMessage({ id: "Year" })
|
||||
const dayLabel = intl.formatMessage({ id: "Day" })
|
||||
const monthLabel = intl.formatMessage({ id: "Month" })
|
||||
const yearLabel = intl.formatMessage({ id: "Year" })
|
||||
|
||||
let dateValue = null
|
||||
try {
|
||||
@@ -60,35 +95,30 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
* date, but we can't check isNan since
|
||||
* we recieve the date as "1999-01-01"
|
||||
*/
|
||||
dateValue = parseDate(d)
|
||||
dateValue = dt(d).isValid() ? parseDate(d) : null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
aria-label={formatMessage({ id: "Select date of birth" })}
|
||||
granularity="day"
|
||||
aria-label={intl.formatMessage({ id: "Select date of birth" })}
|
||||
isRequired={!!registerOptions.required}
|
||||
name={name}
|
||||
ref={field.ref}
|
||||
value={dateValue}
|
||||
data-testid={name}
|
||||
>
|
||||
<Group>
|
||||
<DateInput className={styles.container}>
|
||||
{(segment) => {
|
||||
switch (segment.type) {
|
||||
case "day":
|
||||
let days = []
|
||||
if (segment.maxValue && segment.minValue) {
|
||||
days = rangeArray(segment.minValue, segment.maxValue).map(
|
||||
(day) => ({ value: day, label: `${day}` })
|
||||
)
|
||||
} else {
|
||||
days = Array.from(Array(segment.maxValue).keys()).map(
|
||||
(i) => ({ value: i + 1, label: `${i + 1}` })
|
||||
)
|
||||
}
|
||||
const maxDays = dateSegments.daysInMonth
|
||||
const days = rangeArray(1, maxDays).map((day) => ({
|
||||
value: day,
|
||||
label: `${day}`,
|
||||
}))
|
||||
return (
|
||||
<div className={styles.day}>
|
||||
<Select
|
||||
@@ -100,7 +130,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
placeholder="DD"
|
||||
required
|
||||
tabIndex={3}
|
||||
value={segment.value}
|
||||
defaultValue={
|
||||
segment.isPlaceholder ? undefined : segment.value
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -116,7 +148,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
placeholder="MM"
|
||||
required
|
||||
tabIndex={2}
|
||||
value={segment.value}
|
||||
defaultValue={
|
||||
segment.isPlaceholder ? undefined : segment.value
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -132,7 +166,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
placeholder="YYYY"
|
||||
required
|
||||
tabIndex={1}
|
||||
value={segment.value}
|
||||
defaultValue={
|
||||
segment.isPlaceholder ? undefined : segment.value
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,63 @@
|
||||
"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 } from "@/components/Icons"
|
||||
import Error from "@/components/TempDesignSystem/Form/ErrorMessage/Error"
|
||||
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/passwordValidator"
|
||||
|
||||
import { type IconProps, Key, type NewPasswordProps } from "./newPassword"
|
||||
import Button from "../../Button"
|
||||
import { IconProps, type NewPasswordProps } from "./newPassword"
|
||||
|
||||
import styles from "./newPassword.module.css"
|
||||
|
||||
import { PasswordValidatorKey } from "@/types/components/form/newPassword"
|
||||
|
||||
export default function NewPassword({
|
||||
name = "newPassword",
|
||||
"aria-label": ariaLabel,
|
||||
disabled = false,
|
||||
placeholder = "",
|
||||
registerOptions = {},
|
||||
label,
|
||||
}: NewPasswordProps) {
|
||||
const { control } = useFormContext()
|
||||
const { formatMessage } = useIntl()
|
||||
const intl = useIntl()
|
||||
const [isPasswordVisible, setPasswordVisible] = useState(false)
|
||||
|
||||
function getErrorMessage(key: PasswordValidatorKey) {
|
||||
switch (key) {
|
||||
case "length":
|
||||
return `10 ${intl.formatMessage({ id: "to" })} 40 ${intl.formatMessage({ id: "characters" })}`
|
||||
case "hasUppercase":
|
||||
return `1 ${intl.formatMessage({ id: "uppercase letter" })}`
|
||||
case "hasLowercase":
|
||||
return `1 ${intl.formatMessage({ id: "lowercase letter" })}`
|
||||
case "hasNumber":
|
||||
return `1 ${intl.formatMessage({ id: "number" })}`
|
||||
case "hasSpecialChar":
|
||||
return `1 ${intl.formatMessage({ id: "special character" })}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Controller
|
||||
disabled={disabled}
|
||||
control={control}
|
||||
name="newPassword"
|
||||
name={name}
|
||||
rules={registerOptions}
|
||||
render={({ field, fieldState }) => {
|
||||
const messages = (fieldState.error?.message?.split(",") ?? []) as Key[]
|
||||
render={({ field, fieldState, formState }) => {
|
||||
const errors = Object.values(formState.errors[name]?.types ?? []).flat()
|
||||
return (
|
||||
<TextField
|
||||
aria-label={ariaLabel}
|
||||
@@ -39,51 +69,47 @@ export default function NewPassword({
|
||||
onChange={field.onChange}
|
||||
validationBehavior="aria"
|
||||
value={field.value}
|
||||
type="password"
|
||||
type={isPasswordVisible ? "text" : "password"}
|
||||
>
|
||||
<AriaInputWithLabel
|
||||
{...field}
|
||||
aria-labelledby={field.name}
|
||||
id={field.name}
|
||||
label={formatMessage({ id: "New password" })}
|
||||
placeholder={placeholder}
|
||||
type="password"
|
||||
/>
|
||||
<div className={styles.inputWrapper}>
|
||||
<AriaInputWithLabel
|
||||
{...field}
|
||||
aria-labelledby={field.name}
|
||||
id={field.name}
|
||||
label={intl.formatMessage({ id: "New password" })}
|
||||
placeholder={placeholder}
|
||||
type={isPasswordVisible ? "text" : "password"}
|
||||
/>
|
||||
<Button
|
||||
className={styles.eyeIcon}
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="small"
|
||||
intent="tertiary"
|
||||
onClick={() => setPasswordVisible(!isPasswordVisible)}
|
||||
>
|
||||
{isPasswordVisible ? <EyeHideIcon /> : <EyeShowIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
{field.value ? (
|
||||
<div className={styles.errors}>
|
||||
<Caption asChild color="black">
|
||||
<Text className={styles.helpText} slot="description">
|
||||
<Icon matcher={Key.CHAR_LENGTH} messages={messages} />
|
||||
10 {formatMessage({ id: "to" })} 40{" "}
|
||||
{formatMessage({ id: "characters" })}
|
||||
</Text>
|
||||
</Caption>
|
||||
<Caption asChild color="black">
|
||||
<Text className={styles.helpText} slot="description">
|
||||
<Icon matcher={Key.UPPERCASE} messages={messages} />1{" "}
|
||||
{formatMessage({ id: "uppercase letter" })}
|
||||
</Text>
|
||||
</Caption>
|
||||
<Caption asChild color="black">
|
||||
<Text className={styles.helpText} slot="description">
|
||||
<Icon matcher={Key.NUM} messages={messages} />1{" "}
|
||||
{formatMessage({ id: "number" })}
|
||||
</Text>
|
||||
</Caption>
|
||||
<Caption asChild color="black">
|
||||
<Text className={styles.helpText} slot="description">
|
||||
<Icon matcher={Key.SPECIAL_CHAR} messages={messages} />1{" "}
|
||||
{formatMessage({ id: "special character" })}
|
||||
</Text>
|
||||
</Caption>
|
||||
{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>
|
||||
) : null}
|
||||
{!field.value && fieldState.error ? (
|
||||
<Error>
|
||||
<Text className={styles.helpText} slot="description">
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
</Error>
|
||||
<Caption className={styles.error} fontOnly>
|
||||
<InfoCircleIcon color="red" />
|
||||
{fieldState.error.message}
|
||||
</Caption>
|
||||
) : null}
|
||||
</TextField>
|
||||
)
|
||||
@@ -92,8 +118,8 @@ export default function NewPassword({
|
||||
)
|
||||
}
|
||||
|
||||
function Icon({ matcher, messages }: IconProps) {
|
||||
return messages.includes(matcher) ? (
|
||||
function Icon({ errorMessage, errors }: IconProps) {
|
||||
return errors.includes(errorMessage) ? (
|
||||
<CloseIcon color="red" height={20} width={20} />
|
||||
) : (
|
||||
<CheckIcon color="green" height={20} width={20} />
|
||||
|
||||
@@ -1,12 +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;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { RegisterOptions } from "react-hook-form"
|
||||
|
||||
export enum Key {
|
||||
CHAR_LENGTH = "CHAR_LENGTH",
|
||||
NUM = "NUM",
|
||||
SPECIAL_CHAR = "SPECIAL_CHAR",
|
||||
UPPERCASE = "UPPERCASE",
|
||||
}
|
||||
|
||||
export interface NewPasswordProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
@@ -14,6 +7,6 @@ export interface NewPasswordProps
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
matcher: Key
|
||||
messages: Key[]
|
||||
errorMessage: string
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function Phone({
|
||||
className={styles.select}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
data-testid="country-selector"
|
||||
>
|
||||
<Label required={!!registerOptions.required} size="small">
|
||||
{formatMessage({ id: "Country code" })}
|
||||
|
||||
@@ -25,11 +25,13 @@ export default function Select({
|
||||
disabled={field.disabled}
|
||||
items={items}
|
||||
label={label}
|
||||
aria-label={label}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
onSelect={field.onChange}
|
||||
placeholder={placeholder}
|
||||
value={field.value}
|
||||
data-testid={name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user