Merged in feat/LOY-169-use-new-country-select (pull request #1832)

feat(LOY-169): use new filter select for country select

Approved-by: Erik Tiekstra
This commit is contained in:
Christian Andolf
2025-04-22 07:14:06 +00:00
9 changed files with 60 additions and 200 deletions

View File

@@ -1,80 +0,0 @@
.container {
position: relative;
}
.comboBoxContainer {
position: relative;
height: 60px;
}
.label {
position: absolute;
left: var(--Spacing-x2);
top: var(--Spacing-x-one-and-half);
pointer-events: none;
}
.input {
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-radius: var(--Corner-radius-Medium);
border-style: solid;
border-width: 1px;
padding: var(--Spacing-x4) var(--Spacing-x2) var(--Spacing-x1);
width: 100%;
height: 100%;
&[aria-invalid="true"],
&[data-invalid="true"] {
border-color: var(--Scandic-Red-60);
}
}
.input,
.listBoxItem {
color: var(--Main-Grey-100);
}
.button {
background: none;
border: none;
cursor: pointer;
grid-area: chevron;
height: 100%;
justify-self: flex-end;
padding-left: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
position: absolute;
right: 0;
bottom: 0;
outline: none;
}
.popover {
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Medium);
left: 0px;
max-height: 400px;
overflow: auto;
padding: var(--Spacing-x2);
top: calc(60px + var(--Spacing-x1));
width: 100%;
}
.listBoxItem {
padding: var(--Spacing-x1) var(--Spacing-x1) var(--Spacing-x1)
var(--Spacing-x2);
}
.listBoxItem[data-focused="true"],
.listBoxItem[data-focus-visible="true"],
.listBoxItem[data-selected="true"],
.listBoxItem:hover {
background-color: var(--Scandic-Blue-00);
border-radius: var(--Corner-radius-Medium);
cursor: pointer;
outline: none;
}

View File

@@ -1,33 +1,19 @@
"use client"
import { useState } from "react"
import {
Button,
ComboBox,
Input,
type Key,
ListBox,
ListBoxItem,
Popover,
} from "react-aria-components"
import { useMemo, useState } from "react"
import { useFilter } from "react-aria-components"
import { useController, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { Select } from "@scandic-hotels/design-system/Select"
import { countries } from "@/constants/countries"
import Label from "@/components/TempDesignSystem/Form/Label"
import SelectChevron from "@/components/TempDesignSystem/Form/SelectChevron"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import ErrorMessage from "../ErrorMessage"
import styles from "./country.module.css"
import type {
CountryPortalContainer,
CountryPortalContainerArgs,
CountryProps,
} from "./country"
import type { CountryProps } from "./country"
export default function CountrySelect({
className = "",
@@ -38,104 +24,45 @@ export default function CountrySelect({
}: CountryProps) {
const lang = useLang()
const intl = useIntl()
const [rootDiv, setRootDiv] = useState<CountryPortalContainer>(undefined)
function setRef(node: CountryPortalContainerArgs) {
if (node) {
setRootDiv(node)
}
}
const { control, setValue } = useFormContext()
const { field, formState } = useController({
const { startsWith } = useFilter({ sensitivity: "base" })
const [filterValue, setFilterValue] = useState("")
const { control } = useFormContext()
const { field, formState, fieldState } = useController({
control,
name,
rules: registerOptions,
})
function handleChange(country: Key | null) {
setValue(name, country ?? "")
}
const selectCountryLabel = intl.formatMessage({
defaultMessage: "Select a country",
})
const collator = new Intl.Collator(lang)
const items = useMemo(() => {
const collator = new Intl.Collator(lang)
return countries
.map((country) => ({
value: country.code,
label:
intl.formatDisplayName(country.code, { type: "region" }) ||
country.name,
}))
.filter((item) => startsWith(item.label, filterValue))
.sort((a, b) => collator.compare(a.label, b.label))
}, [filterValue, intl, lang, startsWith])
return (
<div className={`${styles.container} ${className}`} ref={setRef}>
<ComboBox
aria-label={intl.formatMessage({
defaultMessage: "Select country of residence",
})}
isReadOnly={readOnly}
isRequired={!!registerOptions?.required}
<div className={className}>
<Select
label={label}
items={items}
enableFiltering
isRequired={Boolean(registerOptions?.required)}
isInvalid={fieldState.invalid}
name={field.name}
onBlur={field.onBlur}
onSelectionChange={handleChange}
ref={field.ref}
selectedKey={field.value}
defaultSelectedKey={field.value}
data-testid={name}
>
<div className={styles.comboBoxContainer}>
<Label
className={styles.label}
size="small"
required={!!registerOptions.required}
>
{label}
</Label>
<Body asChild fontOnly>
<Input
aria-label={selectCountryLabel}
className={styles.input}
placeholder={selectCountryLabel}
/>
</Body>
<Button className={styles.button}>
<SelectChevron />
</Button>
</div>
<ErrorMessage errors={formState.errors} name={name} />
<Popover
className={styles.popover}
placement="bottom"
shouldFlip={false}
shouldUpdatePosition={false}
/**
* react-aria uses portals to render Popover in body
* unless otherwise specified. We need it to be contained
* by this component to both access css variables assigned
* on the container as well as to not overflow it at any time.
*/
UNSTABLE_portalContainer={rootDiv}
>
<ListBox>
{countries
.map((country) => ({
...country,
localizedDisplayName:
intl.formatDisplayName(country.code, { type: "region" }) ||
country.name,
}))
.sort((a, b) =>
collator.compare(a.localizedDisplayName, b.localizedDisplayName)
)
.map((country, idx) => {
return (
<Body asChild fontOnly key={`${country.code}-${idx}`}>
<ListBoxItem
aria-label={country.name}
className={styles.listBoxItem}
id={country.code}
>
{country.localizedDisplayName}
</ListBoxItem>
</Body>
)
})}
</ListBox>
</Popover>
</ComboBox>
isReadOnly={readOnly}
onInputChange={setFilterValue}
/>
<ErrorMessage errors={formState.errors} name={name} />
</div>
)
}

View File

@@ -22,7 +22,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
const lang = useLang()
const { control, setValue, formState, watch } = useFormContext()
const { field } = useController({
const { field, fieldState } = useController({
control,
name,
rules: registerOptions,
@@ -127,6 +127,8 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
onSelectionChange={(key) => setValue(DateName.day, Number(key))}
isRequired
enableFiltering
isInvalid={fieldState.invalid}
onBlur={field.onBlur}
defaultSelectedKey={dateValue?.day}
/>
</div>
@@ -140,6 +142,8 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
onSelectionChange={(key) => setValue(DateName.month, Number(key))}
isRequired
enableFiltering
isInvalid={fieldState.invalid}
onBlur={field.onBlur}
defaultSelectedKey={dateValue?.month}
/>
</div>
@@ -153,6 +157,8 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
onSelectionChange={(key) => setValue(DateName.year, Number(key))}
isRequired
enableFiltering
isInvalid={fieldState.invalid}
onBlur={field.onBlur}
defaultSelectedKey={dateValue?.year}
/>
</div>

View File

@@ -1,6 +1,6 @@
.message {
align-items: center;
color: var(--Scandic-Red-60);
color: var(--Text-Interactive-Error);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;

View File

@@ -1,5 +1,7 @@
import { ErrorMessage as RHFErrorMessage } from "@hookform/error-message"
import { useIntl } from "react-intl"
import { getErrorMessage } from "../Input/errors"
import Error from "./Error"
import type { ErrorMessageProps } from "./errorMessage"
@@ -8,11 +10,12 @@ export default function ErrorMessage<T>({
errors,
name,
}: ErrorMessageProps<T>) {
const intl = useIntl()
return (
<RHFErrorMessage
errors={errors}
name={name}
render={({ message }) => <Error>{message}</Error>}
render={({ message }) => <Error>{getErrorMessage(intl, message)}</Error>}
/>
)
}

View File

@@ -83,7 +83,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
) : null}
{fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly>
<MaterialIcon icon="info" color="Icon/Interactive/Accent" />
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
{getErrorMessage(intl, fieldState.error.message)}
</Caption>
) : null}

View File

@@ -6,7 +6,7 @@
.error {
align-items: center;
color: var(--Scandic-Red-60);
color: var(--Text-Interactive-Error);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;

View File

@@ -120,7 +120,6 @@ export const countriesMap = {
"Korea, Republic of": "KR",
Kuwait: "KW",
Kyrgyzstan: "KG",
'Lao People"S Democratic Republic': "LA",
Laos: "LA",
Latvia: "LV",
Lebanon: "LB",
@@ -241,7 +240,6 @@ export const countriesMap = {
Uzbekistan: "UZ",
Vanuatu: "VU",
Venezuela: "VE",
"Viet Nam": "VN",
Vietnam: "VN",
"Virgin Islands, British": "VG",
"Virgin Islands, U.S.": "VI",

View File

@@ -24,7 +24,9 @@ export function SelectFilter({
icon,
itemIcon,
defaultSelectedKey,
onSelectionChange,
onSelectionChange = () => undefined,
onFocus = () => undefined,
onBlur = () => undefined,
...props
}: SelectFilterProps) {
const [focus, setFocus] = useState(false)
@@ -39,12 +41,16 @@ export function SelectFilter({
isDisabled={isDisabled}
onSelectionChange={(val) => {
setValue(val)
if (onSelectionChange) {
onSelectionChange(val)
}
onSelectionChange(val)
}}
onFocus={(e) => {
setFocus(true)
onFocus(e)
}}
onBlur={(e) => {
setFocus(false)
onBlur(e)
}}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
defaultSelectedKey={defaultSelectedKey}
{...props}
>