Merged develop into feat/setup-hotel-api-call
This commit is contained in:
@@ -7,6 +7,7 @@ import { languageSelect } from "@/constants/languages"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import NewPassword from "@/components/TempDesignSystem/Form/NewPassword"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Select from "@/components/TempDesignSystem/Form/Select"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
@@ -21,8 +22,8 @@ export default function FormContent() {
|
||||
const country = formatMessage({ id: "Country" })
|
||||
const email = `${formatMessage({ id: "Email" })} ${formatMessage({ id: "Address" }).toLowerCase()}`
|
||||
const street = formatMessage({ id: "Address" })
|
||||
const phoneNumber = formatMessage({ id: "Phone number" })
|
||||
const password = formatMessage({ id: "Current password" })
|
||||
const newPassword = formatMessage({ id: "New password" })
|
||||
const retypeNewPassword = formatMessage({ id: "Retype new password" })
|
||||
const zipCode = formatMessage({ id: "Zip code" })
|
||||
|
||||
@@ -46,7 +47,7 @@ export default function FormContent() {
|
||||
label={zipCode}
|
||||
name="address.zipCode"
|
||||
placeholder={zipCode}
|
||||
required
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<CountrySelect
|
||||
label={country}
|
||||
@@ -55,12 +56,17 @@ export default function FormContent() {
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<Input label={email} name="email" placeholder={email} required />
|
||||
<Phone
|
||||
label={formatMessage({ id: "Phone number" })}
|
||||
name="phoneNumber"
|
||||
placeholder={formatMessage({ id: "Phone number" })}
|
||||
<Input
|
||||
label={email}
|
||||
name="email"
|
||||
placeholder={email}
|
||||
registerOptions={{ required: true }}
|
||||
type="email"
|
||||
/>
|
||||
<Phone
|
||||
label={phoneNumber}
|
||||
name="phoneNumber"
|
||||
placeholder={phoneNumber}
|
||||
/>
|
||||
<Select
|
||||
items={languageSelect}
|
||||
@@ -79,12 +85,7 @@ export default function FormContent() {
|
||||
placeholder={password}
|
||||
type="password"
|
||||
/>
|
||||
<Input
|
||||
label={newPassword}
|
||||
name="newPassword"
|
||||
placeholder={newPassword}
|
||||
type="password"
|
||||
/>
|
||||
<NewPassword />
|
||||
<Input
|
||||
label={retypeNewPassword}
|
||||
name="retypeNewPassword"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useFormState as useReactFormState } from "react-dom"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
|
||||
import { editProfile } from "@/actions/editProfile"
|
||||
import Header from "@/components/Profile/Header"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import FormContent from "./FormContent"
|
||||
@@ -19,11 +22,14 @@ import type {
|
||||
EditFormProps,
|
||||
State,
|
||||
} from "@/types/components/myPages/myProfile/edit"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
const formId = "edit-profile"
|
||||
|
||||
export default function Form({ user }: EditFormProps) {
|
||||
const { formatMessage } = useIntl()
|
||||
const params = useParams()
|
||||
const lang = params.lang as Lang
|
||||
/**
|
||||
* like react, react-hook-form also exports a useFormState hook,
|
||||
* we want to clearly keep them separate by naming.
|
||||
@@ -33,12 +39,14 @@ export default function Form({ user }: EditFormProps) {
|
||||
null
|
||||
)
|
||||
|
||||
const form = useForm<EditProfileSchema>({
|
||||
const methods = useForm<EditProfileSchema>({
|
||||
defaultValues: {
|
||||
"address.city": user.address.city ?? "",
|
||||
"address.countryCode": user.address.countryCode ?? "",
|
||||
"address.streetAddress": user.address.streetAddress ?? "",
|
||||
"address.zipCode": user.address.zipCode ?? "",
|
||||
address: {
|
||||
city: user.address.city ?? "",
|
||||
countryCode: user.address.countryCode ?? "",
|
||||
streetAddress: user.address.streetAddress ?? "",
|
||||
zipCode: user.address.zipCode ?? "",
|
||||
},
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
email: user.email,
|
||||
language: user.language,
|
||||
@@ -53,7 +61,7 @@ export default function Form({ user }: EditFormProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<>
|
||||
<Header>
|
||||
<hgroup>
|
||||
<Title as="h4" color="red" level="h1">
|
||||
@@ -64,17 +72,15 @@ export default function Form({ user }: EditFormProps) {
|
||||
</Title>
|
||||
</hgroup>
|
||||
<div className={styles.btns}>
|
||||
<Button
|
||||
form={formId}
|
||||
intent="secondary"
|
||||
size="small"
|
||||
theme="base"
|
||||
type="reset"
|
||||
>
|
||||
{formatMessage({ id: "Discard changes" })}
|
||||
<Button asChild intent="secondary" size="small" theme="base">
|
||||
<Link href={profile[lang]}>
|
||||
{formatMessage({ id: "Discard changes" })}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
size="small"
|
||||
@@ -85,10 +91,11 @@ export default function Form({ user }: EditFormProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</Header>
|
||||
<Divider color="burgundy" opacity={8} />
|
||||
<form action={formAction} className={styles.form} id={formId}>
|
||||
<FormContent />
|
||||
<FormProvider {...methods}>
|
||||
<FormContent />
|
||||
</FormProvider>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,98 @@
|
||||
import { z } from "zod"
|
||||
|
||||
// import { phoneValidator } from "@/utils/phoneValidator"
|
||||
import { Key } from "@/components/TempDesignSystem/Form/NewPassword/newPassword"
|
||||
import { phoneValidator } from "@/utils/phoneValidator"
|
||||
|
||||
export const editProfileSchema = z.object({
|
||||
"address.city": z.string().optional(),
|
||||
"address.countryCode": z.string().min(1),
|
||||
"address.streetAddress": z.string().optional(),
|
||||
"address.zipCode": z.string().min(1),
|
||||
dateOfBirth: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
language: z.string(),
|
||||
phoneNumber: z.string(),
|
||||
// phoneValidator(
|
||||
// "Phone is required",
|
||||
// "Please enter a valid phone number"
|
||||
// ),
|
||||
const countryRequiredMsg = "Country is required"
|
||||
export const editProfileSchema = z
|
||||
.object({
|
||||
address: z.object({
|
||||
city: z.string().optional(),
|
||||
countryCode: z
|
||||
.string({
|
||||
required_error: countryRequiredMsg,
|
||||
invalid_type_error: countryRequiredMsg,
|
||||
})
|
||||
.min(1, countryRequiredMsg),
|
||||
streetAddress: z.string().optional(),
|
||||
zipCode: z.string().min(1, "Zip code is required"),
|
||||
}),
|
||||
dateOfBirth: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
language: z.string(),
|
||||
phoneNumber: phoneValidator(
|
||||
"Phone is required",
|
||||
"Please enter a valid phone number"
|
||||
),
|
||||
|
||||
currentPassword: z.string().optional(),
|
||||
newPassword: z.string().optional(),
|
||||
retypeNewPassword: z.string().optional(),
|
||||
})
|
||||
currentPassword: z.string().optional(),
|
||||
newPassword: z.string().optional(),
|
||||
retypeNewPassword: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.currentPassword) {
|
||||
if (!data.newPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "New password is required",
|
||||
path: ["newPassword"],
|
||||
})
|
||||
}
|
||||
if (!data.retypeNewPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Retype new password is required",
|
||||
path: ["retypeNewPassword"],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (data.newPassword || data.retypeNewPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Current password is required",
|
||||
path: ["currentPassword"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (data.newPassword) {
|
||||
const msgs = []
|
||||
if (data.newPassword.length < 10 || data.newPassword.length > 40) {
|
||||
msgs.push(Key.CHAR_LENGTH)
|
||||
}
|
||||
if (!data.newPassword.match(/[A-Z]/g)) {
|
||||
msgs.push(Key.UPPERCASE)
|
||||
}
|
||||
if (!data.newPassword.match(/[0-9]/g)) {
|
||||
msgs.push(Key.NUM)
|
||||
}
|
||||
if (!data.newPassword.match(/[^A-Za-z0-9]/g)) {
|
||||
msgs.push(Key.SPECIAL_CHAR)
|
||||
}
|
||||
if (msgs.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: msgs.join(","),
|
||||
path: ["newPassword"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (data.newPassword && !data.retypeNewPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Retype new password is required",
|
||||
path: ["retypeNewPassword"],
|
||||
})
|
||||
}
|
||||
|
||||
if (data.retypeNewPassword !== data.newPassword) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Retype new password does not match new password",
|
||||
path: ["retypeNewPassword"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type EditProfileSchema = z.infer<typeof editProfileSchema>
|
||||
|
||||
36
components/Icons/Close.tsx
Normal file
36
components/Icons/Close.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CloseIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
height="20"
|
||||
id="mask0_7531_17458"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style={{ maskType: "alpha" }}
|
||||
width="20"
|
||||
x="0"
|
||||
y="0"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_7531_17458)">
|
||||
<path
|
||||
d="M10 11.0936L7.58333 13.5207C7.43056 13.6734 7.24653 13.7498 7.03125 13.7498C6.81597 13.7498 6.63194 13.6734 6.47917 13.5207C6.32639 13.3679 6.25 13.1839 6.25 12.9686C6.25 12.7533 6.32639 12.5693 6.47917 12.4165L8.90625 9.99984L6.47917 7.59359C6.32639 7.44081 6.25 7.25678 6.25 7.0415C6.25 6.82623 6.32639 6.6422 6.47917 6.48942C6.63194 6.33664 6.81597 6.26025 7.03125 6.26025C7.24653 6.26025 7.43056 6.33664 7.58333 6.48942L10 8.9165L12.4062 6.48942C12.559 6.33664 12.7431 6.26025 12.9583 6.26025C13.1736 6.26025 13.3576 6.33664 13.5104 6.48942C13.6701 6.64914 13.75 6.83491 13.75 7.04671C13.75 7.25852 13.6701 7.44081 13.5104 7.59359L11.0833 9.99984L13.5104 12.4165C13.6632 12.5693 13.7396 12.7533 13.7396 12.9686C13.7396 13.1839 13.6632 13.3679 13.5104 13.5207C13.3507 13.6804 13.1649 13.7603 12.9531 13.7603C12.7413 13.7603 12.559 13.6804 12.4062 13.5207L10 11.0936Z"
|
||||
fill="#CD0921"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export { default as CheckIcon } from "./Check"
|
||||
export { default as CheckCircleIcon } from "./CheckCircle"
|
||||
export { default as ChevronDownIcon } from "./ChevronDown"
|
||||
export { default as ChevronRightIcon } from "./ChevronRight"
|
||||
export { default as CloseIcon } from "./Close"
|
||||
export { default as EmailIcon } from "./Email"
|
||||
export { default as GlobeIcon } from "./Globe"
|
||||
export { default as HouseIcon } from "./House"
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.comboBoxContainer:has(
|
||||
.input[data-invalid="true"],
|
||||
.input[aria-invalid="true"]
|
||||
) {
|
||||
border-color: var(--Scandic-Red-60);
|
||||
}
|
||||
|
||||
.label {
|
||||
grid-area: label;
|
||||
}
|
||||
@@ -66,6 +73,8 @@
|
||||
var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.listBoxItem[data-focused="true"],
|
||||
.listBoxItem[data-focus-visible="true"],
|
||||
.listBoxItem[data-selected="true"],
|
||||
.listBoxItem:hover {
|
||||
background-color: var(--Scandic-Blue-00);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client"
|
||||
import { ErrorMessage } from "@hookform/error-message"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Button,
|
||||
ComboBox,
|
||||
FieldError,
|
||||
Input,
|
||||
type Key,
|
||||
ListBox,
|
||||
@@ -18,6 +16,7 @@ import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import SelectChevron from "@/components/TempDesignSystem/Form/SelectChevron"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import ErrorMessage from "../ErrorMessage"
|
||||
import { countries } from "./countries"
|
||||
|
||||
import styles from "./country.module.css"
|
||||
@@ -42,7 +41,7 @@ export default function CountrySelect({
|
||||
}
|
||||
}
|
||||
const { control, setValue } = useFormContext()
|
||||
const { field } = useController({
|
||||
const { field, fieldState, formState } = useController({
|
||||
control,
|
||||
name,
|
||||
rules: registerOptions,
|
||||
@@ -60,6 +59,7 @@ export default function CountrySelect({
|
||||
aria-label={formatMessage({ id: "Select country of residence" })}
|
||||
className={styles.select}
|
||||
isRequired={!!registerOptions?.required}
|
||||
isInvalid={fieldState.invalid}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
onSelectionChange={handleChange}
|
||||
@@ -85,9 +85,7 @@ export default function CountrySelect({
|
||||
<SelectChevron />
|
||||
</Button>
|
||||
</div>
|
||||
<FieldError>
|
||||
<ErrorMessage name={name} />
|
||||
</FieldError>
|
||||
<ErrorMessage errors={formState.errors} name={name} />
|
||||
<Popover
|
||||
className={styles.popover}
|
||||
placement="bottom"
|
||||
|
||||
@@ -8,5 +8,5 @@ export const enum DateName {
|
||||
export interface DateProps
|
||||
extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
name: string
|
||||
registerOptions: RegisterOptions
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
"use client"
|
||||
import { parseDate } from "@internationalized/date"
|
||||
import {
|
||||
DateInput,
|
||||
DatePicker,
|
||||
DateSegment,
|
||||
Group,
|
||||
} from "react-aria-components"
|
||||
import { DateInput, DatePicker, Group } from "react-aria-components"
|
||||
import { useController, useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -23,7 +18,7 @@ import type { Key } from "react-aria-components"
|
||||
import type { DateProps } from "./date"
|
||||
|
||||
/** TODO: Get selecting with Enter-key to work */
|
||||
export default function DateSelect({ name, registerOptions }: DateProps) {
|
||||
export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
const { formatMessage } = useIntl()
|
||||
const d = useWatch({ name })
|
||||
const { control, setValue } = useFormContext()
|
||||
@@ -84,7 +79,7 @@ export default function DateSelect({ name, registerOptions }: DateProps) {
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DateSegment className={styles.day} segment={segment}>
|
||||
<div className={styles.day}>
|
||||
<Select
|
||||
aria-label={dayLabel}
|
||||
items={days}
|
||||
@@ -92,13 +87,15 @@ export default function DateSelect({ name, registerOptions }: DateProps) {
|
||||
name={DateName.date}
|
||||
onSelect={createOnSelect(DateName.date)}
|
||||
placeholder="DD"
|
||||
required
|
||||
tabIndex={3}
|
||||
value={segment.value}
|
||||
/>
|
||||
</DateSegment>
|
||||
</div>
|
||||
)
|
||||
case "month":
|
||||
return (
|
||||
<DateSegment className={styles.month} segment={segment}>
|
||||
<div className={styles.month}>
|
||||
<Select
|
||||
aria-label={monthLabel}
|
||||
items={months}
|
||||
@@ -106,13 +103,15 @@ export default function DateSelect({ name, registerOptions }: DateProps) {
|
||||
name={DateName.month}
|
||||
onSelect={createOnSelect(DateName.month)}
|
||||
placeholder="MM"
|
||||
required
|
||||
tabIndex={2}
|
||||
value={segment.value}
|
||||
/>
|
||||
</DateSegment>
|
||||
</div>
|
||||
)
|
||||
case "year":
|
||||
return (
|
||||
<DateSegment className={styles.year} segment={segment}>
|
||||
<div className={styles.year}>
|
||||
<Select
|
||||
aria-label={yearLabel}
|
||||
items={years}
|
||||
@@ -120,9 +119,11 @@ export default function DateSelect({ name, registerOptions }: DateProps) {
|
||||
name={DateName.year}
|
||||
onSelect={createOnSelect(DateName.year)}
|
||||
placeholder="YYYY"
|
||||
required
|
||||
tabIndex={1}
|
||||
value={segment.value}
|
||||
/>
|
||||
</DateSegment>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
/** DateInput forces return of ReactElement */
|
||||
|
||||
13
components/TempDesignSystem/Form/ErrorMessage/Error.tsx
Normal file
13
components/TempDesignSystem/Form/ErrorMessage/Error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { ErrorMessage as RHFErrorMessage } from "@hookform/error-message"
|
||||
|
||||
import { InfoCircleIcon } from "@/components/Icons"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./error.module.css"
|
||||
import Error from "./Error"
|
||||
|
||||
import type { ErrorMessageProps } from "./errorMessage"
|
||||
|
||||
@@ -15,12 +12,7 @@ export default function ErrorMessage<T>({
|
||||
<RHFErrorMessage
|
||||
errors={errors}
|
||||
name={name}
|
||||
render={({ message }) => (
|
||||
<Caption className={styles.message} fontOnly>
|
||||
<InfoCircleIcon color="red" />
|
||||
{message}
|
||||
</Caption>
|
||||
)}
|
||||
render={({ message }) => <Error>{message}</Error>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,66 +2,86 @@
|
||||
import {
|
||||
Input as AriaInput,
|
||||
Label as AriaLabel,
|
||||
Text,
|
||||
TextField,
|
||||
} from "react-aria-components"
|
||||
import { useController, useFormContext } from "react-hook-form"
|
||||
import { Controller, useFormContext } from "react-hook-form"
|
||||
|
||||
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
|
||||
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
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,
|
||||
disabled,
|
||||
disabled = false,
|
||||
helpText = "",
|
||||
label,
|
||||
name,
|
||||
placeholder = "",
|
||||
registerOptions = {},
|
||||
required = false,
|
||||
type = "text",
|
||||
}: InputProps) {
|
||||
const { control } = useFormContext()
|
||||
const rules = {
|
||||
...registerOptions,
|
||||
required:
|
||||
"required" in registerOptions ? !!registerOptions.required : required,
|
||||
let numberAttributes: HTMLAttributes<HTMLInputElement> = {}
|
||||
if (type === "number") {
|
||||
numberAttributes.onWheel = function (evt: WheelEvent<HTMLInputElement>) {
|
||||
evt.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
const { field, fieldState, formState } = useController({
|
||||
control,
|
||||
name,
|
||||
rules,
|
||||
})
|
||||
|
||||
return (
|
||||
<TextField
|
||||
aria-label={ariaLabel}
|
||||
defaultValue={field.value}
|
||||
isDisabled={disabled ?? field.disabled}
|
||||
isInvalid={fieldState.invalid}
|
||||
isRequired={!!registerOptions?.required}
|
||||
name={field.name}
|
||||
type={type}
|
||||
>
|
||||
<AriaLabel className={styles.container} htmlFor={field.name}>
|
||||
<Body asChild fontOnly>
|
||||
<AriaInput
|
||||
className={styles.input}
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
placeholder={placeholder}
|
||||
ref={field.ref}
|
||||
required={rules.required}
|
||||
/>
|
||||
</Body>
|
||||
<Label required={rules.required}>{label}</Label>
|
||||
</AriaLabel>
|
||||
<ErrorMessage errors={formState.errors} name={field.name} />
|
||||
</TextField>
|
||||
<Controller
|
||||
disabled={disabled}
|
||||
control={control}
|
||||
name={name}
|
||||
rules={registerOptions}
|
||||
render={({ field, fieldState }) => (
|
||||
<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}
|
||||
>
|
||||
<AriaLabel className={styles.container} htmlFor={field.name}>
|
||||
<Body asChild fontOnly>
|
||||
<AriaInput
|
||||
{...numberAttributes}
|
||||
aria-labelledby={field.name}
|
||||
className={styles.input}
|
||||
id={field.name}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@
|
||||
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;
|
||||
@@ -42,3 +52,21 @@
|
||||
height: 18px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RegisterOptions } from "react-hook-form"
|
||||
import type { RegisterOptions, UseFormRegister } from "react-hook-form"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
helpText?: string
|
||||
label: string
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
|
||||
@@ -28,3 +28,7 @@ input:placeholder-shown ~ .label {
|
||||
align-self: center;
|
||||
grid-row: 1/-1;
|
||||
}
|
||||
|
||||
input:disabled ~ .label {
|
||||
color: var(--Main-Grey-40);
|
||||
}
|
||||
|
||||
113
components/TempDesignSystem/Form/NewPassword/index.tsx
Normal file
113
components/TempDesignSystem/Form/NewPassword/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
import {
|
||||
Input as AriaInput,
|
||||
Label as AriaLabel,
|
||||
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 Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import { type IconProps, Key, type NewPasswordProps } from "./newPassword"
|
||||
|
||||
import styles from "./newPassword.module.css"
|
||||
|
||||
export default function NewPassword({
|
||||
"aria-label": ariaLabel,
|
||||
disabled = false,
|
||||
placeholder = "",
|
||||
registerOptions = {},
|
||||
}: NewPasswordProps) {
|
||||
const { control } = useFormContext()
|
||||
const { formatMessage } = useIntl()
|
||||
return (
|
||||
<Controller
|
||||
disabled={disabled}
|
||||
control={control}
|
||||
name="newPassword"
|
||||
rules={registerOptions}
|
||||
render={({ field, fieldState }) => {
|
||||
const messages = (fieldState.error?.message?.split(",") ?? []) as Key[]
|
||||
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="password"
|
||||
>
|
||||
<AriaLabel className={styles.container} htmlFor={field.name}>
|
||||
<Body asChild fontOnly>
|
||||
<AriaInput
|
||||
aria-labelledby={field.name}
|
||||
className={styles.input}
|
||||
id={field.name}
|
||||
placeholder={placeholder}
|
||||
type="password"
|
||||
/>
|
||||
</Body>
|
||||
<Label required={!!registerOptions.required}>
|
||||
{formatMessage({ id: "New password" })}
|
||||
</Label>
|
||||
</AriaLabel>
|
||||
{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>
|
||||
</div>
|
||||
) : null}
|
||||
{!field.value && fieldState.error ? (
|
||||
<Error>
|
||||
<Text className={styles.helpText} slot="description">
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
</Error>
|
||||
) : null}
|
||||
</TextField>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Icon({ matcher, messages }: IconProps) {
|
||||
return messages.includes(matcher) ? (
|
||||
<CloseIcon color="red" height={20} width={20} />
|
||||
) : (
|
||||
<CheckIcon color="green" height={20} width={20} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
.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;
|
||||
gap: var(--Spacing-x-half);
|
||||
grid-template-rows: auto auto;
|
||||
height: 60px;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.container:has(.input:not(:focus):placeholder-shown) {
|
||||
gap: 0;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.input:focus:placeholder-shown {
|
||||
height: 18px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
color: var(--Main-Grey-40);
|
||||
}
|
||||
|
||||
.errors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--Spacing-x-one-and-half) var(--Spacing-x1);
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.helpText {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
19
components/TempDesignSystem/Form/NewPassword/newPassword.ts
Normal file
19
components/TempDesignSystem/Form/NewPassword/newPassword.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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
|
||||
registerOptions?: RegisterOptions
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
matcher: Key
|
||||
messages: Key[]
|
||||
}
|
||||
@@ -132,7 +132,6 @@ export default function Phone({
|
||||
</AriaLabel>
|
||||
<ErrorMessage errors={formState.errors} name={field.name} />
|
||||
</TextField>
|
||||
<ErrorMessage errors={formState.errors} name={name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
border-color: var(--Scandic-Blue-90);
|
||||
}
|
||||
|
||||
.inputContainer:has(.input[data-invalid="true"], .input[aria-invalid="true"]) {
|
||||
border-color: var(--Scandic-Red-60);
|
||||
}
|
||||
|
||||
.input {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -31,6 +31,8 @@ export default function Select({
|
||||
name,
|
||||
onSelect,
|
||||
placeholder,
|
||||
required = false,
|
||||
tabIndex,
|
||||
value,
|
||||
}: SelectProps) {
|
||||
const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined)
|
||||
@@ -58,8 +60,10 @@ export default function Select({
|
||||
>
|
||||
<Body asChild fontOnly>
|
||||
<Button className={styles.input}>
|
||||
<div className={styles.inputContentWrapper}>
|
||||
<Label size="small">{label}</Label>
|
||||
<div className={styles.inputContentWrapper} tabIndex={tabIndex}>
|
||||
<Label required={required} size="small">
|
||||
{label}
|
||||
</Label>
|
||||
<SelectValue />
|
||||
</div>
|
||||
<SelectChevron />
|
||||
|
||||
@@ -58,7 +58,8 @@
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.listBoxItem[data-focused="true"] {
|
||||
.listBoxItem[data-focused="true"],
|
||||
.listBoxItem[data-selected="true"] {
|
||||
background: var(--UI-Input-Controls-Surface-Hover, var(--Scandic-Blue-00));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Key } from "react-aria-components"
|
||||
import type { Key, SelectProps as AriaSelectProps } from "react-aria-components"
|
||||
|
||||
export interface SelectProps
|
||||
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onSelect"> {
|
||||
|
||||
Reference in New Issue
Block a user