Merged in feat/SW-3655-input-component (pull request #3296)
feat: (SW-3655) new Input and FormInput components * First version new Input and FormInput components * Handle aria-describedby with react-aria instead of manually add it * Update breaking unit and stories tests * Merge branch 'master' into feat/SW-3655-input-component * Update example form * Merge branch 'master' into feat/SW-3655-input-component * New lock file Approved-by: Linus Flood
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
|
|
||||||
import { expect } from 'storybook/test'
|
import { expect, fn } from 'storybook/test'
|
||||||
|
|
||||||
import { BackToTopButton } from '.'
|
import { BackToTopButton } from '.'
|
||||||
import { config as backToTopButtonConfig } from './variants'
|
import { config as backToTopButtonConfig } from './variants'
|
||||||
@@ -45,7 +45,7 @@ const globalStoryPropsInverted = {
|
|||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
onPress: () => alert('Back to top button pressed!'),
|
onPress: fn(),
|
||||||
label: 'Back to top',
|
label: 'Back to top',
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
@@ -90,7 +90,7 @@ export const PositionRight: Story = {
|
|||||||
export const OnDarkBackground: Story = {
|
export const OnDarkBackground: Story = {
|
||||||
globals: globalStoryPropsInverted,
|
globals: globalStoryPropsInverted,
|
||||||
args: {
|
args: {
|
||||||
onPress: () => alert('Back to top button pressed!'),
|
onPress: fn(),
|
||||||
label: 'Back to top',
|
label: 'Back to top',
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
|
|
||||||
import { expect } from 'storybook/test'
|
import { expect, fn } from 'storybook/test'
|
||||||
|
|
||||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||||
import { config as typographyConfig } from '../Typography/variants'
|
import { config as typographyConfig } from '../Typography/variants'
|
||||||
@@ -84,7 +84,7 @@ type Story = StoryObj<typeof Button>
|
|||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
onPress: () => alert('Primary button pressed!'),
|
onPress: fn(),
|
||||||
children: 'Button',
|
children: 'Button',
|
||||||
typography: 'Body/Paragraph/mdBold',
|
typography: 'Body/Paragraph/mdBold',
|
||||||
},
|
},
|
||||||
@@ -133,6 +133,7 @@ export const PrimaryDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...PrimaryLarge.args,
|
...PrimaryLarge.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -144,6 +145,7 @@ export const PrimaryLoading: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...PrimaryLarge.args,
|
...PrimaryLarge.args,
|
||||||
isPending: true,
|
isPending: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for loading test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -155,10 +157,11 @@ export const PrimaryOnDarkBackground: Story = {
|
|||||||
globals: globalStoryPropsInverted,
|
globals: globalStoryPropsInverted,
|
||||||
args: {
|
args: {
|
||||||
...PrimaryLarge.args,
|
...PrimaryLarge.args,
|
||||||
|
onPress: fn(), // Fresh spy instance
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
expect(args.onPress).toHaveBeenCalledTimes(0)
|
expect(args.onPress).toHaveBeenCalledTimes(1)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +208,7 @@ export const PrimaryInvertedDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...PrimaryInvertedLarge.args,
|
...PrimaryInvertedLarge.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -217,6 +221,7 @@ export const PrimaryInvertedLoading: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...PrimaryInvertedLarge.args,
|
...PrimaryInvertedLarge.args,
|
||||||
isPending: true,
|
isPending: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for loading test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -263,6 +268,7 @@ export const SecondaryDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...SecondaryLarge.args,
|
...SecondaryLarge.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -274,6 +280,7 @@ export const SecondaryLoading: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...SecondaryLarge.args,
|
...SecondaryLarge.args,
|
||||||
isPending: true,
|
isPending: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for loading test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -325,6 +332,7 @@ export const SecondaryInvertedDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...SecondaryInvertedLarge.args,
|
...SecondaryInvertedLarge.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -337,6 +345,7 @@ export const SecondaryInvertedLoading: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...SecondaryInvertedLarge.args,
|
...SecondaryInvertedLarge.args,
|
||||||
isPending: true,
|
isPending: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for loading test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -383,6 +392,7 @@ export const TertiaryDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...TertiaryLarge.args,
|
...TertiaryLarge.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -394,6 +404,7 @@ export const TertiaryLoading: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...TertiaryLarge.args,
|
...TertiaryLarge.args,
|
||||||
isPending: true,
|
isPending: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for loading test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -442,6 +453,7 @@ export const TextDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...TextLarge.args,
|
...TextLarge.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
@@ -504,6 +516,7 @@ export const TextInvertedDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...TextInvertedLarge.args,
|
...TextInvertedLarge.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(await canvas.findByRole('button'))
|
await userEvent.click(await canvas.findByRole('button'))
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import SelectChevron from './SelectChevron'
|
|||||||
|
|
||||||
import styles from './select.module.css'
|
import styles from './select.module.css'
|
||||||
import Body from '../Body'
|
import Body from '../Body'
|
||||||
import { Label } from '../Label'
|
import { InputLabel } from '../InputLabel'
|
||||||
|
|
||||||
interface SelectProps
|
interface SelectProps extends Omit<
|
||||||
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onSelect'> {
|
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||||
|
'onSelect'
|
||||||
|
> {
|
||||||
defaultSelectedKey?: Key
|
defaultSelectedKey?: Key
|
||||||
items: { label: string; value: Key }[]
|
items: { label: string; value: Key }[]
|
||||||
label: string
|
label: string
|
||||||
@@ -101,13 +103,13 @@ export default function Select({
|
|||||||
<SelectValue tabIndex={tabIndex}>
|
<SelectValue tabIndex={tabIndex}>
|
||||||
{({ selectedText }) => (
|
{({ selectedText }) => (
|
||||||
<>
|
<>
|
||||||
<Label
|
<InputLabel
|
||||||
required={required}
|
required={required}
|
||||||
size={discreet ? 'discreet' : 'regular'}
|
size={discreet ? 'discreet' : 'regular'}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{discreet && DELIMITER}
|
{discreet && DELIMITER}
|
||||||
</Label>
|
</InputLabel>
|
||||||
{selectedText && (
|
{selectedText && (
|
||||||
<Body className={optionsIcon ? styles.iconLabel : ''}>
|
<Body className={optionsIcon ? styles.iconLabel : ''}>
|
||||||
{optionsIcon ? optionsIcon : null}
|
{optionsIcon ? optionsIcon : null}
|
||||||
|
|||||||
@@ -0,0 +1,691 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
|
import { fn } from 'storybook/test'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { FormInput } from '../FormInput'
|
||||||
|
import { Button } from '../../Button'
|
||||||
|
import { Typography } from '../../Typography'
|
||||||
|
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||||
|
|
||||||
|
const createExampleFormSchema = (prefix?: string) => {
|
||||||
|
const getKey = (key: string) => (prefix ? `${prefix}_${key}` : key)
|
||||||
|
return z.object({
|
||||||
|
[getKey('firstName')]: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'First name must be at least 2 characters'),
|
||||||
|
[getKey('lastName')]: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'Last name must be at least 2 characters'),
|
||||||
|
[getKey('email')]: z.string().email('Please enter a valid email address'),
|
||||||
|
[getKey('phone')]: z.string().optional(),
|
||||||
|
[getKey('company')]: z.string().optional(),
|
||||||
|
[getKey('message')]: z
|
||||||
|
.string()
|
||||||
|
.min(10, 'Message must be at least 10 characters'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExampleFormProps {
|
||||||
|
onSubmit?: (data: Record<string, unknown>) => void
|
||||||
|
labelPosition?: 'floating' | 'top'
|
||||||
|
defaultValues?: Record<string, unknown>
|
||||||
|
fieldPrefix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExampleFormComponent({
|
||||||
|
onSubmit,
|
||||||
|
labelPosition = 'floating',
|
||||||
|
defaultValues,
|
||||||
|
fieldPrefix = '',
|
||||||
|
}: ExampleFormProps) {
|
||||||
|
const getFieldName = (name: string) =>
|
||||||
|
fieldPrefix ? `${fieldPrefix}_${name}` : name
|
||||||
|
|
||||||
|
const schema = createExampleFormSchema(fieldPrefix || undefined)
|
||||||
|
type FormData = z.infer<typeof schema>
|
||||||
|
const methods = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
[getFieldName('firstName')]: '',
|
||||||
|
[getFieldName('lastName')]: '',
|
||||||
|
[getFieldName('email')]: '',
|
||||||
|
[getFieldName('phone')]: '',
|
||||||
|
[getFieldName('company')]: '',
|
||||||
|
[getFieldName('message')]: '',
|
||||||
|
...(defaultValues as Partial<FormData>),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = methods.handleSubmit((data) => {
|
||||||
|
onSubmit?.(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
maxWidth: '500px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<h2>Example Form</h2>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', width: '100%' }}>
|
||||||
|
<FormInput
|
||||||
|
name={getFieldName('firstName')}
|
||||||
|
label="First name"
|
||||||
|
autoComplete="given-name"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name={getFieldName('lastName')}
|
||||||
|
label="Last name"
|
||||||
|
autoComplete="family-name"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
name={getFieldName('email')}
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
name={getFieldName('phone')}
|
||||||
|
label="Phone (optional)"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
name={getFieldName('company')}
|
||||||
|
label="Company (optional)"
|
||||||
|
autoComplete="organization"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
name={getFieldName('message')}
|
||||||
|
label="Message"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" variant="Primary" size="Large">
|
||||||
|
Send message
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<typeof ExampleFormComponent> = {
|
||||||
|
title: 'Compositions/Form/ExampleForm',
|
||||||
|
component: ExampleFormComponent,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ExampleFormComponent>
|
||||||
|
|
||||||
|
export const LabelFloating: Story = {
|
||||||
|
render: (args) => (
|
||||||
|
<ExampleFormComponent
|
||||||
|
key="label-on-top"
|
||||||
|
{...args}
|
||||||
|
labelPosition="floating"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
args: {
|
||||||
|
onSubmit: fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LabelOnTop: Story = {
|
||||||
|
render: (args) => (
|
||||||
|
<ExampleFormComponent
|
||||||
|
key="label-on-top"
|
||||||
|
{...args}
|
||||||
|
labelPosition="top"
|
||||||
|
fieldPrefix="top"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
args: {
|
||||||
|
onSubmit: fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Form with Errors and Descriptions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const signupSchema = z
|
||||||
|
.object({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(3, 'Username must be at least 3 characters')
|
||||||
|
.regex(
|
||||||
|
/^[a-z0-9_]+$/,
|
||||||
|
'Username can only contain lowercase letters, numbers, and underscores'
|
||||||
|
),
|
||||||
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||||
|
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
})
|
||||||
|
|
||||||
|
type SignupFormData = z.infer<typeof signupSchema>
|
||||||
|
|
||||||
|
interface SignupFormProps {
|
||||||
|
onSubmit?: (data: SignupFormData) => void
|
||||||
|
labelPosition?: 'floating' | 'top'
|
||||||
|
showErrors?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignupFormComponent({
|
||||||
|
onSubmit,
|
||||||
|
labelPosition = 'floating',
|
||||||
|
showErrors = false,
|
||||||
|
}: SignupFormProps) {
|
||||||
|
const methods = useForm<SignupFormData>({
|
||||||
|
resolver: zodResolver(signupSchema),
|
||||||
|
defaultValues: {
|
||||||
|
username: showErrors ? 'A' : '',
|
||||||
|
email: showErrors ? 'invalid-email' : '',
|
||||||
|
password: showErrors ? 'weak' : '',
|
||||||
|
confirmPassword: showErrors ? 'nomatch' : '',
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger validation on mount if showErrors is true
|
||||||
|
useEffect(() => {
|
||||||
|
if (showErrors) {
|
||||||
|
methods.trigger()
|
||||||
|
}
|
||||||
|
}, [showErrors, methods])
|
||||||
|
|
||||||
|
const handleSubmit = methods.handleSubmit((data) => {
|
||||||
|
onSubmit?.(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { errors } = methods.formState
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.5rem',
|
||||||
|
maxWidth: '400px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<h2>Create Account</h2>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
name="username"
|
||||||
|
label="Username"
|
||||||
|
autoComplete="username"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
description={!errors.username ? 'Username is available' : undefined}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
description={
|
||||||
|
!errors.email ? 'We will send a verification email' : undefined
|
||||||
|
}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
description={
|
||||||
|
!errors.password ? 'Password meets all requirements' : undefined
|
||||||
|
}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirm password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
description={!errors.confirmPassword ? 'Passwords match' : undefined}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" variant="Primary" size="Large">
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const signupMeta: Meta<typeof SignupFormComponent> = {
|
||||||
|
title: 'Compositions/Form/SignupForm',
|
||||||
|
component: SignupFormComponent,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
labelPosition: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['floating', 'top'],
|
||||||
|
description: 'Position of the labels',
|
||||||
|
},
|
||||||
|
showErrors: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Show validation errors on mount',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignupStory = StoryObj<typeof SignupFormComponent>
|
||||||
|
|
||||||
|
export const WithDescriptions: SignupStory = {
|
||||||
|
render: (args) => <SignupFormComponent {...args} />,
|
||||||
|
args: {
|
||||||
|
onSubmit: fn(),
|
||||||
|
labelPosition: 'floating',
|
||||||
|
showErrors: false,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...signupMeta.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithErrors: SignupStory = {
|
||||||
|
render: (args) => <SignupFormComponent {...args} />,
|
||||||
|
args: {
|
||||||
|
onSubmit: fn(),
|
||||||
|
labelPosition: 'floating',
|
||||||
|
showErrors: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...signupMeta.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithErrorsLabelOnTop: SignupStory = {
|
||||||
|
render: (args) => <SignupFormComponent {...args} />,
|
||||||
|
args: {
|
||||||
|
onSubmit: fn(),
|
||||||
|
labelPosition: 'top',
|
||||||
|
showErrors: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...signupMeta.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Input Variations Showcase
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const showcaseSchema = z.object({
|
||||||
|
default: z.string().optional(),
|
||||||
|
placeholder: z.string().optional(),
|
||||||
|
filled: z.string().optional(),
|
||||||
|
required: z.string().min(1, 'This field is required'),
|
||||||
|
disabled: z.string().optional(),
|
||||||
|
disabledFilled: z.string().optional(),
|
||||||
|
warningState: z.string().optional(),
|
||||||
|
warningFilled: z.string().optional(),
|
||||||
|
emailIcon: z.string().optional(),
|
||||||
|
searchIcon: z.string().optional(),
|
||||||
|
locked: z.string().optional(),
|
||||||
|
bothIcons: z.string().optional(),
|
||||||
|
emailIconTop: z.string().optional(),
|
||||||
|
searchIconTop: z.string().optional(),
|
||||||
|
lockedTop: z.string().optional(),
|
||||||
|
bothIconsTop: z.string().optional(),
|
||||||
|
emailClear: z.string().optional(),
|
||||||
|
searchClear: z.string().optional(),
|
||||||
|
clearLeftRight: z.string().optional(),
|
||||||
|
emptyClear: z.string().optional(),
|
||||||
|
error: z.string().min(10, 'Must be at least 10 characters'),
|
||||||
|
errorFilled: z.string().email('Invalid email'),
|
||||||
|
warning: z.string().optional(),
|
||||||
|
warningFilledValidation: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
emailType: z.string().optional(),
|
||||||
|
telType: z.string().optional(),
|
||||||
|
number: z.string().optional(),
|
||||||
|
passwordType: z.string().optional(),
|
||||||
|
urlType: z.string().optional(),
|
||||||
|
combined1: z.string().optional(),
|
||||||
|
combined2: z.string().email('Invalid email'),
|
||||||
|
combined3: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ShowcaseFormData = z.infer<typeof showcaseSchema>
|
||||||
|
|
||||||
|
function InputShowcase() {
|
||||||
|
const methods = useForm<ShowcaseFormData>({
|
||||||
|
resolver: zodResolver(showcaseSchema),
|
||||||
|
defaultValues: {
|
||||||
|
filled: 'Sample text',
|
||||||
|
disabledFilled: 'Cannot edit',
|
||||||
|
warningFilled: 'Needs attention',
|
||||||
|
emailClear: 'user@example.com',
|
||||||
|
searchClear: '',
|
||||||
|
clearLeftRight: '+46 70 123 45 67',
|
||||||
|
error: 'Short',
|
||||||
|
errorFilled: 'Invalid input',
|
||||||
|
warningFilledValidation: 'Needs attention',
|
||||||
|
combined1: 'user@example.com',
|
||||||
|
combined2: 'Invalid email',
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger validation for error examples on mount
|
||||||
|
useEffect(() => {
|
||||||
|
methods.trigger(['error', 'errorFilled', 'combined2'])
|
||||||
|
}, [methods])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '3rem',
|
||||||
|
maxWidth: '800px',
|
||||||
|
padding: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="Title/lg">
|
||||||
|
<h1>FormInput Component Showcase</h1>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Basic States */}
|
||||||
|
<section>
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<h2>Basic States</h2>
|
||||||
|
</Typography>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormInput name="default" label="Default" />
|
||||||
|
<FormInput
|
||||||
|
name="placeholder"
|
||||||
|
label="Placeholder"
|
||||||
|
placeholder="Enter text here..."
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="default2"
|
||||||
|
label="Default"
|
||||||
|
description="This is a default input with a description"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="default3"
|
||||||
|
label="Default"
|
||||||
|
placeholder="Enter text here..."
|
||||||
|
description="This is a default input with a description"
|
||||||
|
/>
|
||||||
|
<FormInput name="filled" label="Filled" />
|
||||||
|
<FormInput
|
||||||
|
name="required"
|
||||||
|
label="Required"
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
hideError
|
||||||
|
/>
|
||||||
|
<FormInput name="disabled" label="Disabled" disabled />
|
||||||
|
<FormInput name="disabledFilled" label="Disabled Filled" disabled />
|
||||||
|
<FormInput
|
||||||
|
name="warningState"
|
||||||
|
label="Warning State"
|
||||||
|
validationState="warning"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="warningFilled"
|
||||||
|
label="Warning with Value"
|
||||||
|
validationState="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* With Icons */}
|
||||||
|
<section>
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<h2>With Icons</h2>
|
||||||
|
</Typography>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormInput
|
||||||
|
name="emailIcon"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
leftIcon={<MaterialIcon icon="email" />}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="searchIcon"
|
||||||
|
label="Search"
|
||||||
|
leftIcon={<MaterialIcon icon="search" />}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="locked"
|
||||||
|
label="Locked icon"
|
||||||
|
rightIcon={<MaterialIcon icon="lock" />}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="bothIcons"
|
||||||
|
label="With Both Icons"
|
||||||
|
leftIcon={<MaterialIcon icon="person" />}
|
||||||
|
rightIcon={<MaterialIcon icon="check_circle" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormInput
|
||||||
|
name="emailIconTop"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
leftIcon={<MaterialIcon icon="email" />}
|
||||||
|
labelPosition="top"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="searchIconTop"
|
||||||
|
label="Search"
|
||||||
|
leftIcon={<MaterialIcon icon="search" />}
|
||||||
|
labelPosition="top"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="lockedTop"
|
||||||
|
label="Locked icon"
|
||||||
|
rightIcon={<MaterialIcon icon="lock" />}
|
||||||
|
labelPosition="top"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="bothIconsTop"
|
||||||
|
label="With Both Icons"
|
||||||
|
leftIcon={<MaterialIcon icon="person" />}
|
||||||
|
rightIcon={<MaterialIcon icon="check_circle" />}
|
||||||
|
labelPosition="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Clear Button */}
|
||||||
|
<section>
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<h2>Clear Button</h2>
|
||||||
|
</Typography>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormInput
|
||||||
|
name="emailClear"
|
||||||
|
label="Email with Clear"
|
||||||
|
type="email"
|
||||||
|
showClearContentIcon
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="searchClear"
|
||||||
|
label="Search with Clear"
|
||||||
|
leftIcon={<MaterialIcon icon="search" />}
|
||||||
|
showClearContentIcon
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="clearLeftRight"
|
||||||
|
label="Clear with Left Icon"
|
||||||
|
leftIcon={<MaterialIcon icon="phone" />}
|
||||||
|
showClearContentIcon
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="clearRightIcon"
|
||||||
|
label="Clear with Right Icon"
|
||||||
|
rightIcon={<MaterialIcon icon="lock" />}
|
||||||
|
showClearContentIcon
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="clearBothIcons"
|
||||||
|
label="Clear with Both Icon"
|
||||||
|
leftIcon={<MaterialIcon icon="email" />}
|
||||||
|
rightIcon={<MaterialIcon icon="lock" />}
|
||||||
|
showClearContentIcon
|
||||||
|
/>
|
||||||
|
<FormInput name="emptyClear" label="Empty" showClearContentIcon />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Validation States */}
|
||||||
|
<section>
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<h2>Validation States</h2>
|
||||||
|
</Typography>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormInput
|
||||||
|
name="error"
|
||||||
|
label="Error State"
|
||||||
|
registerOptions={{ minLength: 10 }}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="errorFilled"
|
||||||
|
label="Error with Value"
|
||||||
|
registerOptions={{ required: true }}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="warning"
|
||||||
|
label="Warning State"
|
||||||
|
validationState="warning"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="warningFilledValidation"
|
||||||
|
label="Warning with Value"
|
||||||
|
validationState="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Input Types */}
|
||||||
|
<section>
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<h2>Input Types</h2>
|
||||||
|
</Typography>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormInput name="text" label="Text" type="text" />
|
||||||
|
<FormInput name="emailType" label="Email" type="email" />
|
||||||
|
<FormInput name="number" label="Number" type="number" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</FormProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showcaseMeta: Meta<typeof InputShowcase> = {
|
||||||
|
title: 'Compositions/Form/InputShowcase',
|
||||||
|
component: InputShowcase,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShowcaseStory = StoryObj<typeof InputShowcase>
|
||||||
|
|
||||||
|
export const AllVariations: ShowcaseStory = {
|
||||||
|
render: () => <InputShowcase />,
|
||||||
|
parameters: {
|
||||||
|
...showcaseMeta.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
124
packages/design-system/lib/components/Form/FormInput/index.tsx
Normal file
124
packages/design-system/lib/components/Form/FormInput/index.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'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, type IntlShape } from 'react-intl'
|
||||||
|
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 styles from './input.module.css'
|
||||||
|
|
||||||
|
import type { FormInputProps } from './input'
|
||||||
|
|
||||||
|
const defaultErrorFormatter = (
|
||||||
|
_intl: IntlShape,
|
||||||
|
errorMessage?: string
|
||||||
|
): string => errorMessage ?? ''
|
||||||
|
|
||||||
|
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
||||||
|
function FormInput(
|
||||||
|
{
|
||||||
|
autoComplete,
|
||||||
|
className = '',
|
||||||
|
description = '',
|
||||||
|
disabled = false,
|
||||||
|
errorFormatter,
|
||||||
|
hideError,
|
||||||
|
inputMode,
|
||||||
|
label,
|
||||||
|
labelPosition = 'floating',
|
||||||
|
maxLength,
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
readOnly = false,
|
||||||
|
registerOptions = {},
|
||||||
|
type = 'text',
|
||||||
|
validationState,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const { control } = useFormContext()
|
||||||
|
|
||||||
|
const formatErrorMessage = errorFormatter ?? defaultErrorFormatter
|
||||||
|
|
||||||
|
// Number input: prevent scroll from changing value
|
||||||
|
const numberAttributes: HTMLAttributes<HTMLInputElement> =
|
||||||
|
type === 'number'
|
||||||
|
? {
|
||||||
|
onWheel: (evt: WheelEvent<HTMLInputElement>) => {
|
||||||
|
evt.currentTarget.blur()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
rules={registerOptions}
|
||||||
|
render={({ field, fieldState }) => {
|
||||||
|
const isDisabled = disabled || field.disabled
|
||||||
|
const hasError = fieldState.invalid && !hideError
|
||||||
|
const showDescription = description && !fieldState.error
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Note: No aria-label needed on TextField since the Input component
|
||||||
|
// always renders a visible label that provides the accessible name
|
||||||
|
<TextField
|
||||||
|
className={cx(styles.wrapper, className)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isReadOnly={readOnly}
|
||||||
|
isInvalid={fieldState.invalid}
|
||||||
|
isRequired={!!registerOptions.required}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
{...numberAttributes}
|
||||||
|
ref={mergeRefs(field.ref, ref)}
|
||||||
|
name={field.name}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
onChange={field.onChange}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
id={field.name}
|
||||||
|
label={label}
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
maxLength={maxLength}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={isDisabled}
|
||||||
|
required={!!registerOptions.required}
|
||||||
|
type={type}
|
||||||
|
inputMode={inputMode}
|
||||||
|
// Only 'warning' is passed through; error state is handled via isInvalid
|
||||||
|
data-validation-state={validationState}
|
||||||
|
/>
|
||||||
|
{showDescription ? (
|
||||||
|
<Text className={styles.description} slot="description">
|
||||||
|
<MaterialIcon icon="info" size={20} />
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{hasError && fieldState.error ? (
|
||||||
|
<Text slot="errorMessage" aria-live="polite">
|
||||||
|
<Error>
|
||||||
|
{formatErrorMessage(intl, fieldState.error.message)}
|
||||||
|
</Error>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
FormInput.displayName = 'FormInput'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Space-x05);
|
||||||
|
margin-top: var(--Space-x1);
|
||||||
|
font-size: var(--Body-Supporting-text-Size);
|
||||||
|
font-family: var(--Body-Supporting-text-Font-family, 'Fira Sans');
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: var(--Body-Supporting-text-Font-weight);
|
||||||
|
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { RegisterOptions } from 'react-hook-form'
|
||||||
|
import type { IntlShape } from 'react-intl'
|
||||||
|
|
||||||
|
import type { InputProps } from '../../InputNew/types'
|
||||||
|
|
||||||
|
export interface FormInputProps extends InputProps {
|
||||||
|
/** Helper text displayed below the input (hidden when there's an error) */
|
||||||
|
description?: string
|
||||||
|
/** Field name for react-hook-form registration */
|
||||||
|
name: string
|
||||||
|
/** react-hook-form validation rules */
|
||||||
|
registerOptions?: RegisterOptions
|
||||||
|
/** Hide the error message (useful when showing errors elsewhere) */
|
||||||
|
hideError?: boolean
|
||||||
|
/** Custom formatter for error messages with i18n support */
|
||||||
|
errorFormatter?: (intl: IntlShape, errorMessage?: string) => string
|
||||||
|
/**
|
||||||
|
* Visual validation state for the input.
|
||||||
|
* - 'warning': Shows warning styling (yellow background, focus ring)
|
||||||
|
* - Note: Error state is automatically derived from form validation
|
||||||
|
*/
|
||||||
|
validationState?: 'warning'
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
|
|
||||||
import { ErrorMessage } from '../ErrorMessage'
|
import { ErrorMessage } from '../ErrorMessage'
|
||||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||||
import { Input } from '../../Input'
|
import { Input } from '../../InputNew'
|
||||||
import { Label } from '../../Label'
|
import { InputLabel } from '../../InputLabel'
|
||||||
|
|
||||||
import styles from './phone.module.css'
|
import styles from './phone.module.css'
|
||||||
|
|
||||||
@@ -100,13 +100,13 @@ export default function Phone({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="country-selector"
|
data-testid="country-selector"
|
||||||
>
|
>
|
||||||
<Label
|
<InputLabel
|
||||||
required={!!registerOptions.required}
|
required={!!registerOptions.required}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{countryLabel}
|
{countryLabel}
|
||||||
</Label>
|
</InputLabel>
|
||||||
<span className={styles.selectContainer}>
|
<span className={styles.selectContainer}>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { Controller, useFormContext } from 'react-hook-form'
|
import { Controller, useFormContext } from 'react-hook-form'
|
||||||
|
|
||||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||||
import { Label } from '../../Label'
|
import { InputLabel } from '../../InputLabel'
|
||||||
import { Typography } from '../../Typography'
|
import { Typography } from '../../Typography'
|
||||||
|
|
||||||
import styles from './textarea.module.css'
|
import styles from './textarea.module.css'
|
||||||
@@ -57,7 +57,9 @@ export default function TextArea({
|
|||||||
className={styles.textarea}
|
className={styles.textarea}
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Label required={!!registerOptions.required}>{label}</Label>
|
<InputLabel required={!!registerOptions.required}>
|
||||||
|
{label}
|
||||||
|
</InputLabel>
|
||||||
</AriaLabel>
|
</AriaLabel>
|
||||||
{helpText && !fieldState.error ? (
|
{helpText && !fieldState.error ? (
|
||||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { mergeRefs } from './mergeRefs'
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Ref, RefCallback } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges multiple refs into a single ref callback.
|
||||||
|
* Useful when you need to forward a ref while also using react-hook-form's field.ref.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Input ref={mergeRefs(field.ref, forwardedRef)} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function mergeRefs<T>(
|
||||||
|
...refs: Array<Ref<T> | undefined>
|
||||||
|
): RefCallback<T> {
|
||||||
|
return (node: T | null) => {
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(node)
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
|
|
||||||
import { expect } from 'storybook/test'
|
import { expect, fn } from 'storybook/test'
|
||||||
|
|
||||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||||
import { IconButton } from './IconButton'
|
import { IconButton } from './IconButton'
|
||||||
@@ -60,7 +60,7 @@ type Story = StoryObj<typeof IconButton>
|
|||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
onPress: () => alert('Icon button pressed!'),
|
onPress: fn(),
|
||||||
children: <MaterialIcon icon="search" size={24} color="CurrentColor" />,
|
children: <MaterialIcon icon="search" size={24} color="CurrentColor" />,
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
@@ -73,10 +73,11 @@ export const Primary: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...Default.args,
|
...Default.args,
|
||||||
theme: 'Primary',
|
theme: 'Primary',
|
||||||
|
onPress: fn(), // Fresh spy instance
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(canvas.getByRole('button'))
|
await userEvent.click(canvas.getByRole('button'))
|
||||||
expect(args.onPress).toHaveBeenCalledTimes(0)
|
expect(args.onPress).toHaveBeenCalledTimes(1)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ export const PrimaryDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...Primary.args,
|
...Primary.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(canvas.getByRole('button'))
|
await userEvent.click(canvas.getByRole('button'))
|
||||||
@@ -109,6 +111,7 @@ export const InvertedDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...Inverted.args,
|
...Inverted.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(canvas.getByRole('button'))
|
await userEvent.click(canvas.getByRole('button'))
|
||||||
@@ -131,6 +134,7 @@ export const InvertedElevatedDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...InvertedElevated.args,
|
...InvertedElevated.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(canvas.getByRole('button'))
|
await userEvent.click(canvas.getByRole('button'))
|
||||||
@@ -157,6 +161,7 @@ export const InvertedMutedDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...InvertedMuted.args,
|
...InvertedMuted.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
|
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
@@ -180,6 +185,7 @@ export const InvertedFadedDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...InvertedFaded.args,
|
...InvertedFaded.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(canvas.getByRole('button'))
|
await userEvent.click(canvas.getByRole('button'))
|
||||||
@@ -204,6 +210,7 @@ export const TertiaryDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...TertiaryElevated.args,
|
...TertiaryElevated.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(canvas.getByRole('button'))
|
await userEvent.click(canvas.getByRole('button'))
|
||||||
@@ -227,6 +234,7 @@ export const BlackMutedDisabled: Story = {
|
|||||||
args: {
|
args: {
|
||||||
...BlackMuted.args,
|
...BlackMuted.args,
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
|
onPress: fn(), // Fresh spy instance for disabled test
|
||||||
},
|
},
|
||||||
play: async ({ canvas, userEvent, args }) => {
|
play: async ({ canvas, userEvent, args }) => {
|
||||||
await userEvent.click(canvas.getByRole('button'))
|
await userEvent.click(canvas.getByRole('button'))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { Input as AriaInput, Label as AriaLabel } from 'react-aria-components'
|
import { Input as AriaInput, Label as AriaLabel } from 'react-aria-components'
|
||||||
|
|
||||||
import { Label } from '../Label'
|
import { InputLabel } from '../InputLabel'
|
||||||
|
|
||||||
import styles from './input.module.css'
|
import styles from './input.module.css'
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
|||||||
id={inputId}
|
id={inputId}
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Label required={props.required}>{label}</Label>
|
<InputLabel required={props.required}>{label}</InputLabel>
|
||||||
</AriaLabel>
|
</AriaLabel>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
|
|
||||||
import { Label } from './Label'
|
import { InputLabel } from './InputLabel'
|
||||||
|
|
||||||
const meta: Meta<typeof Label> = {
|
const meta: Meta<typeof InputLabel> = {
|
||||||
title: 'Components/Label',
|
title: 'Components/InputLabel',
|
||||||
component: Label,
|
component: InputLabel,
|
||||||
argTypes: {},
|
argTypes: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default meta
|
export default meta
|
||||||
|
|
||||||
type Story = StoryObj<typeof Label>
|
type Story = StoryObj<typeof InputLabel>
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import { labelVariants } from './variants'
|
import { inputLabelVariants } from './variants'
|
||||||
|
|
||||||
import type { LabelProps } from './types'
|
import type { InputLabelProps } from './types'
|
||||||
|
|
||||||
export function Label({
|
export function InputLabel({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
selected,
|
selected,
|
||||||
required,
|
required,
|
||||||
disabled,
|
disabled,
|
||||||
size,
|
size,
|
||||||
}: LabelProps) {
|
}: InputLabelProps) {
|
||||||
const classNames = labelVariants({
|
const classNames = inputLabelVariants({
|
||||||
className,
|
|
||||||
size,
|
size,
|
||||||
required,
|
required,
|
||||||
selected,
|
selected,
|
||||||
disabled,
|
disabled,
|
||||||
|
className,
|
||||||
})
|
})
|
||||||
|
|
||||||
return <span className={classNames}>{children}</span>
|
return <span className={classNames}>{children}</span>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { InputLabel } from './InputLabel'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.label {
|
.inputLabel {
|
||||||
font-family:
|
font-family:
|
||||||
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback);
|
||||||
font-size: var(--Body-Paragraph-Size);
|
font-size: var(--Body-Paragraph-Size);
|
||||||
@@ -43,12 +43,12 @@
|
|||||||
content: ' *';
|
content: ' *';
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus ~ .label,
|
input:focus ~ .inputLabel,
|
||||||
input:placeholder-shown ~ .label,
|
input:placeholder-shown ~ .inputLabel,
|
||||||
input[value]:not([value='']) ~ .label,
|
input[value]:not([value='']) ~ .inputLabel,
|
||||||
textarea:focus ~ .label,
|
textarea:focus ~ .inputLabel,
|
||||||
textarea:placeholder-shown ~ .label,
|
textarea:placeholder-shown ~ .inputLabel,
|
||||||
textarea[value]:not([value='']) ~ .label,
|
textarea[value]:not([value='']) ~ .inputLabel,
|
||||||
.selected {
|
.selected {
|
||||||
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
||||||
font-size: var(--Label-Size);
|
font-size: var(--Label-Size);
|
||||||
@@ -61,15 +61,15 @@ textarea[value]:not([value='']) ~ .label,
|
|||||||
margin-bottom: var(--Space-x025);
|
margin-bottom: var(--Space-x025);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label.disabled,
|
.inputLabel.disabled,
|
||||||
input:read-only ~ .label,
|
input:read-only ~ .inputLabel,
|
||||||
input:disabled ~ .label,
|
input:disabled ~ .inputLabel,
|
||||||
textarea:disabled ~ .label {
|
textarea:disabled ~ .inputLabel {
|
||||||
color: var(--Text-Interactive-Disabled);
|
color: var(--Text-Interactive-Disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
input:active:not(:disabled) ~ .label {
|
input:active:not(:disabled) ~ .inputLabel {
|
||||||
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
||||||
font-size: var(--Label-Size);
|
font-size: var(--Label-Size);
|
||||||
font-weight: var(--Label-Font-weight);
|
font-weight: var(--Label-Font-weight);
|
||||||
@@ -83,15 +83,15 @@ textarea:disabled ~ .label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Legacy selector for deprecated select component */
|
/* Legacy selector for deprecated select component */
|
||||||
:global(.select-container)[data-disabled] .label {
|
:global(.select-container)[data-disabled] .inputLabel {
|
||||||
color: var(--Text-Interactive-Disabled);
|
color: var(--Text-Interactive-Disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.select-button) .label {
|
:global(.select-button) .inputLabel {
|
||||||
order: unset;
|
order: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.select-container)[data-open='true'] .label:not(.discreet),
|
:global(.select-container)[data-open='true'] .inputLabel:not(.discreet),
|
||||||
:global(.react-aria-SelectValue):has(:nth-child(2)) .label:not(.discreet) {
|
:global(.react-aria-SelectValue):has(:nth-child(2)) .inputLabel:not(.discreet) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
10
packages/design-system/lib/components/InputLabel/types.ts
Normal file
10
packages/design-system/lib/components/InputLabel/types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import type { inputLabelVariants } from './variants'
|
||||||
|
|
||||||
|
export interface InputLabelProps
|
||||||
|
extends
|
||||||
|
React.PropsWithChildren<React.HTMLAttributes<HTMLSpanElement>>,
|
||||||
|
VariantProps<typeof inputLabelVariants> {
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { cva } from 'class-variance-authority'
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
import styles from './label.module.css'
|
import styles from './inputLabel.module.css'
|
||||||
|
|
||||||
export const labelVariants = cva(styles.label, {
|
export const inputLabelVariants = cva(styles.inputLabel, {
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
small: styles.small,
|
small: styles.small,
|
||||||
416
packages/design-system/lib/components/InputNew/Input.stories.tsx
Normal file
416
packages/design-system/lib/components/InputNew/Input.stories.tsx
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
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: '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()
|
||||||
|
},
|
||||||
|
}
|
||||||
197
packages/design-system/lib/components/InputNew/Input.test.tsx
Normal file
197
packages/design-system/lib/components/InputNew/Input.test.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { Input } from './Input'
|
||||||
|
import { TextField } from 'react-aria-components'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap Input in TextField for proper React Aria context
|
||||||
|
const renderInput = (props: React.ComponentProps<typeof Input>) => {
|
||||||
|
return render(
|
||||||
|
<TextField>
|
||||||
|
<Input {...props} />
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Input standalone (without TextField) for testing Input's own behavior
|
||||||
|
const renderInputStandalone = (props: React.ComponentProps<typeof Input>) => {
|
||||||
|
return render(<Input {...props} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Input', () => {
|
||||||
|
describe('props', () => {
|
||||||
|
it('applies required attribute', () => {
|
||||||
|
renderInput({ label: 'Email', required: true })
|
||||||
|
expect(screen.getByRole('textbox')).toHaveProperty('required', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies readOnly attribute', () => {
|
||||||
|
renderInput({ label: 'Email', readOnly: true })
|
||||||
|
expect(screen.getByRole('textbox')).toHaveProperty('readOnly', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies placeholder for floating label when provided', () => {
|
||||||
|
renderInput({ label: 'Email', placeholder: 'Enter email' })
|
||||||
|
expect(screen.getByRole('textbox').getAttribute('placeholder')).toBe(
|
||||||
|
'Enter email'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies empty placeholder for top label by default', () => {
|
||||||
|
renderInput({ label: 'Email', labelPosition: 'top' })
|
||||||
|
expect(screen.getByRole('textbox').getAttribute('placeholder')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom id', () => {
|
||||||
|
// Use standalone render since TextField overrides id via context
|
||||||
|
renderInputStandalone({ label: 'Email', id: 'custom-id' })
|
||||||
|
expect(screen.getByRole('textbox').getAttribute('id')).toBe('custom-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies aria-describedby', () => {
|
||||||
|
renderInput({ label: 'Email', 'aria-describedby': 'error-message' })
|
||||||
|
expect(screen.getByRole('textbox').getAttribute('aria-describedby')).toBe(
|
||||||
|
'error-message'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clear content button', () => {
|
||||||
|
it('does not show clear button when showClearContentIcon is false', () => {
|
||||||
|
renderInput({
|
||||||
|
label: 'Email',
|
||||||
|
value: 'test',
|
||||||
|
onChange: vi.fn(),
|
||||||
|
showClearContentIcon: false,
|
||||||
|
})
|
||||||
|
expect(screen.queryByLabelText('Clear content')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show clear button when input is empty', () => {
|
||||||
|
renderInput({
|
||||||
|
label: 'Email',
|
||||||
|
value: '',
|
||||||
|
onChange: vi.fn(),
|
||||||
|
showClearContentIcon: true,
|
||||||
|
})
|
||||||
|
expect(screen.queryByLabelText('Clear content')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows clear button when input has value and showClearContentIcon is true', () => {
|
||||||
|
renderInput({
|
||||||
|
label: 'Email',
|
||||||
|
value: 'test',
|
||||||
|
onChange: vi.fn(),
|
||||||
|
showClearContentIcon: true,
|
||||||
|
})
|
||||||
|
expect(screen.getByLabelText('Clear content')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('icons', () => {
|
||||||
|
it('renders left icon when provided', () => {
|
||||||
|
renderInput({
|
||||||
|
label: 'Search',
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
leftIcon: <span data-testid="left-icon">🔍</span>,
|
||||||
|
})
|
||||||
|
expect(screen.getByTestId('left-icon')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders right icon when provided', () => {
|
||||||
|
renderInput({
|
||||||
|
label: 'Password',
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
rightIcon: <span data-testid="right-icon">👁</span>,
|
||||||
|
})
|
||||||
|
expect(screen.getByTestId('right-icon')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides right icon when clear button is shown', () => {
|
||||||
|
renderInput({
|
||||||
|
label: 'Email',
|
||||||
|
value: 'test',
|
||||||
|
onChange: vi.fn(),
|
||||||
|
showClearContentIcon: true,
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
rightIcon: <span data-testid="right-icon">👁</span>,
|
||||||
|
})
|
||||||
|
expect(screen.queryByTestId('right-icon')).toBeNull()
|
||||||
|
expect(screen.getByLabelText('Clear content')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows right icon when clear button condition not met', () => {
|
||||||
|
renderInput({
|
||||||
|
label: 'Email',
|
||||||
|
value: '',
|
||||||
|
onChange: vi.fn(),
|
||||||
|
showClearContentIcon: true,
|
||||||
|
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||||
|
rightIcon: <span data-testid="right-icon">👁</span>,
|
||||||
|
})
|
||||||
|
expect(screen.getByTestId('right-icon')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('controlled input', () => {
|
||||||
|
it('displays the controlled value', () => {
|
||||||
|
renderInput({
|
||||||
|
label: 'Email',
|
||||||
|
value: 'test@example.com',
|
||||||
|
onChange: vi.fn(),
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('textbox')).toHaveProperty(
|
||||||
|
'value',
|
||||||
|
'test@example.com'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onChange when typing', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
renderInput({ label: 'Email', value: '', onChange })
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await userEvent.type(input, 'a')
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not change value without onChange updating it', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
renderInput({ label: 'Email', value: 'initial', onChange })
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(input, { target: { value: 'changed' } })
|
||||||
|
|
||||||
|
// Value stays the same because it's controlled
|
||||||
|
expect(input).toHaveProperty('value', 'initial')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ref forwarding', () => {
|
||||||
|
it('forwards ref to the input element', () => {
|
||||||
|
const ref = { current: null as HTMLInputElement | null }
|
||||||
|
render(
|
||||||
|
<TextField>
|
||||||
|
<Input label="Email" ref={ref} />
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
expect(ref.current).toBeInstanceOf(HTMLInputElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows focusing via ref', () => {
|
||||||
|
const ref = { current: null as HTMLInputElement | null }
|
||||||
|
render(
|
||||||
|
<TextField>
|
||||||
|
<Input label="Email" ref={ref} />
|
||||||
|
</TextField>
|
||||||
|
)
|
||||||
|
ref.current?.focus()
|
||||||
|
expect(document.activeElement).toBe(ref.current)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
174
packages/design-system/lib/components/InputNew/Input.tsx
Normal file
174
packages/design-system/lib/components/InputNew/Input.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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 type { InputProps } from './types'
|
||||||
|
import { Typography } from '../Typography'
|
||||||
|
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||||
|
import { IconButton } from '../IconButton'
|
||||||
|
import { clearInput, useInputHasValue } from './utils'
|
||||||
|
|
||||||
|
const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
labelPosition = 'floating',
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
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}
|
||||||
|
theme="Black"
|
||||||
|
onClick={onClearContent}
|
||||||
|
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}
|
||||||
|
theme="Black"
|
||||||
|
onClick={onClearContent}
|
||||||
|
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
packages/design-system/lib/components/InputNew/index.tsx
Normal file
1
packages/design-system/lib/components/InputNew/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Input } from './Input'
|
||||||
139
packages/design-system/lib/components/InputNew/input.module.css
Normal file
139
packages/design-system/lib/components/InputNew/input.module.css
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/design-system/lib/components/InputNew/types.ts
Normal file
11
packages/design-system/lib/components/InputNew/types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
103
packages/design-system/lib/components/InputNew/utils.test.ts
Normal file
103
packages/design-system/lib/components/InputNew/utils.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { useInputHasValue, clearInput } from './utils'
|
||||||
|
|
||||||
|
describe('useInputHasValue', () => {
|
||||||
|
const createMockRef = (value = '') => ({
|
||||||
|
current: { value } as HTMLInputElement,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('controlled input (value prop)', () => {
|
||||||
|
it('returns true when value has content', () => {
|
||||||
|
const ref = createMockRef()
|
||||||
|
const { result } = renderHook(() => useInputHasValue('hello', ref))
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when value is empty string', () => {
|
||||||
|
const ref = createMockRef()
|
||||||
|
const { result } = renderHook(() => useInputHasValue('', ref))
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when value is only whitespace', () => {
|
||||||
|
const ref = createMockRef()
|
||||||
|
const { result } = renderHook(() => useInputHasValue(' ', ref))
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates when value prop changes', () => {
|
||||||
|
const ref = createMockRef()
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useInputHasValue(value, ref),
|
||||||
|
{ initialProps: { value: '' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
|
||||||
|
rerender({ value: 'new value' })
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearInput', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onChange with empty value for controlled input', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const inputRef = {
|
||||||
|
current: {
|
||||||
|
value: 'test',
|
||||||
|
focus: vi.fn(),
|
||||||
|
} as unknown as HTMLInputElement,
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInput({
|
||||||
|
inputRef,
|
||||||
|
onChange,
|
||||||
|
value: 'test',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
target: { value: '' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(inputRef.current.focus).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets input value directly for uncontrolled input', () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.value = 'test'
|
||||||
|
const focusSpy = vi.spyOn(input, 'focus')
|
||||||
|
const dispatchSpy = vi.spyOn(input, 'dispatchEvent')
|
||||||
|
|
||||||
|
const inputRef = { current: input }
|
||||||
|
|
||||||
|
clearInput({
|
||||||
|
inputRef,
|
||||||
|
onChange: undefined,
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(input.value).toBe('')
|
||||||
|
expect(dispatchSpy).toHaveBeenCalled()
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when ref is null', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const inputRef = { current: null }
|
||||||
|
|
||||||
|
clearInput({
|
||||||
|
inputRef,
|
||||||
|
onChange,
|
||||||
|
value: 'test',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
120
packages/design-system/lib/components/InputNew/utils.ts
Normal file
120
packages/design-system/lib/components/InputNew/utils.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ChangeEvent, ChangeEventHandler, RefObject } from 'react'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface ClearInputOptions {
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>
|
||||||
|
onChange?: ChangeEventHandler<HTMLInputElement>
|
||||||
|
value?: string | number | readonly string[]
|
||||||
|
onRightIconClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears an input field value, handling both controlled and uncontrolled components.
|
||||||
|
* Works with React Aria Components Input which can be used standalone or within TextField.
|
||||||
|
*/
|
||||||
|
export function clearInput({
|
||||||
|
inputRef,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
}: ClearInputOptions): void {
|
||||||
|
if (!inputRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isControlled = value !== undefined
|
||||||
|
|
||||||
|
if (isControlled && onChange) {
|
||||||
|
// For controlled components: call onChange with a synthetic event
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: { value: '' },
|
||||||
|
currentTarget: inputRef.current,
|
||||||
|
} as ChangeEvent<HTMLInputElement>
|
||||||
|
onChange(syntheticEvent)
|
||||||
|
} else {
|
||||||
|
// For uncontrolled components: use native input value setter
|
||||||
|
// This triggers React's change detection properly
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
'value'
|
||||||
|
)?.set
|
||||||
|
|
||||||
|
if (nativeInputValueSetter) {
|
||||||
|
nativeInputValueSetter.call(inputRef.current, '')
|
||||||
|
} else {
|
||||||
|
inputRef.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch input event to trigger any listeners
|
||||||
|
const inputEvent = new Event('input', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
inputRef.current.dispatchEvent(inputEvent)
|
||||||
|
|
||||||
|
// Also dispatch change event
|
||||||
|
const changeEvent = new Event('change', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
inputRef.current.dispatchEvent(changeEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the input after clearing
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to track whether an input has a value.
|
||||||
|
* Works for both controlled and uncontrolled components.
|
||||||
|
*/
|
||||||
|
export function useInputHasValue(
|
||||||
|
value: string | number | readonly string[] | undefined,
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>
|
||||||
|
): boolean {
|
||||||
|
const [hasValue, setHasValue] = useState(() => {
|
||||||
|
// For controlled components, check value prop
|
||||||
|
if (value !== undefined) {
|
||||||
|
return String(value ?? '').trim().length > 0
|
||||||
|
}
|
||||||
|
// For uncontrolled, check ref
|
||||||
|
return String(inputRef.current?.value ?? '').trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync with controlled value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
setHasValue(String(value ?? '').trim().length > 0)
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
// For uncontrolled components, sync from ref on input events
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
return // Controlled component, no need to listen
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = inputRef.current
|
||||||
|
if (!input) return
|
||||||
|
|
||||||
|
const updateHasValue = (event?: Event) => {
|
||||||
|
// Use setTimeout to ensure we read the value after React/React Aria Components
|
||||||
|
// has fully processed the change, especially for operations like select-all + delete
|
||||||
|
setTimeout(() => {
|
||||||
|
const target = (event?.target as HTMLInputElement) || inputRef.current
|
||||||
|
if (target) {
|
||||||
|
setHasValue((target.value ?? '').trim().length > 0)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', updateHasValue)
|
||||||
|
// Also listen to change event as a fallback
|
||||||
|
input.addEventListener('change', updateHasValue)
|
||||||
|
return () => {
|
||||||
|
input.removeEventListener('input', updateHasValue)
|
||||||
|
input.removeEventListener('change', updateHasValue)
|
||||||
|
}
|
||||||
|
}, [value, inputRef])
|
||||||
|
|
||||||
|
return hasValue
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { Label } from './Label'
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { VariantProps } from 'class-variance-authority'
|
|
||||||
|
|
||||||
import type { labelVariants } from './variants'
|
|
||||||
|
|
||||||
export interface LabelProps
|
|
||||||
extends React.PropsWithChildren<React.HTMLAttributes<HTMLSpanElement>>,
|
|
||||||
VariantProps<typeof labelVariants> {
|
|
||||||
required?: boolean
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ import type { SelectProps, SelectFilterProps } from './types'
|
|||||||
|
|
||||||
import styles from './select.module.css'
|
import styles from './select.module.css'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Label } from '../Label'
|
import { InputLabel } from '../InputLabel'
|
||||||
|
|
||||||
export function Select({
|
export function Select({
|
||||||
name,
|
name,
|
||||||
@@ -65,12 +65,12 @@ export function Select({
|
|||||||
{({ selectedText }) => {
|
{({ selectedText }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Label
|
<InputLabel
|
||||||
className={styles.label}
|
className={styles.label}
|
||||||
selected={Boolean(selectedText || isOpen)}
|
selected={Boolean(selectedText || isOpen)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</InputLabel>
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
<span className={styles.selectedText}>{selectedText}</span>
|
<span className={styles.selectedText}>{selectedText}</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { SelectItem } from './SelectItem'
|
|||||||
import type { SelectFilterProps } from './types'
|
import type { SelectFilterProps } from './types'
|
||||||
|
|
||||||
import styles from './select.module.css'
|
import styles from './select.module.css'
|
||||||
import { Label } from '../Label'
|
import { InputLabel } from '../InputLabel'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ComboBoxInner
|
* ComboBoxInner
|
||||||
@@ -136,9 +136,12 @@ export function SelectFilter({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<span className={styles.displayText}>
|
<span className={styles.displayText}>
|
||||||
<Label className={styles.label} selected={Boolean(focus || value)}>
|
<InputLabel
|
||||||
|
className={styles.label}
|
||||||
|
selected={Boolean(focus || value)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</InputLabel>
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"./Form/Date": "./lib/components/Form/Date/index.tsx",
|
"./Form/Date": "./lib/components/Form/Date/index.tsx",
|
||||||
"./Form/Select": "./lib/components/Form/FormSelect/index.tsx",
|
"./Form/Select": "./lib/components/Form/FormSelect/index.tsx",
|
||||||
"./Form/ErrorMessage": "./lib/components/Form/ErrorMessage/index.tsx",
|
"./Form/ErrorMessage": "./lib/components/Form/ErrorMessage/index.tsx",
|
||||||
|
"./Form/FormInput": "./lib/components/Form/FormInput/index.tsx",
|
||||||
"./Form/RadioButtonsGroup": "./lib/components/Form/RadioButtonsGroup/index.tsx",
|
"./Form/RadioButtonsGroup": "./lib/components/Form/RadioButtonsGroup/index.tsx",
|
||||||
"./Form/PaymentOption": "./lib/components/Form/PaymentOption/PaymentOption.tsx",
|
"./Form/PaymentOption": "./lib/components/Form/PaymentOption/PaymentOption.tsx",
|
||||||
"./Form/PaymentOptionsGroup": "./lib/components/Form/PaymentOption/PaymentOptionsGroup.tsx",
|
"./Form/PaymentOptionsGroup": "./lib/components/Form/PaymentOption/PaymentOptionsGroup.tsx",
|
||||||
@@ -249,12 +250,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.26.0",
|
"@eslint/js": "^9.26.0",
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@storybook/addon-a11y": "^10.0.8",
|
"@storybook/addon-a11y": "^10.0.8",
|
||||||
"@storybook/addon-docs": "^10.0.8",
|
"@storybook/addon-docs": "^10.0.8",
|
||||||
"@storybook/addon-links": "^10.0.8",
|
"@storybook/addon-links": "^10.0.8",
|
||||||
"@storybook/addon-themes": "^10.0.8",
|
"@storybook/addon-themes": "^10.0.8",
|
||||||
"@storybook/addon-vitest": "^10.0.8",
|
"@storybook/addon-vitest": "^10.0.8",
|
||||||
"@storybook/nextjs-vite": "^10.0.8",
|
"@storybook/nextjs-vite": "^10.0.8",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/css-modules": "^1.0.5",
|
"@types/css-modules": "^1.0.5",
|
||||||
"@types/node": "^20.17.17",
|
"@types/node": "^20.17.17",
|
||||||
"@types/react": "^19.2.3",
|
"@types/react": "^19.2.3",
|
||||||
@@ -294,6 +299,7 @@
|
|||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"vite-plugin-lib-inject-css": "^2.2.2",
|
"vite-plugin-lib-inject-css": "^2.2.2",
|
||||||
"vitest": "^4.0.0",
|
"vitest": "^4.0.0",
|
||||||
"vitest-browser-react": "^1.0.1"
|
"vitest-browser-react": "^1.0.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
yarn.lock
20
yarn.lock
@@ -1383,6 +1383,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@hookform/resolvers@npm:^3.9.1":
|
||||||
|
version: 3.10.0
|
||||||
|
resolution: "@hookform/resolvers@npm:3.10.0"
|
||||||
|
peerDependencies:
|
||||||
|
react-hook-form: ^7.0.0
|
||||||
|
checksum: 10c0/7ee44533b4cdc28c4fa2a94894c735411e5a1f830f4a617c580533321a9b901df0cc8c1e2fad81ad8d55154ebc5cb844cf9c116a3148ffae2bc48758c33cbb8e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@hookform/resolvers@npm:^5.0.1":
|
"@hookform/resolvers@npm:^5.0.1":
|
||||||
version: 5.2.2
|
version: 5.2.2
|
||||||
resolution: "@hookform/resolvers@npm:5.2.2"
|
resolution: "@hookform/resolvers@npm:5.2.2"
|
||||||
@@ -5290,6 +5299,7 @@ __metadata:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@eslint/eslintrc": "npm:^3.3.1"
|
"@eslint/eslintrc": "npm:^3.3.1"
|
||||||
"@eslint/js": "npm:^9.26.0"
|
"@eslint/js": "npm:^9.26.0"
|
||||||
|
"@hookform/resolvers": "npm:^3.9.1"
|
||||||
"@scandic-hotels/common": "workspace:*"
|
"@scandic-hotels/common": "workspace:*"
|
||||||
"@storybook/addon-a11y": "npm:^10.0.8"
|
"@storybook/addon-a11y": "npm:^10.0.8"
|
||||||
"@storybook/addon-docs": "npm:^10.0.8"
|
"@storybook/addon-docs": "npm:^10.0.8"
|
||||||
@@ -5297,6 +5307,9 @@ __metadata:
|
|||||||
"@storybook/addon-themes": "npm:^10.0.8"
|
"@storybook/addon-themes": "npm:^10.0.8"
|
||||||
"@storybook/addon-vitest": "npm:^10.0.8"
|
"@storybook/addon-vitest": "npm:^10.0.8"
|
||||||
"@storybook/nextjs-vite": "npm:^10.0.8"
|
"@storybook/nextjs-vite": "npm:^10.0.8"
|
||||||
|
"@testing-library/dom": "npm:^10.4.0"
|
||||||
|
"@testing-library/react": "npm:^16.1.0"
|
||||||
|
"@testing-library/user-event": "npm:^14.5.2"
|
||||||
"@types/css-modules": "npm:^1.0.5"
|
"@types/css-modules": "npm:^1.0.5"
|
||||||
"@types/node": "npm:^20.17.17"
|
"@types/node": "npm:^20.17.17"
|
||||||
"@types/react": "npm:^19.2.3"
|
"@types/react": "npm:^19.2.3"
|
||||||
@@ -5338,6 +5351,7 @@ __metadata:
|
|||||||
vite-plugin-lib-inject-css: "npm:^2.2.2"
|
vite-plugin-lib-inject-css: "npm:^2.2.2"
|
||||||
vitest: "npm:^4.0.0"
|
vitest: "npm:^4.0.0"
|
||||||
vitest-browser-react: "npm:^1.0.1"
|
vitest-browser-react: "npm:^1.0.1"
|
||||||
|
zod: "npm:^3.24.1"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@babel/core": ^7.27.4
|
"@babel/core": ^7.27.4
|
||||||
"@hookform/error-message": ^2.0.1
|
"@hookform/error-message": ^2.0.1
|
||||||
@@ -6466,7 +6480,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@testing-library/react@npm:^16.3.0":
|
"@testing-library/react@npm:^16.1.0, @testing-library/react@npm:^16.3.0":
|
||||||
version: 16.3.0
|
version: 16.3.0
|
||||||
resolution: "@testing-library/react@npm:16.3.0"
|
resolution: "@testing-library/react@npm:16.3.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6486,7 +6500,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@testing-library/user-event@npm:^14.6.1":
|
"@testing-library/user-event@npm:^14.5.2, @testing-library/user-event@npm:^14.6.1":
|
||||||
version: 14.6.1
|
version: 14.6.1
|
||||||
resolution: "@testing-library/user-event@npm:14.6.1"
|
resolution: "@testing-library/user-event@npm:14.6.1"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -17596,7 +17610,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"zod@npm:^3.23.8, zod@npm:^3.24.4":
|
"zod@npm:^3.23.8, zod@npm:^3.24.1, zod@npm:^3.24.4":
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
resolution: "zod@npm:3.25.76"
|
resolution: "zod@npm:3.25.76"
|
||||||
checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c
|
checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c
|
||||||
|
|||||||
Reference in New Issue
Block a user