diff --git a/packages/design-system/lib/components/BackToTopButton/BackToTopButton.stories.tsx b/packages/design-system/lib/components/BackToTopButton/BackToTopButton.stories.tsx index 9d2ac2960..ccb331f5a 100644 --- a/packages/design-system/lib/components/BackToTopButton/BackToTopButton.stories.tsx +++ b/packages/design-system/lib/components/BackToTopButton/BackToTopButton.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { expect } from 'storybook/test' +import { expect, fn } from 'storybook/test' import { BackToTopButton } from '.' import { config as backToTopButtonConfig } from './variants' @@ -45,7 +45,7 @@ const globalStoryPropsInverted = { export const Default: Story = { args: { - onPress: () => alert('Back to top button pressed!'), + onPress: fn(), label: 'Back to top', }, play: async ({ canvas, userEvent, args }) => { @@ -90,7 +90,7 @@ export const PositionRight: Story = { export const OnDarkBackground: Story = { globals: globalStoryPropsInverted, args: { - onPress: () => alert('Back to top button pressed!'), + onPress: fn(), label: 'Back to top', }, play: async ({ canvas, userEvent, args }) => { diff --git a/packages/design-system/lib/components/Button/Button.stories.tsx b/packages/design-system/lib/components/Button/Button.stories.tsx index 50c780d41..9935228a5 100644 --- a/packages/design-system/lib/components/Button/Button.stories.tsx +++ b/packages/design-system/lib/components/Button/Button.stories.tsx @@ -1,6 +1,6 @@ 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 { config as typographyConfig } from '../Typography/variants' @@ -84,7 +84,7 @@ type Story = StoryObj export const Default: Story = { args: { - onPress: () => alert('Primary button pressed!'), + onPress: fn(), children: 'Button', typography: 'Body/Paragraph/mdBold', }, @@ -133,6 +133,7 @@ export const PrimaryDisabled: Story = { args: { ...PrimaryLarge.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -144,6 +145,7 @@ export const PrimaryLoading: Story = { args: { ...PrimaryLarge.args, isPending: true, + onPress: fn(), // Fresh spy instance for loading test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -155,10 +157,11 @@ export const PrimaryOnDarkBackground: Story = { globals: globalStoryPropsInverted, args: { ...PrimaryLarge.args, + onPress: fn(), // Fresh spy instance }, play: async ({ canvas, userEvent, args }) => { 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: { ...PrimaryInvertedLarge.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -217,6 +221,7 @@ export const PrimaryInvertedLoading: Story = { args: { ...PrimaryInvertedLarge.args, isPending: true, + onPress: fn(), // Fresh spy instance for loading test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -263,6 +268,7 @@ export const SecondaryDisabled: Story = { args: { ...SecondaryLarge.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -274,6 +280,7 @@ export const SecondaryLoading: Story = { args: { ...SecondaryLarge.args, isPending: true, + onPress: fn(), // Fresh spy instance for loading test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -325,6 +332,7 @@ export const SecondaryInvertedDisabled: Story = { args: { ...SecondaryInvertedLarge.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -337,6 +345,7 @@ export const SecondaryInvertedLoading: Story = { args: { ...SecondaryInvertedLarge.args, isPending: true, + onPress: fn(), // Fresh spy instance for loading test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -383,6 +392,7 @@ export const TertiaryDisabled: Story = { args: { ...TertiaryLarge.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -394,6 +404,7 @@ export const TertiaryLoading: Story = { args: { ...TertiaryLarge.args, isPending: true, + onPress: fn(), // Fresh spy instance for loading test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -442,6 +453,7 @@ export const TextDisabled: Story = { args: { ...TextLarge.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -504,6 +516,7 @@ export const TextInvertedDisabled: Story = { args: { ...TextInvertedLarge.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) diff --git a/packages/design-system/lib/components/DeprecatedSelect/index.tsx b/packages/design-system/lib/components/DeprecatedSelect/index.tsx index 441eaf612..6927d59cd 100644 --- a/packages/design-system/lib/components/DeprecatedSelect/index.tsx +++ b/packages/design-system/lib/components/DeprecatedSelect/index.tsx @@ -14,10 +14,12 @@ import SelectChevron from './SelectChevron' import styles from './select.module.css' import Body from '../Body' -import { Label } from '../Label' +import { InputLabel } from '../InputLabel' -interface SelectProps - extends Omit, 'onSelect'> { +interface SelectProps extends Omit< + React.SelectHTMLAttributes, + 'onSelect' +> { defaultSelectedKey?: Key items: { label: string; value: Key }[] label: string @@ -101,13 +103,13 @@ export default function Select({ {({ selectedText }) => ( <> - + {selectedText && ( {optionsIcon ? optionsIcon : null} diff --git a/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx b/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx new file mode 100644 index 000000000..b38961dee --- /dev/null +++ b/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx @@ -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) => void + labelPosition?: 'floating' | 'top' + defaultValues?: Record + 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 + const methods = useForm({ + resolver: zodResolver(schema), + defaultValues: { + [getFieldName('firstName')]: '', + [getFieldName('lastName')]: '', + [getFieldName('email')]: '', + [getFieldName('phone')]: '', + [getFieldName('company')]: '', + [getFieldName('message')]: '', + ...(defaultValues as Partial), + }, + }) + + const handleSubmit = methods.handleSubmit((data) => { + onSubmit?.(data) + }) + + return ( + +
+ +

Example Form

+
+ +
+ + +
+ + + + + + + + + + + +
+ ) +} + +const meta: Meta = { + title: 'Compositions/Form/ExampleForm', + component: ExampleFormComponent, + parameters: { + layout: 'padded', + }, +} + +export default meta + +type Story = StoryObj + +export const LabelFloating: Story = { + render: (args) => ( + + ), + args: { + onSubmit: fn(), + }, +} + +export const LabelOnTop: Story = { + render: (args) => ( + + ), + 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 + +interface SignupFormProps { + onSubmit?: (data: SignupFormData) => void + labelPosition?: 'floating' | 'top' + showErrors?: boolean +} + +function SignupFormComponent({ + onSubmit, + labelPosition = 'floating', + showErrors = false, +}: SignupFormProps) { + const methods = useForm({ + 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 ( + +
+ +

Create Account

+
+ + + + + + + + + + + +
+ ) +} + +const signupMeta: Meta = { + 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 + +export const WithDescriptions: SignupStory = { + render: (args) => , + args: { + onSubmit: fn(), + labelPosition: 'floating', + showErrors: false, + }, + parameters: { + ...signupMeta.parameters, + }, +} + +export const WithErrors: SignupStory = { + render: (args) => , + args: { + onSubmit: fn(), + labelPosition: 'floating', + showErrors: true, + }, + parameters: { + ...signupMeta.parameters, + }, +} + +export const WithErrorsLabelOnTop: SignupStory = { + render: (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 + +function InputShowcase() { + const methods = useForm({ + 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 ( + +
+ +

FormInput Component Showcase

+
+ + {/* Basic States */} +
+ +

Basic States

+
+
+ + + + + + + + + + +
+
+ + {/* With Icons */} +
+ +

With Icons

+
+
+ } + /> + } + /> + } + /> + } + rightIcon={} + /> +
+
+ } + labelPosition="top" + /> + } + labelPosition="top" + /> + } + labelPosition="top" + /> + } + rightIcon={} + labelPosition="top" + /> +
+
+ + {/* Clear Button */} +
+ +

Clear Button

+
+
+ + } + showClearContentIcon + /> + } + showClearContentIcon + /> + } + showClearContentIcon + /> + } + rightIcon={} + showClearContentIcon + /> + +
+
+ + {/* Validation States */} +
+ +

Validation States

+
+
+ + + + +
+
+ + {/* Input Types */} +
+ +

Input Types

+
+
+ + + +
+
+
+
+ ) +} + +const showcaseMeta: Meta = { + title: 'Compositions/Form/InputShowcase', + component: InputShowcase, + parameters: { + layout: 'fullscreen', + }, +} + +type ShowcaseStory = StoryObj + +export const AllVariations: ShowcaseStory = { + render: () => , + parameters: { + ...showcaseMeta.parameters, + }, +} diff --git a/packages/design-system/lib/components/Form/FormInput/index.tsx b/packages/design-system/lib/components/Form/FormInput/index.tsx new file mode 100644 index 000000000..263ae3458 --- /dev/null +++ b/packages/design-system/lib/components/Form/FormInput/index.tsx @@ -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( + 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 = + type === 'number' + ? { + onWheel: (evt: WheelEvent) => { + evt.currentTarget.blur() + }, + } + : {} + + return ( + { + 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 + + + {showDescription ? ( + + + {description} + + ) : null} + {hasError && fieldState.error ? ( + + + {formatErrorMessage(intl, fieldState.error.message)} + + + ) : null} + + ) + }} + /> + ) + } +) + +FormInput.displayName = 'FormInput' diff --git a/packages/design-system/lib/components/Form/FormInput/input.module.css b/packages/design-system/lib/components/Form/FormInput/input.module.css new file mode 100644 index 000000000..1cb01bcf9 --- /dev/null +++ b/packages/design-system/lib/components/Form/FormInput/input.module.css @@ -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); +} diff --git a/packages/design-system/lib/components/Form/FormInput/input.ts b/packages/design-system/lib/components/Form/FormInput/input.ts new file mode 100644 index 000000000..fd5620b6d --- /dev/null +++ b/packages/design-system/lib/components/Form/FormInput/input.ts @@ -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' +} diff --git a/packages/design-system/lib/components/Form/Phone/index.tsx b/packages/design-system/lib/components/Form/Phone/index.tsx index d14fa18fe..afac2eb9e 100644 --- a/packages/design-system/lib/components/Form/Phone/index.tsx +++ b/packages/design-system/lib/components/Form/Phone/index.tsx @@ -16,8 +16,8 @@ import { import { ErrorMessage } from '../ErrorMessage' import { MaterialIcon } from '../../Icons/MaterialIcon' -import { Input } from '../../Input' -import { Label } from '../../Label' +import { Input } from '../../InputNew' +import { InputLabel } from '../../InputLabel' import styles from './phone.module.css' @@ -100,13 +100,13 @@ export default function Phone({ type="button" data-testid="country-selector" > - + {props.children} diff --git a/packages/design-system/lib/components/Form/TextArea/index.tsx b/packages/design-system/lib/components/Form/TextArea/index.tsx index 7959fab5e..6c11e6ee3 100644 --- a/packages/design-system/lib/components/Form/TextArea/index.tsx +++ b/packages/design-system/lib/components/Form/TextArea/index.tsx @@ -9,7 +9,7 @@ import { import { Controller, useFormContext } from 'react-hook-form' import { MaterialIcon } from '../../Icons/MaterialIcon' -import { Label } from '../../Label' +import { InputLabel } from '../../InputLabel' import { Typography } from '../../Typography' import styles from './textarea.module.css' @@ -57,7 +57,9 @@ export default function TextArea({ className={styles.textarea} /> - + + {label} + {helpText && !fieldState.error ? ( diff --git a/packages/design-system/lib/components/Form/utils/index.ts b/packages/design-system/lib/components/Form/utils/index.ts new file mode 100644 index 000000000..1e7ae5168 --- /dev/null +++ b/packages/design-system/lib/components/Form/utils/index.ts @@ -0,0 +1 @@ +export { mergeRefs } from './mergeRefs' diff --git a/packages/design-system/lib/components/Form/utils/mergeRefs.ts b/packages/design-system/lib/components/Form/utils/mergeRefs.ts new file mode 100644 index 000000000..0e2199366 --- /dev/null +++ b/packages/design-system/lib/components/Form/utils/mergeRefs.ts @@ -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 + * + * ``` + */ +export function mergeRefs( + ...refs: Array | undefined> +): RefCallback { + return (node: T | null) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + }) + } +} diff --git a/packages/design-system/lib/components/IconButton/IconButton.stories.tsx b/packages/design-system/lib/components/IconButton/IconButton.stories.tsx index 5aa768d95..71ad53444 100644 --- a/packages/design-system/lib/components/IconButton/IconButton.stories.tsx +++ b/packages/design-system/lib/components/IconButton/IconButton.stories.tsx @@ -1,6 +1,6 @@ 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 { IconButton } from './IconButton' @@ -60,7 +60,7 @@ type Story = StoryObj export const Default: Story = { args: { - onPress: () => alert('Icon button pressed!'), + onPress: fn(), children: , }, play: async ({ canvas, userEvent, args }) => { @@ -73,10 +73,11 @@ export const Primary: Story = { args: { ...Default.args, theme: 'Primary', + onPress: fn(), // Fresh spy instance }, play: async ({ canvas, userEvent, args }) => { 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: { ...Primary.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(canvas.getByRole('button')) @@ -109,6 +111,7 @@ export const InvertedDisabled: Story = { args: { ...Inverted.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(canvas.getByRole('button')) @@ -131,6 +134,7 @@ export const InvertedElevatedDisabled: Story = { args: { ...InvertedElevated.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(canvas.getByRole('button')) @@ -157,6 +161,7 @@ export const InvertedMutedDisabled: Story = { args: { ...InvertedMuted.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { @@ -180,6 +185,7 @@ export const InvertedFadedDisabled: Story = { args: { ...InvertedFaded.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(canvas.getByRole('button')) @@ -204,6 +210,7 @@ export const TertiaryDisabled: Story = { args: { ...TertiaryElevated.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(canvas.getByRole('button')) @@ -227,6 +234,7 @@ export const BlackMutedDisabled: Story = { args: { ...BlackMuted.args, isDisabled: true, + onPress: fn(), // Fresh spy instance for disabled test }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(canvas.getByRole('button')) diff --git a/packages/design-system/lib/components/Input/Input.tsx b/packages/design-system/lib/components/Input/Input.tsx index 06ca8b309..8a7c17b71 100644 --- a/packages/design-system/lib/components/Input/Input.tsx +++ b/packages/design-system/lib/components/Input/Input.tsx @@ -8,7 +8,7 @@ import { } from 'react' 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' @@ -40,7 +40,7 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent( id={inputId} /> - + {label} ) }) diff --git a/packages/design-system/lib/components/Label/Label.stories.tsx b/packages/design-system/lib/components/InputLabel/InputLabel.stories.tsx similarity index 67% rename from packages/design-system/lib/components/Label/Label.stories.tsx rename to packages/design-system/lib/components/InputLabel/InputLabel.stories.tsx index ddb800e70..68c512c32 100644 --- a/packages/design-system/lib/components/Label/Label.stories.tsx +++ b/packages/design-system/lib/components/InputLabel/InputLabel.stories.tsx @@ -1,16 +1,16 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { Label } from './Label' +import { InputLabel } from './InputLabel' -const meta: Meta = { - title: 'Components/Label', - component: Label, +const meta: Meta = { + title: 'Components/InputLabel', + component: InputLabel, argTypes: {}, } export default meta -type Story = StoryObj +type Story = StoryObj export const Default: Story = { args: { diff --git a/packages/design-system/lib/components/Label/Label.tsx b/packages/design-system/lib/components/InputLabel/InputLabel.tsx similarity index 51% rename from packages/design-system/lib/components/Label/Label.tsx rename to packages/design-system/lib/components/InputLabel/InputLabel.tsx index 1cf769890..dd3493ec6 100644 --- a/packages/design-system/lib/components/Label/Label.tsx +++ b/packages/design-system/lib/components/InputLabel/InputLabel.tsx @@ -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, className, selected, required, disabled, size, -}: LabelProps) { - const classNames = labelVariants({ - className, +}: InputLabelProps) { + const classNames = inputLabelVariants({ size, required, selected, disabled, + className, }) return {children} diff --git a/packages/design-system/lib/components/InputLabel/index.tsx b/packages/design-system/lib/components/InputLabel/index.tsx new file mode 100644 index 000000000..944026214 --- /dev/null +++ b/packages/design-system/lib/components/InputLabel/index.tsx @@ -0,0 +1 @@ +export { InputLabel } from './InputLabel' diff --git a/packages/design-system/lib/components/Label/label.module.css b/packages/design-system/lib/components/InputLabel/inputLabel.module.css similarity index 75% rename from packages/design-system/lib/components/Label/label.module.css rename to packages/design-system/lib/components/InputLabel/inputLabel.module.css index d246b3fc2..311af10e8 100644 --- a/packages/design-system/lib/components/Label/label.module.css +++ b/packages/design-system/lib/components/InputLabel/inputLabel.module.css @@ -1,4 +1,4 @@ -.label { +.inputLabel { font-family: var(--Body-Paragraph-Font-family), var(--Body-Paragraph-Font-fallback); font-size: var(--Body-Paragraph-Size); @@ -43,12 +43,12 @@ content: ' *'; } -input:focus ~ .label, -input:placeholder-shown ~ .label, -input[value]:not([value='']) ~ .label, -textarea:focus ~ .label, -textarea:placeholder-shown ~ .label, -textarea[value]:not([value='']) ~ .label, +input:focus ~ .inputLabel, +input:placeholder-shown ~ .inputLabel, +input[value]:not([value='']) ~ .inputLabel, +textarea:focus ~ .inputLabel, +textarea:placeholder-shown ~ .inputLabel, +textarea[value]:not([value='']) ~ .inputLabel, .selected { font-family: var(--Label-Font-family), var(--Label-Font-fallback); font-size: var(--Label-Size); @@ -61,15 +61,15 @@ textarea[value]:not([value='']) ~ .label, margin-bottom: var(--Space-x025); } -.label.disabled, -input:read-only ~ .label, -input:disabled ~ .label, -textarea:disabled ~ .label { +.inputLabel.disabled, +input:read-only ~ .inputLabel, +input:disabled ~ .inputLabel, +textarea:disabled ~ .inputLabel { color: var(--Text-Interactive-Disabled); } @media (hover: hover) { - input:active:not(:disabled) ~ .label { + input:active:not(:disabled) ~ .inputLabel { font-family: var(--Label-Font-family), var(--Label-Font-fallback); font-size: var(--Label-Size); font-weight: var(--Label-Font-weight); @@ -83,15 +83,15 @@ textarea:disabled ~ .label { } /* Legacy selector for deprecated select component */ -:global(.select-container)[data-disabled] .label { +:global(.select-container)[data-disabled] .inputLabel { color: var(--Text-Interactive-Disabled); } -:global(.select-button) .label { +:global(.select-button) .inputLabel { order: unset; } -:global(.select-container)[data-open='true'] .label:not(.discreet), -:global(.react-aria-SelectValue):has(:nth-child(2)) .label:not(.discreet) { +:global(.select-container)[data-open='true'] .inputLabel:not(.discreet), +:global(.react-aria-SelectValue):has(:nth-child(2)) .inputLabel:not(.discreet) { font-size: 12px; } diff --git a/packages/design-system/lib/components/InputLabel/types.ts b/packages/design-system/lib/components/InputLabel/types.ts new file mode 100644 index 000000000..e7f6c15b2 --- /dev/null +++ b/packages/design-system/lib/components/InputLabel/types.ts @@ -0,0 +1,10 @@ +import type { VariantProps } from 'class-variance-authority' + +import type { inputLabelVariants } from './variants' + +export interface InputLabelProps + extends + React.PropsWithChildren>, + VariantProps { + required?: boolean +} diff --git a/packages/design-system/lib/components/Label/variants.ts b/packages/design-system/lib/components/InputLabel/variants.ts similarity index 79% rename from packages/design-system/lib/components/Label/variants.ts rename to packages/design-system/lib/components/InputLabel/variants.ts index 55572b1b6..62d3b749d 100644 --- a/packages/design-system/lib/components/Label/variants.ts +++ b/packages/design-system/lib/components/InputLabel/variants.ts @@ -1,8 +1,8 @@ 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: { size: { small: styles.small, diff --git a/packages/design-system/lib/components/InputNew/Input.stories.tsx b/packages/design-system/lib/components/InputNew/Input.stories.tsx new file mode 100644 index 000000000..a4e1b3393 --- /dev/null +++ b/packages/design-system/lib/components/InputNew/Input.stories.tsx @@ -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 = { + title: 'Components/Input (New)', + // @ts-expect-error Input does not support this, but wrapping does + component: ({ isInvalid, validationState, ...props }) => ( + + + + ), + 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 + +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: , + rightIcon: , + 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: , + 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: , + rightIcon: , + 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 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 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 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 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 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 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 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 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() + }, +} diff --git a/packages/design-system/lib/components/InputNew/Input.test.tsx b/packages/design-system/lib/components/InputNew/Input.test.tsx new file mode 100644 index 000000000..e341b9ce6 --- /dev/null +++ b/packages/design-system/lib/components/InputNew/Input.test.tsx @@ -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) => { + return render( + + + + ) +} + +// Render Input standalone (without TextField) for testing Input's own behavior +const renderInputStandalone = (props: React.ComponentProps) => { + return render() +} + +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: 🔍, + }) + 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: 👁, + }) + 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: 👁, + }) + 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: 👁, + }) + 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( + + + + ) + expect(ref.current).toBeInstanceOf(HTMLInputElement) + }) + + it('allows focusing via ref', () => { + const ref = { current: null as HTMLInputElement | null } + render( + + + + ) + ref.current?.focus() + expect(document.activeElement).toBe(ref.current) + }) + }) +}) diff --git a/packages/design-system/lib/components/InputNew/Input.tsx b/packages/design-system/lib/components/InputNew/Input.tsx new file mode 100644 index 000000000..55db3726b --- /dev/null +++ b/packages/design-system/lib/components/InputNew/Input.tsx @@ -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 +) { + // Create an internal ref that we can access + const internalRef = useRef(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 ( + <> + + {label} + +
+ {leftIcon && ( +
{leftIcon}
+ )} + + + {showClearContentIcon && hasValue && ( +
+ + + +
+ )} + {rightIcon && !(showClearContentIcon && hasValue) && ( +
{rightIcon}
+ )} +
+ + ) + } + + // Floating label (default behavior) - label inside container + return ( +
+ {leftIcon &&
{leftIcon}
} + + + + + {label} + + {showClearContentIcon && hasValue && ( +
+ + + +
+ )} + {rightIcon && !(showClearContentIcon && hasValue) && ( +
{rightIcon}
+ )} +
+ ) +}) + +export const Input = InputComponent as React.ForwardRefExoticComponent< + InputProps & React.RefAttributes +> diff --git a/packages/design-system/lib/components/InputNew/index.tsx b/packages/design-system/lib/components/InputNew/index.tsx new file mode 100644 index 000000000..3188ccc6a --- /dev/null +++ b/packages/design-system/lib/components/InputNew/index.tsx @@ -0,0 +1 @@ +export { Input } from './Input' diff --git a/packages/design-system/lib/components/InputNew/input.module.css b/packages/design-system/lib/components/InputNew/input.module.css new file mode 100644 index 000000000..e8831c788 --- /dev/null +++ b/packages/design-system/lib/components/InputNew/input.module.css @@ -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; + } +} diff --git a/packages/design-system/lib/components/InputNew/types.ts b/packages/design-system/lib/components/InputNew/types.ts new file mode 100644 index 000000000..dbd0ce62b --- /dev/null +++ b/packages/design-system/lib/components/InputNew/types.ts @@ -0,0 +1,11 @@ +import { ComponentProps } from 'react' +import { Input } from 'react-aria-components' + +export interface InputProps extends ComponentProps { + label: string + labelPosition?: 'floating' | 'top' + leftIcon?: React.ReactNode + rightIcon?: React.ReactNode + onRightIconClick?: () => void + showClearContentIcon?: boolean +} diff --git a/packages/design-system/lib/components/InputNew/utils.test.ts b/packages/design-system/lib/components/InputNew/utils.test.ts new file mode 100644 index 000000000..4c1d3e578 --- /dev/null +++ b/packages/design-system/lib/components/InputNew/utils.test.ts @@ -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() + }) +}) diff --git a/packages/design-system/lib/components/InputNew/utils.ts b/packages/design-system/lib/components/InputNew/utils.ts new file mode 100644 index 000000000..a54605b70 --- /dev/null +++ b/packages/design-system/lib/components/InputNew/utils.ts @@ -0,0 +1,120 @@ +import type { ChangeEvent, ChangeEventHandler, RefObject } from 'react' +import { useState, useEffect } from 'react' + +interface ClearInputOptions { + inputRef: RefObject + onChange?: ChangeEventHandler + 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 + 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 +): 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 +} diff --git a/packages/design-system/lib/components/Label/index.tsx b/packages/design-system/lib/components/Label/index.tsx deleted file mode 100644 index 717144f33..000000000 --- a/packages/design-system/lib/components/Label/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { Label } from './Label' diff --git a/packages/design-system/lib/components/Label/types.ts b/packages/design-system/lib/components/Label/types.ts deleted file mode 100644 index f38e947dd..000000000 --- a/packages/design-system/lib/components/Label/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { VariantProps } from 'class-variance-authority' - -import type { labelVariants } from './variants' - -export interface LabelProps - extends React.PropsWithChildren>, - VariantProps { - required?: boolean -} diff --git a/packages/design-system/lib/components/Select/Select.tsx b/packages/design-system/lib/components/Select/Select.tsx index 92e574798..0e6664836 100644 --- a/packages/design-system/lib/components/Select/Select.tsx +++ b/packages/design-system/lib/components/Select/Select.tsx @@ -16,7 +16,7 @@ import type { SelectProps, SelectFilterProps } from './types' import styles from './select.module.css' import { useState } from 'react' -import { Label } from '../Label' +import { InputLabel } from '../InputLabel' export function Select({ name, @@ -65,12 +65,12 @@ export function Select({ {({ selectedText }) => { return ( <> - + {selectedText} diff --git a/packages/design-system/lib/components/Select/SelectFilter.tsx b/packages/design-system/lib/components/Select/SelectFilter.tsx index bb958efa1..e89530c37 100644 --- a/packages/design-system/lib/components/Select/SelectFilter.tsx +++ b/packages/design-system/lib/components/Select/SelectFilter.tsx @@ -26,7 +26,7 @@ import { SelectItem } from './SelectItem' import type { SelectFilterProps } from './types' import styles from './select.module.css' -import { Label } from '../Label' +import { InputLabel } from '../InputLabel' /** * ComboBoxInner @@ -136,9 +136,12 @@ export function SelectFilter({ ) : null} - +