Merged in feat/use-new-input-component (pull request #3324)
feat(SW-3659): Use new input component * Use new input component * Update error formatter * Merged master into feat/use-new-input-component * Merged master into feat/use-new-input-component * Merge branch 'master' into feat/use-new-input-component * Merged master into feat/use-new-input-component * Update Input stories * Merge branch 'feat/use-new-input-component' of bitbucket.org:scandic-swap/web into feat/use-new-input-component * Update Storybook logo * Add some new demo icon input story * Fix the clear content button position * Fix broken password input icon * Merged master into feat/use-new-input-component * Merged master into feat/use-new-input-component * Add aria-hidden to required asterisk * Merge branch 'feat/use-new-input-component' of bitbucket.org:scandic-swap/web into feat/use-new-input-component * Merge branch 'master' into feat/use-new-input-component Approved-by: Bianca Widstam Approved-by: Matilda Landström
This commit is contained in:
@@ -15,9 +15,9 @@ import {
|
|||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import Image from "@scandic-hotels/design-system/Image"
|
import Image from "@scandic-hotels/design-system/Image"
|
||||||
import { Input } from "@scandic-hotels/design-system/Input"
|
|
||||||
import Modal from "@scandic-hotels/design-system/Modal"
|
import Modal from "@scandic-hotels/design-system/Modal"
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
@@ -87,7 +87,8 @@ export function TransferPointsFormClient({
|
|||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
<div className={styles.inputsWrapper}>
|
<div className={styles.inputsWrapper}>
|
||||||
<TextField type="number" isDisabled={disabled}>
|
<TextField type="number" isDisabled={disabled}>
|
||||||
<Input
|
<FormInput
|
||||||
|
name="ebPointsToExchange"
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "partnerSas.ebPointsToExchange",
|
id: "partnerSas.ebPointsToExchange",
|
||||||
defaultMessage: "EB points to exchange",
|
defaultMessage: "EB points to exchange",
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ import { getDefaultCountryFromLang } from "@scandic-hotels/common/utils/phone"
|
|||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
||||||
import DateSelect from "@scandic-hotels/design-system/Form/Date"
|
import DateSelect from "@scandic-hotels/design-system/Form/Date"
|
||||||
|
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
||||||
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
||||||
import { FormSelect } from "@scandic-hotels/design-system/Form/Select"
|
import { FormSelect } from "@scandic-hotels/design-system/Form/Select"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { getLocalizedLanguageOptions } from "@/constants/languages"
|
import { getLocalizedLanguageOptions } from "@/constants/languages"
|
||||||
|
|
||||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
|
||||||
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
|
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
import { getFormattedCountryList } from "@/utils/countries"
|
import { getFormattedCountryList } from "@/utils/countries"
|
||||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
import {
|
||||||
|
formatFormErrorMessage,
|
||||||
|
getErrorMessage,
|
||||||
|
} from "@/utils/getErrorMessage"
|
||||||
|
|
||||||
import styles from "./formContent.module.css"
|
import styles from "./formContent.module.css"
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
|
|||||||
name="dateOfBirth"
|
name="dateOfBirth"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
data-hj-suppress
|
data-hj-suppress
|
||||||
label={`${intl.formatMessage({
|
label={`${intl.formatMessage({
|
||||||
id: "common.address",
|
id: "common.address",
|
||||||
@@ -71,7 +74,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
|
|||||||
})} 1`}
|
})} 1`}
|
||||||
name="address.streetAddress"
|
name="address.streetAddress"
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
data-hj-suppress
|
data-hj-suppress
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.city",
|
id: "common.city",
|
||||||
@@ -80,12 +83,13 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
|
|||||||
name="address.city"
|
name="address.city"
|
||||||
/>
|
/>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Input
|
<FormInput
|
||||||
data-hj-suppress
|
data-hj-suppress
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.zipCode",
|
id: "common.zipCode",
|
||||||
defaultMessage: "Zip code",
|
defaultMessage: "Zip code",
|
||||||
})}
|
})}
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
name="address.zipCode"
|
name="address.zipCode"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
@@ -105,7 +109,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
|
|||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.emailAddress",
|
id: "common.emailAddress",
|
||||||
defaultMessage: "Email address",
|
defaultMessage: "Email address",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Button } from "@scandic-hotels/design-system/Button"
|
|||||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||||
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
||||||
import DateSelect from "@scandic-hotels/design-system/Form/Date"
|
import DateSelect from "@scandic-hotels/design-system/Form/Date"
|
||||||
|
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
||||||
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
||||||
import { TextLink } from "@scandic-hotels/design-system/TextLink"
|
import { TextLink } from "@scandic-hotels/design-system/TextLink"
|
||||||
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
|
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
|
||||||
@@ -29,11 +30,13 @@ import {
|
|||||||
} from "@scandic-hotels/trpc/routers/user/schemas"
|
} from "@scandic-hotels/trpc/routers/user/schemas"
|
||||||
|
|
||||||
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
|
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
|
||||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
|
||||||
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
|
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
import { getFormattedCountryList } from "@/utils/countries"
|
import { getFormattedCountryList } from "@/utils/countries"
|
||||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
import {
|
||||||
|
formatFormErrorMessage,
|
||||||
|
getErrorMessage,
|
||||||
|
} from "@/utils/getErrorMessage"
|
||||||
import { requestOpen } from "@/utils/profilingConsent"
|
import { requestOpen } from "@/utils/profilingConsent"
|
||||||
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
|
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
|
||||||
|
|
||||||
@@ -162,7 +165,8 @@ export default function SignupForm({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</header>
|
</header>
|
||||||
<div className={styles.nameInputs}>
|
<div className={styles.nameInputs}>
|
||||||
<Input
|
<FormInput
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.firstName",
|
id: "common.firstName",
|
||||||
defaultMessage: "First name",
|
defaultMessage: "First name",
|
||||||
@@ -170,7 +174,8 @@ export default function SignupForm({
|
|||||||
name="firstName"
|
name="firstName"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.lastName",
|
id: "common.lastName",
|
||||||
defaultMessage: "Last name",
|
defaultMessage: "Last name",
|
||||||
@@ -214,7 +219,8 @@ export default function SignupForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={cx(styles.container, styles.additional)}>
|
<div className={cx(styles.container, styles.additional)}>
|
||||||
<Input
|
<FormInput
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.zipCode",
|
id: "common.zipCode",
|
||||||
defaultMessage: "Zip code",
|
defaultMessage: "Zip code",
|
||||||
@@ -236,7 +242,8 @@ export default function SignupForm({
|
|||||||
name="address.countryCode"
|
name="address.countryCode"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.emailAddress",
|
id: "common.emailAddress",
|
||||||
defaultMessage: "Email address",
|
defaultMessage: "Email address",
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { FormProvider, useForm } from "react-hook-form"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { Button } from "@scandic-hotels/design-system/Button"
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
||||||
|
|
||||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
import { formatFormErrorMessage } from "@/utils/getErrorMessage"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AdditionalInfoFormSchema,
|
type AdditionalInfoFormSchema,
|
||||||
@@ -53,15 +54,16 @@ export default function AdditionalInfoForm({
|
|||||||
<form onSubmit={form.handleSubmit(onSubmit)} className={styles.form}>
|
<form onSubmit={form.handleSubmit(onSubmit)} className={styles.form}>
|
||||||
<Title isAdditional />
|
<Title isAdditional />
|
||||||
<div className={styles.inputs}>
|
<div className={styles.inputs}>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.firstName",
|
id: "common.firstName",
|
||||||
defaultMessage: "First name",
|
defaultMessage: "First name",
|
||||||
})}
|
})}
|
||||||
name="firstName"
|
name="firstName"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.email",
|
id: "common.email",
|
||||||
defaultMessage: "Email",
|
defaultMessage: "Email",
|
||||||
@@ -69,6 +71,7 @@ export default function AdditionalInfoForm({
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import { myStay } from "@scandic-hotels/common/constants/routes/myStay"
|
|||||||
import { logger } from "@scandic-hotels/common/logger"
|
import { logger } from "@scandic-hotels/common/logger"
|
||||||
import { Alert } from "@scandic-hotels/design-system/Alert"
|
import { Alert } from "@scandic-hotels/design-system/Alert"
|
||||||
import { Button } from "@scandic-hotels/design-system/Button"
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
|
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
||||||
import { TextLink } from "@scandic-hotels/design-system/TextLink"
|
import { TextLink } from "@scandic-hotels/design-system/TextLink"
|
||||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
import { trpc } from "@scandic-hotels/trpc/client"
|
import { trpc } from "@scandic-hotels/trpc/client"
|
||||||
|
|
||||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
import { formatFormErrorMessage } from "@/utils/getErrorMessage"
|
||||||
|
|
||||||
import { type FindMyBookingFormSchema, findMyBookingFormSchema } from "./schema"
|
import { type FindMyBookingFormSchema, findMyBookingFormSchema } from "./schema"
|
||||||
import { Title } from "./Title"
|
import { Title } from "./Title"
|
||||||
@@ -95,31 +96,34 @@ export default function FindMyBooking({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={[styles.inputs, styles.grid].join(" ")}>
|
<div className={[styles.inputs, styles.grid].join(" ")}>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.bookingNumber",
|
id: "common.bookingNumber",
|
||||||
defaultMessage: "Booking number",
|
defaultMessage: "Booking number",
|
||||||
})}
|
})}
|
||||||
name="confirmationNumber"
|
name="confirmationNumber"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.firstName",
|
id: "common.firstName",
|
||||||
defaultMessage: "First name",
|
defaultMessage: "First name",
|
||||||
})}
|
})}
|
||||||
name="firstName"
|
name="firstName"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.lastName",
|
id: "common.lastName",
|
||||||
defaultMessage: "Last name",
|
defaultMessage: "Last name",
|
||||||
})}
|
})}
|
||||||
name="lastName"
|
name="lastName"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.email",
|
id: "common.email",
|
||||||
defaultMessage: "Email",
|
defaultMessage: "Email",
|
||||||
@@ -127,6 +131,7 @@ export default function FindMyBooking({
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
|
errorFormatter={formatFormErrorMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
getDefaultCountryFromLang,
|
getDefaultCountryFromLang,
|
||||||
} from "@scandic-hotels/common/utils/phone"
|
} from "@scandic-hotels/common/utils/phone"
|
||||||
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
||||||
|
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
||||||
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
import { getFormattedCountryList } from "@/utils/countries"
|
import { getFormattedCountryList } from "@/utils/countries"
|
||||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
import { getErrorMessage } from "@/utils/getErrorMessage"
|
||||||
@@ -45,7 +45,7 @@ export default function ModifyContact({
|
|||||||
{isFirstStep ? (
|
{isFirstStep ? (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={`${styles.row} ${styles.gridEqual}`}>
|
<div className={`${styles.row} ${styles.gridEqual}`}>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.firstName",
|
id: "common.firstName",
|
||||||
defaultMessage: "First name",
|
defaultMessage: "First name",
|
||||||
@@ -54,7 +54,7 @@ export default function ModifyContact({
|
|||||||
name="firstName"
|
name="firstName"
|
||||||
disabled={!!guest.firstName}
|
disabled={!!guest.firstName}
|
||||||
/>
|
/>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.lastName",
|
id: "common.lastName",
|
||||||
defaultMessage: "Last name",
|
defaultMessage: "Last name",
|
||||||
@@ -80,7 +80,7 @@ export default function ModifyContact({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<Input
|
<FormInput
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
id: "common.email",
|
id: "common.email",
|
||||||
defaultMessage: "Email",
|
defaultMessage: "Email",
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { forwardRef, type HTMLAttributes, type WheelEvent } from "react"
|
|
||||||
import { Text, TextField } from "react-aria-components"
|
|
||||||
import { Controller, useFormContext } from "react-hook-form"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import Caption from "@scandic-hotels/design-system/Caption"
|
|
||||||
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
||||||
import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input"
|
|
||||||
|
|
||||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
|
||||||
|
|
||||||
import styles from "./input.module.css"
|
|
||||||
|
|
||||||
import type { InputProps } from "./input"
|
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
||||||
{
|
|
||||||
"aria-label": ariaLabel,
|
|
||||||
autoComplete,
|
|
||||||
className = "",
|
|
||||||
disabled = false,
|
|
||||||
helpText = "",
|
|
||||||
label,
|
|
||||||
maxLength,
|
|
||||||
name,
|
|
||||||
placeholder,
|
|
||||||
readOnly = false,
|
|
||||||
registerOptions = {},
|
|
||||||
type = "text",
|
|
||||||
hideError,
|
|
||||||
inputMode,
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const { control } = useFormContext()
|
|
||||||
const numberAttributes: HTMLAttributes<HTMLInputElement> = {}
|
|
||||||
if (type === "number") {
|
|
||||||
numberAttributes.onWheel = function (evt: WheelEvent<HTMLInputElement>) {
|
|
||||||
evt.currentTarget.blur()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
disabled={disabled}
|
|
||||||
control={control}
|
|
||||||
name={name}
|
|
||||||
rules={registerOptions}
|
|
||||||
render={({ field, fieldState, formState }) => (
|
|
||||||
<TextField
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className={className}
|
|
||||||
isDisabled={field.disabled}
|
|
||||||
isReadOnly={readOnly}
|
|
||||||
isInvalid={fieldState.invalid}
|
|
||||||
isRequired={!!registerOptions.required}
|
|
||||||
name={field.name}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
onChange={field.onChange}
|
|
||||||
validationBehavior="aria"
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<InputWithLabel
|
|
||||||
{...field}
|
|
||||||
ref={ref}
|
|
||||||
aria-labelledby={field.name}
|
|
||||||
autoComplete={autoComplete}
|
|
||||||
id={field.name}
|
|
||||||
label={label}
|
|
||||||
maxLength={maxLength}
|
|
||||||
placeholder={placeholder}
|
|
||||||
readOnly={readOnly}
|
|
||||||
disabled={disabled}
|
|
||||||
required={!!registerOptions.required}
|
|
||||||
type={type}
|
|
||||||
inputMode={inputMode}
|
|
||||||
/>
|
|
||||||
{helpText && !fieldState.error ? (
|
|
||||||
<Caption asChild color="black">
|
|
||||||
<Text className={styles.helpText} slot="description">
|
|
||||||
<MaterialIcon icon="check" size={20} />
|
|
||||||
{helpText}
|
|
||||||
</Text>
|
|
||||||
</Caption>
|
|
||||||
) : null}
|
|
||||||
{fieldState.error && !hideError ? (
|
|
||||||
<ErrorMessage
|
|
||||||
errors={formState.errors}
|
|
||||||
name={name}
|
|
||||||
messageLabel={getErrorMessage(intl, fieldState.error.message)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</TextField>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
export default Input
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
.helpText {
|
|
||||||
align-items: flex-start;
|
|
||||||
display: flex;
|
|
||||||
gap: var(--Space-x05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
align-items: center;
|
|
||||||
color: var(--Text-Interactive-Error);
|
|
||||||
display: flex;
|
|
||||||
gap: var(--Space-x05);
|
|
||||||
margin: var(--Space-x1) 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error svg {
|
|
||||||
min-width: 20px;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { RegisterOptions } from "react-hook-form"
|
|
||||||
|
|
||||||
export interface InputProps
|
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
helpText?: string
|
|
||||||
label: string
|
|
||||||
name: string
|
|
||||||
registerOptions?: RegisterOptions
|
|
||||||
hideError?: boolean
|
|
||||||
}
|
|
||||||
@@ -20,8 +20,7 @@ import { NewPasswordValidation } from "./NewPasswordValidation"
|
|||||||
|
|
||||||
import styles from "./passwordInput.module.css"
|
import styles from "./passwordInput.module.css"
|
||||||
|
|
||||||
interface PasswordInputProps
|
interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
label?: string
|
label?: string
|
||||||
registerOptions?: RegisterOptions
|
registerOptions?: RegisterOptions
|
||||||
visibilityToggleable?: boolean
|
visibilityToggleable?: boolean
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggleButton {
|
.inputWrapper .toggleButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--Space-x2);
|
right: var(--Space-x2);
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@@ -196,3 +196,14 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
|
|||||||
return errorCode
|
return errorCode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for getErrorMessage that ensures a string is always returned.
|
||||||
|
* Can be used directly as errorFormatter prop for FormInput components.
|
||||||
|
*/
|
||||||
|
export function formatFormErrorMessage(
|
||||||
|
intl: IntlShape,
|
||||||
|
errorMessage?: string
|
||||||
|
): string {
|
||||||
|
return getErrorMessage(intl, errorMessage) ?? ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
// This is almost a copy of the Input in TempDesignSystem, but since it's tightly coupled
|
import { forwardRef, useMemo } from "react"
|
||||||
// to the error messages we need to duplicate it for now. In the future we should
|
|
||||||
// rewrite it to be more reusable.
|
|
||||||
|
|
||||||
import { forwardRef } from "react"
|
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
|
||||||
import { Text, TextField } from "react-aria-components"
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
type RegisterOptions,
|
|
||||||
useFormContext,
|
|
||||||
} from "react-hook-form"
|
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import Caption from "@scandic-hotels/design-system/Caption"
|
|
||||||
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
||||||
import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input"
|
|
||||||
|
|
||||||
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
|
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
|
||||||
import { getErrorMessage } from "./errors"
|
import { getErrorMessage } from "./errors"
|
||||||
|
|
||||||
import styles from "./input.module.css"
|
import type { RegisterOptions } from "react-hook-form"
|
||||||
|
import type { IntlShape } from "react-intl"
|
||||||
|
|
||||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
helpText?: string
|
helpText?: string
|
||||||
@@ -34,7 +21,6 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|||||||
const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
|
const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
|
||||||
function Input(
|
function Input(
|
||||||
{
|
{
|
||||||
"aria-label": ariaLabel,
|
|
||||||
autoComplete,
|
autoComplete,
|
||||||
className = "",
|
className = "",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -48,67 +34,38 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
type = "text",
|
type = "text",
|
||||||
hideError,
|
hideError,
|
||||||
inputMode,
|
inputMode,
|
||||||
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const intl = useIntl()
|
|
||||||
const { control, formState } = useFormContext()
|
|
||||||
const config = useBookingFlowConfig()
|
const config = useBookingFlowConfig()
|
||||||
|
|
||||||
|
// Create error formatter wrapper that uses getErrorMessage with the variant
|
||||||
|
const errorFormatter = useMemo(
|
||||||
|
() => (intl: IntlShape, errorMessage?: string) =>
|
||||||
|
getErrorMessage(intl, config.variant, errorMessage) ?? "",
|
||||||
|
[config.variant]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<FormInput
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
className={className}
|
||||||
|
description={helpText}
|
||||||
|
descriptionIcon="check"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
control={control}
|
errorFormatter={errorFormatter}
|
||||||
|
hideError={hideError}
|
||||||
|
inputMode={inputMode}
|
||||||
|
label={label}
|
||||||
|
maxLength={maxLength}
|
||||||
name={name}
|
name={name}
|
||||||
rules={registerOptions}
|
placeholder={placeholder}
|
||||||
render={({ field, fieldState }) => (
|
readOnly={readOnly}
|
||||||
<TextField
|
registerOptions={registerOptions}
|
||||||
aria-label={ariaLabel}
|
type={type}
|
||||||
className={className}
|
|
||||||
isDisabled={field.disabled}
|
|
||||||
isInvalid={fieldState.invalid}
|
|
||||||
isRequired={!!registerOptions.required}
|
|
||||||
name={field.name}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
onChange={field.onChange}
|
|
||||||
validationBehavior="aria"
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<InputWithLabel
|
|
||||||
{...field}
|
|
||||||
ref={ref}
|
|
||||||
aria-labelledby={field.name}
|
|
||||||
autoComplete={autoComplete}
|
|
||||||
id={field.name}
|
|
||||||
label={label}
|
|
||||||
maxLength={maxLength}
|
|
||||||
placeholder={placeholder}
|
|
||||||
readOnly={readOnly}
|
|
||||||
required={!!registerOptions.required}
|
|
||||||
type={type}
|
|
||||||
inputMode={inputMode}
|
|
||||||
/>
|
|
||||||
{helpText && !fieldState.error ? (
|
|
||||||
<Caption asChild color="black">
|
|
||||||
<Text className={styles.helpText} slot="description">
|
|
||||||
<MaterialIcon icon="check" size={20} />
|
|
||||||
{helpText}
|
|
||||||
</Text>
|
|
||||||
</Caption>
|
|
||||||
) : null}
|
|
||||||
{fieldState.error && !hideError ? (
|
|
||||||
<ErrorMessage
|
|
||||||
errors={formState.errors}
|
|
||||||
name={name}
|
|
||||||
messageLabel={getErrorMessage(
|
|
||||||
intl,
|
|
||||||
config.variant,
|
|
||||||
fieldState.error.message
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</TextField>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ export default create({
|
|||||||
base: 'dark',
|
base: 'dark',
|
||||||
brandTitle: 'Scandic Design System',
|
brandTitle: 'Scandic Design System',
|
||||||
brandUrl: 'https://www.scandichotels.com/',
|
brandUrl: 'https://www.scandichotels.com/',
|
||||||
brandImage: 'http://scandichotels.com/_static/img/scandic-logotype.png',
|
brandImage: '/img/scandic-logotype.png',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -137,40 +137,37 @@ function ExampleFormComponent({
|
|||||||
|
|
||||||
const meta: Meta<typeof ExampleFormComponent> = {
|
const meta: Meta<typeof ExampleFormComponent> = {
|
||||||
title: 'Compositions/Form/ExampleForm',
|
title: 'Compositions/Form/ExampleForm',
|
||||||
component: ExampleFormComponent,
|
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'padded',
|
layout: 'padded',
|
||||||
},
|
},
|
||||||
|
argTypes: {
|
||||||
|
labelPosition: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['floating', 'top'],
|
||||||
|
description: 'Position of labels for all input fields in the form',
|
||||||
|
table: {
|
||||||
|
type: { summary: "'floating' | 'top'" },
|
||||||
|
defaultValue: { summary: "'floating'" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default meta
|
export default meta
|
||||||
|
|
||||||
type Story = StoryObj<typeof ExampleFormComponent>
|
type Story = StoryObj<typeof ExampleFormComponent>
|
||||||
|
|
||||||
export const LabelFloating: Story = {
|
export const Default: Story = {
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<ExampleFormComponent
|
<ExampleFormComponent
|
||||||
key="label-on-top"
|
key={`label-${args.labelPosition || 'floating'}`}
|
||||||
{...args}
|
{...args}
|
||||||
labelPosition="floating"
|
fieldPrefix="example"
|
||||||
/>
|
|
||||||
),
|
|
||||||
args: {
|
|
||||||
onSubmit: fn(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LabelOnTop: Story = {
|
|
||||||
render: (args) => (
|
|
||||||
<ExampleFormComponent
|
|
||||||
key="label-on-top"
|
|
||||||
{...args}
|
|
||||||
labelPosition="top"
|
|
||||||
fieldPrefix="top"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
args: {
|
args: {
|
||||||
onSubmit: fn(),
|
onSubmit: fn(),
|
||||||
|
labelPosition: 'floating',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,18 +345,6 @@ export const WithErrors: SignupStory = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WithErrorsLabelOnTop: SignupStory = {
|
|
||||||
render: (args) => <SignupFormComponent {...args} />,
|
|
||||||
args: {
|
|
||||||
onSubmit: fn(),
|
|
||||||
labelPosition: 'top',
|
|
||||||
showErrors: true,
|
|
||||||
},
|
|
||||||
parameters: {
|
|
||||||
...signupMeta.parameters,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Input Variations Showcase
|
// Input Variations Showcase
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { cx } from 'class-variance-authority'
|
|||||||
|
|
||||||
import { Error } from '../ErrorMessage/Error'
|
import { Error } from '../ErrorMessage/Error'
|
||||||
import { mergeRefs } from '../utils/mergeRefs'
|
import { mergeRefs } from '../utils/mergeRefs'
|
||||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
import { MaterialIcon, MaterialIconProps } from '../../Icons/MaterialIcon'
|
||||||
import { Input } from '../../InputNew'
|
import { Input } from '../../Input'
|
||||||
|
|
||||||
import styles from './input.module.css'
|
import styles from './input.module.css'
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
|||||||
autoComplete,
|
autoComplete,
|
||||||
className = '',
|
className = '',
|
||||||
description = '',
|
description = '',
|
||||||
|
descriptionIcon = 'info' as MaterialIconProps['icon'],
|
||||||
disabled = false,
|
disabled = false,
|
||||||
errorFormatter,
|
errorFormatter,
|
||||||
hideError,
|
hideError,
|
||||||
@@ -102,7 +103,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
|||||||
/>
|
/>
|
||||||
{showDescription ? (
|
{showDescription ? (
|
||||||
<Text className={styles.description} slot="description">
|
<Text className={styles.description} slot="description">
|
||||||
<MaterialIcon icon="info" size={20} />
|
<MaterialIcon icon={descriptionIcon} size={20} />
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { RegisterOptions } from 'react-hook-form'
|
import type { RegisterOptions } from 'react-hook-form'
|
||||||
import type { IntlShape } from 'react-intl'
|
import type { IntlShape } from 'react-intl'
|
||||||
|
|
||||||
import type { InputProps } from '../../InputNew/types'
|
import type { MaterialIconProps } from '../../Icons/MaterialIcon'
|
||||||
|
import type { InputProps } from '../../Input/types'
|
||||||
|
|
||||||
export interface FormInputProps extends InputProps {
|
export interface FormInputProps extends InputProps {
|
||||||
/** Helper text displayed below the input (hidden when there's an error) */
|
/** Helper text displayed below the input (hidden when there's an error) */
|
||||||
description?: string
|
description?: string
|
||||||
|
/** Icon to display with the description text. Defaults to 'info' */
|
||||||
|
descriptionIcon?: MaterialIconProps['icon']
|
||||||
/** Field name for react-hook-form registration */
|
/** Field name for react-hook-form registration */
|
||||||
name: string
|
name: string
|
||||||
/** react-hook-form validation rules */
|
/** react-hook-form validation rules */
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
|
|
||||||
import { ErrorMessage } from '../ErrorMessage'
|
import { ErrorMessage } from '../ErrorMessage'
|
||||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||||
import { Input } from '../../InputNew'
|
import { Input } from '../../Input'
|
||||||
import { InputLabel } from '../../InputLabel'
|
import { InputLabel } from '../../InputLabel'
|
||||||
|
|
||||||
import styles from './phone.module.css'
|
import styles from './phone.module.css'
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { ComponentProps } from 'react'
|
|||||||
import { variants } from './variants'
|
import { variants } from './variants'
|
||||||
|
|
||||||
interface IconButtonProps
|
interface IconButtonProps
|
||||||
extends ComponentProps<typeof ButtonRAC>,
|
extends ComponentProps<typeof ButtonRAC>, VariantProps<typeof variants> {}
|
||||||
VariantProps<typeof variants> {}
|
|
||||||
|
|
||||||
export function IconButton({
|
export function IconButton({
|
||||||
variant,
|
variant,
|
||||||
|
|||||||
@@ -4,16 +4,133 @@ import { expect } from 'storybook/test'
|
|||||||
|
|
||||||
import { Input } from './Input'
|
import { Input } from './Input'
|
||||||
import { TextField } from 'react-aria-components'
|
import { TextField } from 'react-aria-components'
|
||||||
|
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||||
|
import type { SymbolCodepoints } from '../Icons/MaterialIcon/MaterialSymbol/types'
|
||||||
|
|
||||||
const meta: Meta<typeof Input> = {
|
const meta: Meta<typeof Input> = {
|
||||||
title: 'Core Components/Input',
|
title: 'Core Components/Input',
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||||
component: ({ isInvalid, ...props }) => (
|
component: ({ isInvalid, validationState, ...props }) => (
|
||||||
<TextField isInvalid={isInvalid}>
|
<TextField isInvalid={isInvalid} data-validation-state={validationState}>
|
||||||
<Input {...props} />
|
<Input {...props} data-validation-state={validationState} />
|
||||||
</TextField>
|
</TextField>
|
||||||
),
|
),
|
||||||
argTypes: {},
|
argTypes: {
|
||||||
|
label: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'The label text displayed for the input field',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labelPosition: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['floating', 'top'],
|
||||||
|
description: 'Position of the label relative to the input',
|
||||||
|
table: {
|
||||||
|
type: { summary: "'floating' | 'top'" },
|
||||||
|
defaultValue: { summary: "'floating'" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Placeholder text shown when input is empty',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'string' },
|
||||||
|
defaultValue: { summary: 'undefined' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether the input is required',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'boolean' },
|
||||||
|
defaultValue: { summary: 'false' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether the input is disabled',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'boolean' },
|
||||||
|
defaultValue: { summary: 'false' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showClearContentIcon: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether the clear content icon is shown',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'boolean' },
|
||||||
|
defaultValue: { summary: 'false' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showLeftIcon: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether to show a left icon',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'boolean' },
|
||||||
|
defaultValue: { summary: 'false' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showRightIcon: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether to show a right icon',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'boolean' },
|
||||||
|
defaultValue: { summary: 'false' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
leftIconName: {
|
||||||
|
control: 'select',
|
||||||
|
options: [
|
||||||
|
'calendar_month',
|
||||||
|
'credit_card',
|
||||||
|
'email',
|
||||||
|
'info_circle',
|
||||||
|
'location_on',
|
||||||
|
'lock',
|
||||||
|
'phone',
|
||||||
|
'search',
|
||||||
|
'sell',
|
||||||
|
'visibility',
|
||||||
|
'visibility_off',
|
||||||
|
],
|
||||||
|
description: 'Icon name for the left icon',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'string' },
|
||||||
|
defaultValue: { summary: "'person'" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rightIconName: {
|
||||||
|
control: 'select',
|
||||||
|
options: [
|
||||||
|
'calendar_month',
|
||||||
|
'credit_card',
|
||||||
|
'email',
|
||||||
|
'info_circle',
|
||||||
|
'location_on',
|
||||||
|
'lock',
|
||||||
|
'phone',
|
||||||
|
'search',
|
||||||
|
'sell',
|
||||||
|
'visibility',
|
||||||
|
'visibility_off',
|
||||||
|
],
|
||||||
|
description: 'Icon name for the right icon',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'string' },
|
||||||
|
defaultValue: { summary: "'lock'" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showWarning: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether to show warning validation state',
|
||||||
|
table: {
|
||||||
|
type: { summary: 'boolean' },
|
||||||
|
defaultValue: { summary: 'false' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default meta
|
export default meta
|
||||||
@@ -25,8 +142,50 @@ export const Default: Story = {
|
|||||||
label: 'Label',
|
label: 'Label',
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
required: false,
|
required: false,
|
||||||
},
|
showLeftIcon: false,
|
||||||
|
showRightIcon: false,
|
||||||
|
leftIconName: 'person',
|
||||||
|
rightIconName: 'lock',
|
||||||
|
showWarning: false,
|
||||||
|
} as any,
|
||||||
|
render: (args) => {
|
||||||
|
// Extract custom Storybook args
|
||||||
|
const {
|
||||||
|
showLeftIcon,
|
||||||
|
showRightIcon,
|
||||||
|
leftIconName,
|
||||||
|
rightIconName,
|
||||||
|
showWarning,
|
||||||
|
...inputProps
|
||||||
|
} = args as typeof args & {
|
||||||
|
showLeftIcon?: boolean
|
||||||
|
showRightIcon?: boolean
|
||||||
|
leftIconName?: string
|
||||||
|
rightIconName?: string
|
||||||
|
showWarning?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationState = showWarning ? 'warning' : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField data-validation-state={validationState}>
|
||||||
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
data-validation-state={validationState}
|
||||||
|
leftIcon={
|
||||||
|
showLeftIcon && leftIconName ? (
|
||||||
|
<MaterialIcon icon={leftIconName as SymbolCodepoints} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
rightIcon={
|
||||||
|
showRightIcon && rightIconName ? (
|
||||||
|
<MaterialIcon icon={rightIconName as SymbolCodepoints} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
},
|
||||||
play: async ({ canvas, userEvent }) => {
|
play: async ({ canvas, userEvent }) => {
|
||||||
const textbox = canvas.getByRole('textbox')
|
const textbox = canvas.getByRole('textbox')
|
||||||
expect(textbox).not.toBeDisabled()
|
expect(textbox).not.toBeDisabled()
|
||||||
@@ -40,68 +199,3 @@ export const Default: Story = {
|
|||||||
expect(textbox).toHaveValue('')
|
expect(textbox).toHaveValue('')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Filled: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
value: 'Value',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Error: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
isInvalid: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Disabled: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas, userEvent }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
expect(textbox).toBeDisabled()
|
|
||||||
|
|
||||||
await userEvent.type(textbox, 'Hello World')
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DisabledFilled: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
disabled: true,
|
|
||||||
value: 'Value',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas, userEvent }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
expect(textbox).toBeDisabled()
|
|
||||||
|
|
||||||
await userEvent.type(textbox, 'Hello World')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,36 +12,165 @@ import { InputLabel } from '../InputLabel'
|
|||||||
|
|
||||||
import styles from './input.module.css'
|
import styles from './input.module.css'
|
||||||
|
|
||||||
import type { InputProps } from './types'
|
import { IconButton } from '../IconButton'
|
||||||
|
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||||
import { Typography } from '../Typography'
|
import { Typography } from '../Typography'
|
||||||
|
import type { InputProps } from './types'
|
||||||
|
import { clearInput, useInputHasValue } from './utils'
|
||||||
|
|
||||||
const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
||||||
{ label, ...props }: InputProps,
|
{
|
||||||
forwardedRef: ForwardedRef<HTMLInputElement>
|
label,
|
||||||
|
labelPosition = 'floating',
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
onRightIconClick,
|
||||||
|
showClearContentIcon,
|
||||||
|
placeholder,
|
||||||
|
id,
|
||||||
|
required,
|
||||||
|
'data-validation-state': validationState,
|
||||||
|
...props
|
||||||
|
}: InputProps & { 'data-validation-state'?: string },
|
||||||
|
ref: ForwardedRef<HTMLInputElement>
|
||||||
) {
|
) {
|
||||||
const ref = useRef<HTMLInputElement>(null)
|
// Create an internal ref that we can access
|
||||||
|
const internalRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Unique id is required for multiple inputs of same name appearing multiple times
|
// Generate a unique ID for the input
|
||||||
// on same page. This will inherited by parent label element.
|
// This is used to ensure the input is properly associated with the label
|
||||||
// Shouldn't really be needed if we don't set id though.
|
// when the label is positioned above the input
|
||||||
const uniqueId = useId()
|
const generatedId = useId()
|
||||||
const inputId = `${uniqueId}-${props.name}`
|
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ref.current as HTMLInputElement)
|
// Forward the ref properly
|
||||||
|
useImperativeHandle(ref, () => internalRef.current!, [])
|
||||||
|
|
||||||
|
// Track whether input has a value (for showing/hiding clear button)
|
||||||
|
const hasValue = useInputHasValue(props.value, internalRef)
|
||||||
|
|
||||||
|
const onClearContent = () => {
|
||||||
|
clearInput({
|
||||||
|
inputRef: internalRef,
|
||||||
|
onChange: props.onChange,
|
||||||
|
value: props.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// When labelPosition is 'top', restructure to have label outside container
|
||||||
|
// We need an ID for proper label-input association
|
||||||
|
if (labelPosition === 'top') {
|
||||||
|
const inputId = id || generatedId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InputLabel
|
||||||
|
required={required}
|
||||||
|
className={cx(
|
||||||
|
styles.labelAbove,
|
||||||
|
leftIcon && styles.labelAboveWithLeftIcon
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</InputLabel>
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
{leftIcon && (
|
||||||
|
<div className={styles.leftIconContainer}>{leftIcon}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className={cx(
|
||||||
|
styles.container,
|
||||||
|
leftIcon && styles.containerWithLeftIcon,
|
||||||
|
rightIcon && styles.containerWithRightIcon
|
||||||
|
)}
|
||||||
|
data-validation-state={validationState}
|
||||||
|
>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<AriaInput
|
||||||
|
{...props}
|
||||||
|
id={inputId}
|
||||||
|
required={required}
|
||||||
|
// Avoid duplicating label text in placeholder when label is positioned above
|
||||||
|
// Screen readers would announce the label twice (once as label, once as placeholder)
|
||||||
|
// Only use placeholder if explicitly provided, otherwise use empty string
|
||||||
|
placeholder={placeholder ?? ''}
|
||||||
|
className={cx(
|
||||||
|
styles.input,
|
||||||
|
styles.inputTopLabel,
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
ref={internalRef}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
</label>
|
||||||
|
{showClearContentIcon && hasValue && (
|
||||||
|
<div className={styles.rightIconContainer}>
|
||||||
|
<IconButton
|
||||||
|
className={styles.rightIconButton}
|
||||||
|
variant="Muted"
|
||||||
|
emphasis
|
||||||
|
onPress={onClearContent}
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
aria-label="Clear content"
|
||||||
|
>
|
||||||
|
<MaterialIcon icon="cancel" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||||
|
<div className={styles.rightIconContainer}>{rightIcon}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating label (default behavior) - label inside container
|
||||||
return (
|
return (
|
||||||
<AriaLabel className={styles.container}>
|
<div className={styles.inputContainer}>
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
{leftIcon && <div className={styles.leftIconContainer}>{leftIcon}</div>}
|
||||||
<AriaInput
|
<AriaLabel
|
||||||
{...props}
|
className={cx(
|
||||||
placeholder={props.placeholder}
|
styles.container,
|
||||||
className={cx(styles.input, props.className)}
|
leftIcon && styles.containerWithLeftIcon,
|
||||||
ref={ref}
|
rightIcon && styles.containerWithRightIcon
|
||||||
id={inputId}
|
)}
|
||||||
/>
|
data-validation-state={validationState}
|
||||||
</Typography>
|
>
|
||||||
<InputLabel required={props.required}>{label}</InputLabel>
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
</AriaLabel>
|
<AriaInput
|
||||||
|
{...props}
|
||||||
|
id={id}
|
||||||
|
required={required}
|
||||||
|
// For floating labels, only set placeholder if explicitly provided
|
||||||
|
// The label itself acts as the placeholder, so we don't want to duplicate it
|
||||||
|
// This ensures the label only floats when focused or has value
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cx(styles.input, props.className)}
|
||||||
|
ref={internalRef}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
<InputLabel required={required}>{label}</InputLabel>
|
||||||
|
</AriaLabel>
|
||||||
|
{showClearContentIcon && hasValue && (
|
||||||
|
<div className={styles.rightIconContainer}>
|
||||||
|
<IconButton
|
||||||
|
className={styles.rightIconButton}
|
||||||
|
variant="Muted"
|
||||||
|
emphasis
|
||||||
|
onPress={onClearContent}
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
aria-label="Clear content"
|
||||||
|
>
|
||||||
|
<MaterialIcon icon="cancel" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||||
|
<div className={styles.rightIconContainer}>{rightIcon}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
/* Label positioned above input (outside container) */
|
||||||
|
.labelAbove {
|
||||||
|
color: var(--Text-Default);
|
||||||
|
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
||||||
|
font-size: var(--Body-Supporting-text-Size);
|
||||||
|
font-weight: var(--Body-Supporting-text-Font-weight-2);
|
||||||
|
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
align-content: center;
|
align-content: center;
|
||||||
background-color: var(--Surface-Primary-Default);
|
background-color: var(--Surface-Primary-Default);
|
||||||
@@ -10,8 +20,13 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
|
|
||||||
.container:has(.input:focus):not(:has(.input:disabled)),
|
&:has(.input:focus):not(:has(.input:disabled)):not(
|
||||||
.container:has(.input:focus):not(:has(.input:read-only)) {
|
:has(.input:read-only)
|
||||||
|
):not(:has(.input[data-invalid='true'])):not(
|
||||||
|
:has(.input[aria-invalid='true'])
|
||||||
|
):not(:has(.input[data-warning='true'])):not(
|
||||||
|
:has(.input[data-validation-state='warning'])
|
||||||
|
):not([data-validation-state='warning']) {
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
outline: 2px solid var(--Border-Interactive-Focus);
|
outline: 2px solid var(--Border-Interactive-Focus);
|
||||||
}
|
}
|
||||||
@@ -26,11 +41,35 @@
|
|||||||
&:has(.input[data-invalid='true'], .input[aria-invalid='true']) {
|
&:has(.input[data-invalid='true'], .input[aria-invalid='true']) {
|
||||||
border-color: var(--Border-Interactive-Error);
|
border-color: var(--Border-Interactive-Error);
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within,
|
||||||
|
&:has(.input:focus) {
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
outline: 2px solid var(--Border-Interactive-Error);
|
outline: 2px solid var(--Border-Interactive-Error);
|
||||||
|
border-color: var(--Border-Interactive-Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:has(.input[data-warning='true']),
|
||||||
|
&:has(.input[data-validation-state='warning']),
|
||||||
|
&[data-validation-state='warning'] {
|
||||||
|
background-color: var(--Surface-Feedback-Warning-light);
|
||||||
|
border-color: var(--Border-Interactive-Focus);
|
||||||
|
|
||||||
|
&:focus-within,
|
||||||
|
&:has(.input:focus) {
|
||||||
|
outline-offset: -2px;
|
||||||
|
outline: 2px solid var(--Border-Interactive-Focus);
|
||||||
|
border-color: var(--Border-Interactive-Focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerWithLeftIcon {
|
||||||
|
padding-left: calc(var(--Space-x5) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerWithRightIcon {
|
||||||
|
padding-right: calc(var(--Space-x5));
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
@@ -41,6 +80,7 @@
|
|||||||
order: 2;
|
order: 2;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: height 150ms ease;
|
transition: height 150ms ease;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:placeholder-shown,
|
&:placeholder-shown,
|
||||||
@@ -56,6 +96,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Input with label on top - always has proper height */
|
||||||
|
.inputTopLabel {
|
||||||
|
height: 24px;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputContainer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftIconContainer {
|
||||||
|
position: absolute;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto 0 auto var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightIconContainer {
|
||||||
|
position: absolute;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: auto var(--Space-x15) auto 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightIconButton {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.input:active:not(:disabled) {
|
.input:active:not(:disabled) {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|||||||
@@ -3,4 +3,9 @@ import { Input } from 'react-aria-components'
|
|||||||
|
|
||||||
export interface InputProps extends ComponentProps<typeof Input> {
|
export interface InputProps extends ComponentProps<typeof Input> {
|
||||||
label: string
|
label: string
|
||||||
|
labelPosition?: 'floating' | 'top'
|
||||||
|
leftIcon?: React.ReactNode
|
||||||
|
rightIcon?: React.ReactNode
|
||||||
|
onRightIconClick?: () => void
|
||||||
|
showClearContentIcon?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function InputLabel({
|
|||||||
required,
|
required,
|
||||||
disabled,
|
disabled,
|
||||||
size,
|
size,
|
||||||
|
...rest
|
||||||
}: InputLabelProps) {
|
}: InputLabelProps) {
|
||||||
const classNames = inputLabelVariants({
|
const classNames = inputLabelVariants({
|
||||||
size,
|
size,
|
||||||
@@ -18,5 +19,10 @@ export function InputLabel({
|
|||||||
className,
|
className,
|
||||||
})
|
})
|
||||||
|
|
||||||
return <span className={classNames}>{children}</span>
|
return (
|
||||||
|
<span className={classNames} {...rest}>
|
||||||
|
{children}
|
||||||
|
{required && <span aria-hidden="true">{' *'}</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,6 @@
|
|||||||
order: unset;
|
order: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.required:after {
|
|
||||||
content: ' *';
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus ~ .inputLabel,
|
input:focus ~ .inputLabel,
|
||||||
input:placeholder-shown ~ .inputLabel,
|
input:placeholder-shown ~ .inputLabel,
|
||||||
input[value]:not([value='']) ~ .inputLabel,
|
input[value]:not([value='']) ~ .inputLabel,
|
||||||
|
|||||||
@@ -1,416 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
|
||||||
|
|
||||||
import { expect } from 'storybook/test'
|
|
||||||
|
|
||||||
import { Input } from './Input'
|
|
||||||
import { TextField } from 'react-aria-components'
|
|
||||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
|
||||||
|
|
||||||
const meta: Meta<typeof Input> = {
|
|
||||||
title: 'Core Components/Input (New)',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
component: ({ isInvalid, validationState, ...props }) => (
|
|
||||||
<TextField isInvalid={isInvalid} data-validation-state={validationState}>
|
|
||||||
<Input {...props} data-validation-state={validationState} />
|
|
||||||
</TextField>
|
|
||||||
),
|
|
||||||
argTypes: {
|
|
||||||
label: {
|
|
||||||
control: 'text',
|
|
||||||
description: 'The label text displayed for the input field',
|
|
||||||
table: {
|
|
||||||
type: { summary: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
labelPosition: {
|
|
||||||
control: 'select',
|
|
||||||
options: ['floating', 'top'],
|
|
||||||
description: 'Position of the label relative to the input',
|
|
||||||
table: {
|
|
||||||
type: { summary: "'floating' | 'top'" },
|
|
||||||
defaultValue: { summary: "'floating'" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
control: 'text',
|
|
||||||
description: 'Placeholder text shown when input is empty',
|
|
||||||
table: {
|
|
||||||
type: { summary: 'string' },
|
|
||||||
defaultValue: { summary: 'undefined' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: {
|
|
||||||
control: 'boolean',
|
|
||||||
description: 'Whether the input is required',
|
|
||||||
table: {
|
|
||||||
type: { summary: 'boolean' },
|
|
||||||
defaultValue: { summary: 'false' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
control: 'boolean',
|
|
||||||
description: 'Whether the input is disabled',
|
|
||||||
table: {
|
|
||||||
type: { summary: 'boolean' },
|
|
||||||
defaultValue: { summary: 'false' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
showClearContentIcon: {
|
|
||||||
control: 'boolean',
|
|
||||||
description: 'Whether the clear content icon is shown',
|
|
||||||
table: {
|
|
||||||
type: { summary: 'boolean' },
|
|
||||||
defaultValue: { summary: 'false' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default meta
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof Input>
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas, userEvent }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
|
|
||||||
await userEvent.type(textbox, 'Hello World')
|
|
||||||
expect(textbox).toHaveValue('Hello World')
|
|
||||||
|
|
||||||
await userEvent.clear(textbox)
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WithIconsFloatingLabel: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
value: 'Value',
|
|
||||||
leftIcon: <MaterialIcon icon="sell" />,
|
|
||||||
rightIcon: <MaterialIcon icon="lock" />,
|
|
||||||
showClearContentIcon: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WithIconsTopLabel: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
value: 'Value',
|
|
||||||
labelPosition: 'top',
|
|
||||||
leftIcon: <MaterialIcon icon="email" />,
|
|
||||||
showClearContentIcon: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WithIconsAndClearIconTopLabel: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
value: 'Value',
|
|
||||||
labelPosition: 'top',
|
|
||||||
leftIcon: <MaterialIcon icon="person" />,
|
|
||||||
rightIcon: <MaterialIcon icon="email" />,
|
|
||||||
showClearContentIcon: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Filled: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
value: 'Value',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Error: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
isInvalid: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ErrorFilled: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
isInvalid: true,
|
|
||||||
value: 'Value',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Disabled: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas, userEvent }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
expect(textbox).toBeDisabled()
|
|
||||||
|
|
||||||
await userEvent.type(textbox, 'Hello World')
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DisabledFilled: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
disabled: true,
|
|
||||||
value: 'Value',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas, userEvent }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
expect(textbox).toBeDisabled()
|
|
||||||
|
|
||||||
await userEvent.type(textbox, 'Hello World')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WarningDefault: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
validationState: 'warning',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
// data-validation-state is on the parent label element, not the input
|
|
||||||
const container = textbox.closest('[data-validation-state]')
|
|
||||||
expect(container?.getAttribute('data-validation-state')).toBe('warning')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WarningFilled: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
validationState: 'warning',
|
|
||||||
value: 'Value',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
// data-validation-state is on the parent label element, not the input
|
|
||||||
const container = textbox.closest('[data-validation-state]')
|
|
||||||
expect(container?.getAttribute('data-validation-state')).toBe('warning')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultTop: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
placeholder: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
required: false,
|
|
||||||
labelPosition: 'top',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas, userEvent }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
|
|
||||||
await userEvent.type(textbox, 'Hello World')
|
|
||||||
expect(textbox).toHaveValue('Hello World')
|
|
||||||
|
|
||||||
await userEvent.clear(textbox)
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FilledTop: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
value: 'Value',
|
|
||||||
labelPosition: 'top',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ErrorTop: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
isInvalid: true,
|
|
||||||
placeholder: 'Label',
|
|
||||||
labelPosition: 'top',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ErrorFilledTop: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
isInvalid: true,
|
|
||||||
value: 'Value',
|
|
||||||
labelPosition: 'top',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DisabledTop: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
disabled: true,
|
|
||||||
labelPosition: 'top',
|
|
||||||
placeholder: 'Label',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas, userEvent }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
expect(textbox).toBeDisabled()
|
|
||||||
|
|
||||||
await userEvent.type(textbox, 'Hello World')
|
|
||||||
expect(textbox).toHaveValue('')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DisabledFilledTop: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
disabled: true,
|
|
||||||
value: 'Value',
|
|
||||||
labelPosition: 'top',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas, userEvent }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
expect(textbox).toBeDisabled()
|
|
||||||
|
|
||||||
await userEvent.type(textbox, 'Hello World')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WarningDefaultTop: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
validationState: 'warning',
|
|
||||||
labelPosition: 'top',
|
|
||||||
placeholder: 'Label',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
// data-validation-state is on the parent label element, not the input
|
|
||||||
const container = textbox.closest('[data-validation-state]')
|
|
||||||
expect(container?.getAttribute('data-validation-state')).toBe('warning')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WarningFilledTop: Story = {
|
|
||||||
args: {
|
|
||||||
label: 'Label',
|
|
||||||
name: 'foo',
|
|
||||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
|
||||||
validationState: 'warning',
|
|
||||||
value: 'Value',
|
|
||||||
labelPosition: 'top',
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({ canvas }) => {
|
|
||||||
const textbox = canvas.getByRole('textbox')
|
|
||||||
expect(textbox).toHaveValue('Value')
|
|
||||||
// data-validation-state is on the parent label element, not the input
|
|
||||||
const container = textbox.closest('[data-validation-state]')
|
|
||||||
expect(container?.getAttribute('data-validation-state')).toBe('warning')
|
|
||||||
expect(textbox).not.toBeDisabled()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { cx } from 'class-variance-authority'
|
|
||||||
import {
|
|
||||||
type ForwardedRef,
|
|
||||||
forwardRef,
|
|
||||||
useId,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
} from 'react'
|
|
||||||
import { Input as AriaInput, Label as AriaLabel } from 'react-aria-components'
|
|
||||||
|
|
||||||
import { InputLabel } from '../InputLabel'
|
|
||||||
|
|
||||||
import styles from './input.module.css'
|
|
||||||
|
|
||||||
import { IconButton } from '../IconButton'
|
|
||||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
|
||||||
import { Typography } from '../Typography'
|
|
||||||
import type { InputProps } from './types'
|
|
||||||
import { clearInput, useInputHasValue } from './utils'
|
|
||||||
|
|
||||||
const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
|
||||||
{
|
|
||||||
label,
|
|
||||||
labelPosition = 'floating',
|
|
||||||
leftIcon,
|
|
||||||
rightIcon,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
onRightIconClick,
|
|
||||||
showClearContentIcon,
|
|
||||||
placeholder,
|
|
||||||
id,
|
|
||||||
required,
|
|
||||||
'data-validation-state': validationState,
|
|
||||||
...props
|
|
||||||
}: InputProps & { 'data-validation-state'?: string },
|
|
||||||
ref: ForwardedRef<HTMLInputElement>
|
|
||||||
) {
|
|
||||||
// Create an internal ref that we can access
|
|
||||||
const internalRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
// Generate a unique ID for the input
|
|
||||||
// This is used to ensure the input is properly associated with the label
|
|
||||||
// when the label is positioned above the input
|
|
||||||
const generatedId = useId()
|
|
||||||
|
|
||||||
// Forward the ref properly
|
|
||||||
useImperativeHandle(ref, () => internalRef.current!, [])
|
|
||||||
|
|
||||||
// Track whether input has a value (for showing/hiding clear button)
|
|
||||||
const hasValue = useInputHasValue(props.value, internalRef)
|
|
||||||
|
|
||||||
const onClearContent = () => {
|
|
||||||
clearInput({
|
|
||||||
inputRef: internalRef,
|
|
||||||
onChange: props.onChange,
|
|
||||||
value: props.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// When labelPosition is 'top', restructure to have label outside container
|
|
||||||
// We need an ID for proper label-input association
|
|
||||||
if (labelPosition === 'top') {
|
|
||||||
const inputId = id || generatedId
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<InputLabel
|
|
||||||
required={required}
|
|
||||||
className={cx(
|
|
||||||
styles.labelAbove,
|
|
||||||
leftIcon && styles.labelAboveWithLeftIcon
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</InputLabel>
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
{leftIcon && (
|
|
||||||
<div className={styles.leftIconContainer}>{leftIcon}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label
|
|
||||||
htmlFor={inputId}
|
|
||||||
className={cx(
|
|
||||||
styles.container,
|
|
||||||
leftIcon && styles.containerWithLeftIcon,
|
|
||||||
rightIcon && styles.containerWithRightIcon
|
|
||||||
)}
|
|
||||||
data-validation-state={validationState}
|
|
||||||
>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<AriaInput
|
|
||||||
{...props}
|
|
||||||
id={inputId}
|
|
||||||
required={required}
|
|
||||||
// Avoid duplicating label text in placeholder when label is positioned above
|
|
||||||
// Screen readers would announce the label twice (once as label, once as placeholder)
|
|
||||||
// Only use placeholder if explicitly provided, otherwise use empty string
|
|
||||||
placeholder={placeholder ?? ''}
|
|
||||||
className={cx(
|
|
||||||
styles.input,
|
|
||||||
styles.inputTopLabel,
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
ref={internalRef}
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
</label>
|
|
||||||
{showClearContentIcon && hasValue && (
|
|
||||||
<div className={styles.rightIconContainer}>
|
|
||||||
<IconButton
|
|
||||||
className={styles.rightIconButton}
|
|
||||||
variant="Muted"
|
|
||||||
emphasis
|
|
||||||
onPress={onClearContent}
|
|
||||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
|
||||||
aria-label="Clear content"
|
|
||||||
>
|
|
||||||
<MaterialIcon icon="cancel" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
|
||||||
<div className={styles.rightIconContainer}>{rightIcon}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floating label (default behavior) - label inside container
|
|
||||||
return (
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
{leftIcon && <div className={styles.leftIconContainer}>{leftIcon}</div>}
|
|
||||||
<AriaLabel
|
|
||||||
className={cx(
|
|
||||||
styles.container,
|
|
||||||
leftIcon && styles.containerWithLeftIcon,
|
|
||||||
rightIcon && styles.containerWithRightIcon
|
|
||||||
)}
|
|
||||||
data-validation-state={validationState}
|
|
||||||
>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<AriaInput
|
|
||||||
{...props}
|
|
||||||
id={id}
|
|
||||||
required={required}
|
|
||||||
// For floating labels, only set placeholder if explicitly provided
|
|
||||||
// The label itself acts as the placeholder, so we don't want to duplicate it
|
|
||||||
// This ensures the label only floats when focused or has value
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={cx(styles.input, props.className)}
|
|
||||||
ref={internalRef}
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
<InputLabel required={required}>{label}</InputLabel>
|
|
||||||
</AriaLabel>
|
|
||||||
{showClearContentIcon && hasValue && (
|
|
||||||
<div className={styles.rightIconContainer}>
|
|
||||||
<IconButton
|
|
||||||
className={styles.rightIconButton}
|
|
||||||
variant="Muted"
|
|
||||||
emphasis
|
|
||||||
onPress={onClearContent}
|
|
||||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
|
||||||
aria-label="Clear content"
|
|
||||||
>
|
|
||||||
<MaterialIcon icon="cancel" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
|
||||||
<div className={styles.rightIconContainer}>{rightIcon}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Input = InputComponent as React.ForwardRefExoticComponent<
|
|
||||||
InputProps & React.RefAttributes<HTMLInputElement>
|
|
||||||
>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { Input } from './Input'
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/* Label positioned above input (outside container) */
|
|
||||||
.labelAbove {
|
|
||||||
color: var(--Text-Default);
|
|
||||||
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
|
||||||
font-size: var(--Body-Supporting-text-Size);
|
|
||||||
font-weight: var(--Body-Supporting-text-Font-weight-2);
|
|
||||||
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
align-content: center;
|
|
||||||
background-color: var(--Surface-Primary-Default);
|
|
||||||
border: 1px solid var(--Border-Interactive-Default);
|
|
||||||
border-radius: var(--Corner-radius-md);
|
|
||||||
display: grid;
|
|
||||||
min-width: 0; /* allow shrinkage */
|
|
||||||
height: 56px;
|
|
||||||
padding: 0 var(--Space-x15);
|
|
||||||
box-sizing: border-box;
|
|
||||||
cursor: text;
|
|
||||||
margin-top: var(--Space-x1);
|
|
||||||
|
|
||||||
&:has(.input:focus):not(:has(.input:disabled)):not(
|
|
||||||
:has(.input:read-only)
|
|
||||||
):not(:has(.input[data-invalid='true'])):not(
|
|
||||||
:has(.input[aria-invalid='true'])
|
|
||||||
):not(:has(.input[data-warning='true'])):not(
|
|
||||||
:has(.input[data-validation-state='warning'])
|
|
||||||
):not([data-validation-state='warning']) {
|
|
||||||
outline-offset: -2px;
|
|
||||||
outline: 2px solid var(--Border-Interactive-Focus);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(.input:disabled),
|
|
||||||
&:has(.input:read-only) {
|
|
||||||
background-color: var(--Surface-Primary-Disabled);
|
|
||||||
border: transparent;
|
|
||||||
cursor: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(.input[data-invalid='true'], .input[aria-invalid='true']) {
|
|
||||||
border-color: var(--Border-Interactive-Error);
|
|
||||||
|
|
||||||
&:focus-within,
|
|
||||||
&:has(.input:focus) {
|
|
||||||
outline-offset: -2px;
|
|
||||||
outline: 2px solid var(--Border-Interactive-Error);
|
|
||||||
border-color: var(--Border-Interactive-Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(.input[data-warning='true']),
|
|
||||||
&:has(.input[data-validation-state='warning']),
|
|
||||||
&[data-validation-state='warning'] {
|
|
||||||
background-color: var(--Surface-Feedback-Warning-light);
|
|
||||||
border-color: var(--Border-Interactive-Focus);
|
|
||||||
|
|
||||||
&:focus-within,
|
|
||||||
&:has(.input:focus) {
|
|
||||||
outline-offset: -2px;
|
|
||||||
outline: 2px solid var(--Border-Interactive-Focus);
|
|
||||||
border-color: var(--Border-Interactive-Focus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.containerWithLeftIcon {
|
|
||||||
padding-left: calc(var(--Space-x5) + 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.containerWithRightIcon {
|
|
||||||
padding-right: calc(var(--Space-x5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--Text-Default);
|
|
||||||
height: 1px;
|
|
||||||
order: 2;
|
|
||||||
padding: 0;
|
|
||||||
transition: height 150ms ease;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&:placeholder-shown,
|
|
||||||
&[value]:not([value='']) {
|
|
||||||
height: 24px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled,
|
|
||||||
&:read-only {
|
|
||||||
color: var(--Text-Interactive-Disabled);
|
|
||||||
cursor: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input with label on top - always has proper height */
|
|
||||||
.inputTopLabel {
|
|
||||||
height: 24px;
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputContainer {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftIconContainer {
|
|
||||||
position: absolute;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin: auto 0 auto var(--Space-x15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rightIconContainer {
|
|
||||||
position: absolute;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
margin: auto var(--Space-x15) auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rightIconButton {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.input:active:not(:disabled) {
|
|
||||||
height: 24px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { ComponentProps } from 'react'
|
|
||||||
import { Input } from 'react-aria-components'
|
|
||||||
|
|
||||||
export interface InputProps extends ComponentProps<typeof Input> {
|
|
||||||
label: string
|
|
||||||
labelPosition?: 'floating' | 'top'
|
|
||||||
leftIcon?: React.ReactNode
|
|
||||||
rightIcon?: React.ReactNode
|
|
||||||
onRightIconClick?: () => void
|
|
||||||
showClearContentIcon?: boolean
|
|
||||||
}
|
|
||||||
BIN
packages/design-system/public/img/scandic-logotype.png
Normal file
BIN
packages/design-system/public/img/scandic-logotype.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Reference in New Issue
Block a user