feat(WEB-163): edit profile field validation

This commit is contained in:
Simon Emanuelsson
2024-07-01 15:37:12 +02:00
parent 2a71a45d3d
commit 56bfbc3b71
30 changed files with 588 additions and 134 deletions

View File

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

View File

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

View File

@@ -8,5 +8,5 @@ export const enum DateName {
export interface DateProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {
name: string
registerOptions: RegisterOptions
registerOptions?: RegisterOptions
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -28,3 +28,7 @@ input:placeholder-shown ~ .label {
align-self: center;
grid-row: 1/-1;
}
input:disabled ~ .label {
color: var(--Main-Grey-40);
}

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

View File

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

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

View File

@@ -132,7 +132,6 @@ export default function Phone({
</AriaLabel>
<ErrorMessage errors={formState.errors} name={field.name} />
</TextField>
<ErrorMessage errors={formState.errors} name={name} />
</div>
)
}

View File

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

View File

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

View File

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

View File

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