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:
Rasmus Langvad
2025-12-18 15:42:09 +00:00
parent 40e1efa81f
commit b9a62b5280
34 changed files with 520 additions and 1113 deletions

View File

@@ -15,9 +15,9 @@ import {
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
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 { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -87,7 +87,8 @@ export function TransferPointsFormClient({
</I18nProvider>
<div className={styles.inputsWrapper}>
<TextField type="number" isDisabled={disabled}>
<Input
<FormInput
name="ebPointsToExchange"
label={intl.formatMessage({
id: "partnerSas.ebPointsToExchange",
defaultMessage: "EB points to exchange",

View File

@@ -6,17 +6,20 @@ import { getDefaultCountryFromLang } from "@scandic-hotels/common/utils/phone"
import { Divider } from "@scandic-hotels/design-system/Divider"
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
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 { FormSelect } from "@scandic-hotels/design-system/Form/Select"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getLocalizedLanguageOptions } from "@/constants/languages"
import Input from "@/components/TempDesignSystem/Form/Input"
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
import useLang from "@/hooks/useLang"
import { getFormattedCountryList } from "@/utils/countries"
import { getErrorMessage } from "@/utils/getErrorMessage"
import {
formatFormErrorMessage,
getErrorMessage,
} from "@/utils/getErrorMessage"
import styles from "./formContent.module.css"
@@ -63,7 +66,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
name="dateOfBirth"
registerOptions={{ required: true }}
/>
<Input
<FormInput
data-hj-suppress
label={`${intl.formatMessage({
id: "common.address",
@@ -71,7 +74,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
})} 1`}
name="address.streetAddress"
/>
<Input
<FormInput
data-hj-suppress
label={intl.formatMessage({
id: "common.city",
@@ -80,12 +83,13 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
name="address.city"
/>
<div className={styles.container}>
<Input
<FormInput
data-hj-suppress
label={intl.formatMessage({
id: "common.zipCode",
defaultMessage: "Zip code",
})}
errorFormatter={formatFormErrorMessage}
name="address.zipCode"
registerOptions={{ required: true }}
/>
@@ -105,7 +109,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) {
registerOptions={{ required: true }}
/>
</div>
<Input
<FormInput
label={intl.formatMessage({
id: "common.emailAddress",
defaultMessage: "Email address",

View File

@@ -15,6 +15,7 @@ import { Button } from "@scandic-hotels/design-system/Button"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
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 { TextLink } from "@scandic-hotels/design-system/TextLink"
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
@@ -29,11 +30,13 @@ import {
} from "@scandic-hotels/trpc/routers/user/schemas"
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
import Input from "@/components/TempDesignSystem/Form/Input"
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
import useLang from "@/hooks/useLang"
import { getFormattedCountryList } from "@/utils/countries"
import { getErrorMessage } from "@/utils/getErrorMessage"
import {
formatFormErrorMessage,
getErrorMessage,
} from "@/utils/getErrorMessage"
import { requestOpen } from "@/utils/profilingConsent"
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
@@ -162,7 +165,8 @@ export default function SignupForm({
</Typography>
</header>
<div className={styles.nameInputs}>
<Input
<FormInput
errorFormatter={formatFormErrorMessage}
label={intl.formatMessage({
id: "common.firstName",
defaultMessage: "First name",
@@ -170,7 +174,8 @@ export default function SignupForm({
name="firstName"
registerOptions={{ required: true }}
/>
<Input
<FormInput
errorFormatter={formatFormErrorMessage}
label={intl.formatMessage({
id: "common.lastName",
defaultMessage: "Last name",
@@ -214,7 +219,8 @@ export default function SignupForm({
/>
</div>
<div className={cx(styles.container, styles.additional)}>
<Input
<FormInput
errorFormatter={formatFormErrorMessage}
label={intl.formatMessage({
id: "common.zipCode",
defaultMessage: "Zip code",
@@ -236,7 +242,8 @@ export default function SignupForm({
name="address.countryCode"
registerOptions={{ required: true }}
/>
<Input
<FormInput
errorFormatter={formatFormErrorMessage}
label={intl.formatMessage({
id: "common.emailAddress",
defaultMessage: "Email address",

View File

@@ -6,8 +6,9 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
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 {
type AdditionalInfoFormSchema,
@@ -53,15 +54,16 @@ export default function AdditionalInfoForm({
<form onSubmit={form.handleSubmit(onSubmit)} className={styles.form}>
<Title isAdditional />
<div className={styles.inputs}>
<Input
<FormInput
label={intl.formatMessage({
id: "common.firstName",
defaultMessage: "First name",
})}
name="firstName"
registerOptions={{ required: true }}
errorFormatter={formatFormErrorMessage}
/>
<Input
<FormInput
label={intl.formatMessage({
id: "common.email",
defaultMessage: "Email",
@@ -69,6 +71,7 @@ export default function AdditionalInfoForm({
name="email"
type="email"
registerOptions={{ required: true }}
errorFormatter={formatFormErrorMessage}
/>
</div>
<div className={styles.buttons}>

View File

@@ -11,13 +11,14 @@ import { myStay } from "@scandic-hotels/common/constants/routes/myStay"
import { logger } from "@scandic-hotels/common/logger"
import { Alert } from "@scandic-hotels/design-system/Alert"
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 { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import Input from "@/components/TempDesignSystem/Form/Input"
import useLang from "@/hooks/useLang"
import { formatFormErrorMessage } from "@/utils/getErrorMessage"
import { type FindMyBookingFormSchema, findMyBookingFormSchema } from "./schema"
import { Title } from "./Title"
@@ -95,31 +96,34 @@ export default function FindMyBooking({
/>
) : null}
<div className={[styles.inputs, styles.grid].join(" ")}>
<Input
<FormInput
label={intl.formatMessage({
id: "common.bookingNumber",
defaultMessage: "Booking number",
})}
name="confirmationNumber"
registerOptions={{ required: true }}
errorFormatter={formatFormErrorMessage}
/>
<Input
<FormInput
label={intl.formatMessage({
id: "common.firstName",
defaultMessage: "First name",
})}
name="firstName"
registerOptions={{ required: true }}
errorFormatter={formatFormErrorMessage}
/>
<Input
<FormInput
label={intl.formatMessage({
id: "common.lastName",
defaultMessage: "Last name",
})}
name="lastName"
registerOptions={{ required: true }}
errorFormatter={formatFormErrorMessage}
/>
<Input
<FormInput
label={intl.formatMessage({
id: "common.email",
defaultMessage: "Email",
@@ -127,6 +131,7 @@ export default function FindMyBooking({
name="email"
type="email"
registerOptions={{ required: true }}
errorFormatter={formatFormErrorMessage}
/>
</div>
<div className={styles.buttons}>

View File

@@ -7,10 +7,10 @@ import {
getDefaultCountryFromLang,
} from "@scandic-hotels/common/utils/phone"
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 { Typography } from "@scandic-hotels/design-system/Typography"
import Input from "@/components/TempDesignSystem/Form/Input"
import useLang from "@/hooks/useLang"
import { getFormattedCountryList } from "@/utils/countries"
import { getErrorMessage } from "@/utils/getErrorMessage"
@@ -45,7 +45,7 @@ export default function ModifyContact({
{isFirstStep ? (
<div className={styles.container}>
<div className={`${styles.row} ${styles.gridEqual}`}>
<Input
<FormInput
label={intl.formatMessage({
id: "common.firstName",
defaultMessage: "First name",
@@ -54,7 +54,7 @@ export default function ModifyContact({
name="firstName"
disabled={!!guest.firstName}
/>
<Input
<FormInput
label={intl.formatMessage({
id: "common.lastName",
defaultMessage: "Last name",
@@ -80,7 +80,7 @@ export default function ModifyContact({
/>
</div>
<div className={styles.row}>
<Input
<FormInput
label={intl.formatMessage({
id: "common.email",
defaultMessage: "Email",

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,7 @@ import { NewPasswordValidation } from "./NewPasswordValidation"
import styles from "./passwordInput.module.css"
interface PasswordInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
registerOptions?: RegisterOptions
visibilityToggleable?: boolean

View File

@@ -23,7 +23,7 @@
position: relative;
}
.toggleButton {
.inputWrapper .toggleButton {
position: absolute;
right: var(--Space-x2);
top: 50%;

View File

@@ -196,3 +196,14 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
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) ?? ""
}

View File

@@ -1,27 +1,14 @@
"use client"
// This is almost a copy of the Input in TempDesignSystem, but since it's tightly coupled
// to the error messages we need to duplicate it for now. In the future we should
// rewrite it to be more reusable.
import { forwardRef, useMemo } from "react"
import { forwardRef } from "react"
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 { FormInput } from "@scandic-hotels/design-system/Form/FormInput"
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
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> {
helpText?: string
@@ -34,7 +21,6 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
function Input(
{
"aria-label": ariaLabel,
autoComplete,
className = "",
disabled = false,
@@ -48,67 +34,38 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
type = "text",
hideError,
inputMode,
...props
},
ref
) {
const intl = useIntl()
const { control, formState } = useFormContext()
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 (
<Controller
disabled={disabled}
control={control}
name={name}
rules={registerOptions}
render={({ field, fieldState }) => (
<TextField
aria-label={ariaLabel}
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}
<FormInput
{...props}
ref={ref}
aria-labelledby={field.name}
autoComplete={autoComplete}
id={field.name}
className={className}
description={helpText}
descriptionIcon="check"
disabled={disabled}
errorFormatter={errorFormatter}
hideError={hideError}
inputMode={inputMode}
label={label}
maxLength={maxLength}
name={name}
placeholder={placeholder}
readOnly={readOnly}
required={!!registerOptions.required}
registerOptions={registerOptions}
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>
)}
/>
)
}

View File

@@ -4,5 +4,5 @@ export default create({
base: 'dark',
brandTitle: 'Scandic Design System',
brandUrl: 'https://www.scandichotels.com/',
brandImage: 'http://scandichotels.com/_static/img/scandic-logotype.png',
brandImage: '/img/scandic-logotype.png',
})

View File

@@ -137,40 +137,37 @@ function ExampleFormComponent({
const meta: Meta<typeof ExampleFormComponent> = {
title: 'Compositions/Form/ExampleForm',
component: ExampleFormComponent,
parameters: {
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
type Story = StoryObj<typeof ExampleFormComponent>
export const LabelFloating: Story = {
export const Default: Story = {
render: (args) => (
<ExampleFormComponent
key="label-on-top"
key={`label-${args.labelPosition || 'floating'}`}
{...args}
labelPosition="floating"
/>
),
args: {
onSubmit: fn(),
},
}
export const LabelOnTop: Story = {
render: (args) => (
<ExampleFormComponent
key="label-on-top"
{...args}
labelPosition="top"
fieldPrefix="top"
fieldPrefix="example"
/>
),
args: {
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
// ============================================================================

View File

@@ -8,8 +8,8 @@ import { cx } from 'class-variance-authority'
import { Error } from '../ErrorMessage/Error'
import { mergeRefs } from '../utils/mergeRefs'
import { MaterialIcon } from '../../Icons/MaterialIcon'
import { Input } from '../../InputNew'
import { MaterialIcon, MaterialIconProps } from '../../Icons/MaterialIcon'
import { Input } from '../../Input'
import styles from './input.module.css'
@@ -26,6 +26,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
autoComplete,
className = '',
description = '',
descriptionIcon = 'info' as MaterialIconProps['icon'],
disabled = false,
errorFormatter,
hideError,
@@ -102,7 +103,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
/>
{showDescription ? (
<Text className={styles.description} slot="description">
<MaterialIcon icon="info" size={20} />
<MaterialIcon icon={descriptionIcon} size={20} />
{description}
</Text>
) : null}

View File

@@ -1,11 +1,14 @@
import type { RegisterOptions } from 'react-hook-form'
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 {
/** Helper text displayed below the input (hidden when there's an error) */
description?: string
/** Icon to display with the description text. Defaults to 'info' */
descriptionIcon?: MaterialIconProps['icon']
/** Field name for react-hook-form registration */
name: string
/** react-hook-form validation rules */

View File

@@ -16,7 +16,7 @@ import {
import { ErrorMessage } from '../ErrorMessage'
import { MaterialIcon } from '../../Icons/MaterialIcon'
import { Input } from '../../InputNew'
import { Input } from '../../Input'
import { InputLabel } from '../../InputLabel'
import styles from './phone.module.css'

View File

@@ -5,8 +5,7 @@ import { ComponentProps } from 'react'
import { variants } from './variants'
interface IconButtonProps
extends ComponentProps<typeof ButtonRAC>,
VariantProps<typeof variants> {}
extends ComponentProps<typeof ButtonRAC>, VariantProps<typeof variants> {}
export function IconButton({
variant,

View File

@@ -4,16 +4,133 @@ import { expect } from 'storybook/test'
import { Input } from './Input'
import { TextField } from 'react-aria-components'
import { MaterialIcon } from '../Icons/MaterialIcon'
import type { SymbolCodepoints } from '../Icons/MaterialIcon/MaterialSymbol/types'
const meta: Meta<typeof Input> = {
title: 'Core Components/Input',
// @ts-expect-error Input does not support this, but wrapping <TextField> does
component: ({ isInvalid, ...props }) => (
<TextField isInvalid={isInvalid}>
<Input {...props} />
component: ({ isInvalid, validationState, ...props }) => (
<TextField isInvalid={isInvalid} data-validation-state={validationState}>
<Input {...props} data-validation-state={validationState} />
</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
@@ -25,8 +142,50 @@ export const Default: Story = {
label: 'Label',
name: 'foo',
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 }) => {
const textbox = canvas.getByRole('textbox')
expect(textbox).not.toBeDisabled()
@@ -40,68 +199,3 @@ export const Default: Story = {
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')
},
}

View File

@@ -12,36 +12,165 @@ import { InputLabel } from '../InputLabel'
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 type { InputProps } from './types'
import { clearInput, useInputHasValue } from './utils'
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
// on same page. This will inherited by parent label element.
// Shouldn't really be needed if we don't set id though.
const uniqueId = useId()
const inputId = `${uniqueId}-${props.name}`
// 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()
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 (
<AriaLabel className={styles.container}>
<>
<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}
placeholder={props.placeholder}
className={cx(styles.input, props.className)}
ref={ref}
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>
<InputLabel required={props.required}>{label}</InputLabel>
</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>
)
})

View File

@@ -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 {
align-content: center;
background-color: var(--Surface-Primary-Default);
@@ -10,8 +20,13 @@
box-sizing: border-box;
cursor: text;
.container:has(.input:focus):not(:has(.input:disabled)),
.container:has(.input:focus):not(:has(.input:read-only)) {
&: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);
}
@@ -26,11 +41,35 @@
&:has(.input[data-invalid='true'], .input[aria-invalid='true']) {
border-color: var(--Border-Interactive-Error);
&:focus-within {
&: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 {
@@ -41,6 +80,7 @@
order: 2;
padding: 0;
transition: height 150ms ease;
width: 100%;
&:focus,
&: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) {
.input:active:not(:disabled) {
height: 24px;

View File

@@ -3,4 +3,9 @@ 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
}

View File

@@ -9,6 +9,7 @@ export function InputLabel({
required,
disabled,
size,
...rest
}: InputLabelProps) {
const classNames = inputLabelVariants({
size,
@@ -18,5 +19,10 @@ export function InputLabel({
className,
})
return <span className={classNames}>{children}</span>
return (
<span className={classNames} {...rest}>
{children}
{required && <span aria-hidden="true">{' *'}</span>}
</span>
)
}

View File

@@ -39,10 +39,6 @@
order: unset;
}
.required:after {
content: ' *';
}
input:focus ~ .inputLabel,
input:placeholder-shown ~ .inputLabel,
input[value]:not([value='']) ~ .inputLabel,

View File

@@ -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()
},
}

View File

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

View File

@@ -1 +0,0 @@
export { Input } from './Input'

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB