Merged in feat/use-new-input-component (pull request #3324)

feat(SW-3659): Use new input component

* Use new input component

* Update error formatter

* Merged master into feat/use-new-input-component

* Merged master into feat/use-new-input-component

* Merge branch 'master' into feat/use-new-input-component

* Merged master into feat/use-new-input-component

* Update Input stories

* Merge branch 'feat/use-new-input-component' of bitbucket.org:scandic-swap/web into feat/use-new-input-component

* Update Storybook logo

* Add some new demo icon input story

* Fix the clear content button position

* Fix broken password input icon

* Merged master into feat/use-new-input-component

* Merged master into feat/use-new-input-component

* Add aria-hidden to required asterisk

* Merge branch 'feat/use-new-input-component' of bitbucket.org:scandic-swap/web into feat/use-new-input-component

* Merge branch 'master' into feat/use-new-input-component


Approved-by: Bianca Widstam
Approved-by: Matilda Landström
This commit is contained in:
Rasmus Langvad
2025-12-18 15:42:09 +00:00
parent 40e1efa81f
commit b9a62b5280
34 changed files with 520 additions and 1113 deletions

View File

@@ -4,16 +4,133 @@ import { expect } from 'storybook/test'
import { Input } from './Input'
import { TextField } from 'react-aria-components'
import { MaterialIcon } from '../Icons/MaterialIcon'
import type { SymbolCodepoints } from '../Icons/MaterialIcon/MaterialSymbol/types'
const meta: Meta<typeof Input> = {
title: 'Core Components/Input',
// @ts-expect-error Input does not support this, but wrapping <TextField> does
component: ({ isInvalid, ...props }) => (
<TextField isInvalid={isInvalid}>
<Input {...props} />
component: ({ isInvalid, validationState, ...props }) => (
<TextField isInvalid={isInvalid} data-validation-state={validationState}>
<Input {...props} data-validation-state={validationState} />
</TextField>
),
argTypes: {},
argTypes: {
label: {
control: 'text',
description: 'The label text displayed for the input field',
table: {
type: { summary: 'string' },
},
},
labelPosition: {
control: 'select',
options: ['floating', 'top'],
description: 'Position of the label relative to the input',
table: {
type: { summary: "'floating' | 'top'" },
defaultValue: { summary: "'floating'" },
},
},
placeholder: {
control: 'text',
description: 'Placeholder text shown when input is empty',
table: {
type: { summary: 'string' },
defaultValue: { summary: 'undefined' },
},
},
required: {
control: 'boolean',
description: 'Whether the input is required',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
disabled: {
control: 'boolean',
description: 'Whether the input is disabled',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
showClearContentIcon: {
control: 'boolean',
description: 'Whether the clear content icon is shown',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
showLeftIcon: {
control: 'boolean',
description: 'Whether to show a left icon',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
showRightIcon: {
control: 'boolean',
description: 'Whether to show a right icon',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
leftIconName: {
control: 'select',
options: [
'calendar_month',
'credit_card',
'email',
'info_circle',
'location_on',
'lock',
'phone',
'search',
'sell',
'visibility',
'visibility_off',
],
description: 'Icon name for the left icon',
table: {
type: { summary: 'string' },
defaultValue: { summary: "'person'" },
},
},
rightIconName: {
control: 'select',
options: [
'calendar_month',
'credit_card',
'email',
'info_circle',
'location_on',
'lock',
'phone',
'search',
'sell',
'visibility',
'visibility_off',
],
description: 'Icon name for the right icon',
table: {
type: { summary: 'string' },
defaultValue: { summary: "'lock'" },
},
},
showWarning: {
control: 'boolean',
description: 'Whether to show warning validation state',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
} as any,
}
export default meta
@@ -25,8 +142,50 @@ export const Default: Story = {
label: 'Label',
name: 'foo',
required: false,
},
showLeftIcon: false,
showRightIcon: false,
leftIconName: 'person',
rightIconName: 'lock',
showWarning: false,
} as any,
render: (args) => {
// Extract custom Storybook args
const {
showLeftIcon,
showRightIcon,
leftIconName,
rightIconName,
showWarning,
...inputProps
} = args as typeof args & {
showLeftIcon?: boolean
showRightIcon?: boolean
leftIconName?: string
rightIconName?: string
showWarning?: boolean
}
const validationState = showWarning ? 'warning' : undefined
return (
<TextField data-validation-state={validationState}>
<Input
{...inputProps}
data-validation-state={validationState}
leftIcon={
showLeftIcon && leftIconName ? (
<MaterialIcon icon={leftIconName as SymbolCodepoints} />
) : undefined
}
rightIcon={
showRightIcon && rightIconName ? (
<MaterialIcon icon={rightIconName as SymbolCodepoints} />
) : undefined
}
/>
</TextField>
)
},
play: async ({ canvas, userEvent }) => {
const textbox = canvas.getByRole('textbox')
expect(textbox).not.toBeDisabled()
@@ -40,68 +199,3 @@ export const Default: Story = {
expect(textbox).toHaveValue('')
},
}
export const Filled: Story = {
args: {
label: 'Label',
name: 'foo',
value: 'Value',
},
play: async ({ canvas }) => {
const textbox = canvas.getByRole('textbox')
expect(textbox).toHaveValue('Value')
expect(textbox).not.toBeDisabled()
},
}
export const Error: Story = {
args: {
label: 'Label',
name: 'foo',
// @ts-expect-error Input does not support this, but wrapping <TextField> does
isInvalid: true,
},
play: async ({ canvas }) => {
const textbox = canvas.getByRole('textbox')
expect(textbox).toHaveAttribute('aria-invalid', 'true')
expect(textbox).not.toBeDisabled()
},
}
export const Disabled: Story = {
args: {
label: 'Label',
name: 'foo',
disabled: true,
},
play: async ({ canvas, userEvent }) => {
const textbox = canvas.getByRole('textbox')
expect(textbox).toHaveValue('')
expect(textbox).toBeDisabled()
await userEvent.type(textbox, 'Hello World')
expect(textbox).toHaveValue('')
},
}
export const DisabledFilled: Story = {
args: {
label: 'Label',
name: 'foo',
disabled: true,
value: 'Value',
},
play: async ({ canvas, userEvent }) => {
const textbox = canvas.getByRole('textbox')
expect(textbox).toHaveValue('Value')
expect(textbox).toBeDisabled()
await userEvent.type(textbox, 'Hello World')
expect(textbox).toHaveValue('Value')
},
}

View File

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

View File

@@ -12,36 +12,165 @@ import { InputLabel } from '../InputLabel'
import styles from './input.module.css'
import type { InputProps } from './types'
import { IconButton } from '../IconButton'
import { MaterialIcon } from '../Icons/MaterialIcon'
import { Typography } from '../Typography'
import type { InputProps } from './types'
import { clearInput, useInputHasValue } from './utils'
const InputComponent = forwardRef(function AriaInputWithLabelComponent(
{ label, ...props }: InputProps,
forwardedRef: ForwardedRef<HTMLInputElement>
{
label,
labelPosition = 'floating',
leftIcon,
rightIcon,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onRightIconClick,
showClearContentIcon,
placeholder,
id,
required,
'data-validation-state': validationState,
...props
}: InputProps & { 'data-validation-state'?: string },
ref: ForwardedRef<HTMLInputElement>
) {
const ref = useRef<HTMLInputElement>(null)
// Create an internal ref that we can access
const internalRef = useRef<HTMLInputElement>(null)
// Unique id is required for multiple inputs of same name appearing multiple times
// on same page. This will inherited by parent label element.
// Shouldn't really be needed if we don't set id though.
const uniqueId = useId()
const inputId = `${uniqueId}-${props.name}`
// Generate a unique ID for the input
// This is used to ensure the input is properly associated with the label
// when the label is positioned above the input
const generatedId = useId()
useImperativeHandle(forwardedRef, () => ref.current as HTMLInputElement)
// Forward the ref properly
useImperativeHandle(ref, () => internalRef.current!, [])
// Track whether input has a value (for showing/hiding clear button)
const hasValue = useInputHasValue(props.value, internalRef)
const onClearContent = () => {
clearInput({
inputRef: internalRef,
onChange: props.onChange,
value: props.value,
})
}
// When labelPosition is 'top', restructure to have label outside container
// We need an ID for proper label-input association
if (labelPosition === 'top') {
const inputId = id || generatedId
return (
<>
<InputLabel
required={required}
className={cx(
styles.labelAbove,
leftIcon && styles.labelAboveWithLeftIcon
)}
>
{label}
</InputLabel>
<div className={styles.inputContainer}>
{leftIcon && (
<div className={styles.leftIconContainer}>{leftIcon}</div>
)}
<label
htmlFor={inputId}
className={cx(
styles.container,
leftIcon && styles.containerWithLeftIcon,
rightIcon && styles.containerWithRightIcon
)}
data-validation-state={validationState}
>
<Typography variant="Body/Paragraph/mdRegular">
<AriaInput
{...props}
id={inputId}
required={required}
// Avoid duplicating label text in placeholder when label is positioned above
// Screen readers would announce the label twice (once as label, once as placeholder)
// Only use placeholder if explicitly provided, otherwise use empty string
placeholder={placeholder ?? ''}
className={cx(
styles.input,
styles.inputTopLabel,
props.className
)}
ref={internalRef}
/>
</Typography>
</label>
{showClearContentIcon && hasValue && (
<div className={styles.rightIconContainer}>
<IconButton
className={styles.rightIconButton}
variant="Muted"
emphasis
onPress={onClearContent}
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
aria-label="Clear content"
>
<MaterialIcon icon="cancel" />
</IconButton>
</div>
)}
{rightIcon && !(showClearContentIcon && hasValue) && (
<div className={styles.rightIconContainer}>{rightIcon}</div>
)}
</div>
</>
)
}
// Floating label (default behavior) - label inside container
return (
<AriaLabel className={styles.container}>
<Typography variant="Body/Paragraph/mdRegular">
<AriaInput
{...props}
placeholder={props.placeholder}
className={cx(styles.input, props.className)}
ref={ref}
id={inputId}
/>
</Typography>
<InputLabel required={props.required}>{label}</InputLabel>
</AriaLabel>
<div className={styles.inputContainer}>
{leftIcon && <div className={styles.leftIconContainer}>{leftIcon}</div>}
<AriaLabel
className={cx(
styles.container,
leftIcon && styles.containerWithLeftIcon,
rightIcon && styles.containerWithRightIcon
)}
data-validation-state={validationState}
>
<Typography variant="Body/Paragraph/mdRegular">
<AriaInput
{...props}
id={id}
required={required}
// For floating labels, only set placeholder if explicitly provided
// The label itself acts as the placeholder, so we don't want to duplicate it
// This ensures the label only floats when focused or has value
placeholder={placeholder}
className={cx(styles.input, props.className)}
ref={internalRef}
/>
</Typography>
<InputLabel required={required}>{label}</InputLabel>
</AriaLabel>
{showClearContentIcon && hasValue && (
<div className={styles.rightIconContainer}>
<IconButton
className={styles.rightIconButton}
variant="Muted"
emphasis
onPress={onClearContent}
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
aria-label="Clear content"
>
<MaterialIcon icon="cancel" />
</IconButton>
</div>
)}
{rightIcon && !(showClearContentIcon && hasValue) && (
<div className={styles.rightIconContainer}>{rightIcon}</div>
)}
</div>
)
})

View File

@@ -1,3 +1,13 @@
/* Label positioned above input (outside container) */
.labelAbove {
color: var(--Text-Default);
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
font-size: var(--Body-Supporting-text-Size);
font-weight: var(--Body-Supporting-text-Font-weight-2);
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
line-height: 1.5;
}
.container {
align-content: center;
background-color: var(--Surface-Primary-Default);
@@ -10,8 +20,13 @@
box-sizing: border-box;
cursor: text;
.container:has(.input:focus):not(:has(.input:disabled)),
.container:has(.input:focus):not(:has(.input:read-only)) {
&:has(.input:focus):not(:has(.input:disabled)):not(
:has(.input:read-only)
):not(:has(.input[data-invalid='true'])):not(
:has(.input[aria-invalid='true'])
):not(:has(.input[data-warning='true'])):not(
:has(.input[data-validation-state='warning'])
):not([data-validation-state='warning']) {
outline-offset: -2px;
outline: 2px solid var(--Border-Interactive-Focus);
}
@@ -26,11 +41,35 @@
&:has(.input[data-invalid='true'], .input[aria-invalid='true']) {
border-color: var(--Border-Interactive-Error);
&:focus-within {
&:focus-within,
&:has(.input:focus) {
outline-offset: -2px;
outline: 2px solid var(--Border-Interactive-Error);
border-color: var(--Border-Interactive-Error);
}
}
&:has(.input[data-warning='true']),
&:has(.input[data-validation-state='warning']),
&[data-validation-state='warning'] {
background-color: var(--Surface-Feedback-Warning-light);
border-color: var(--Border-Interactive-Focus);
&:focus-within,
&:has(.input:focus) {
outline-offset: -2px;
outline: 2px solid var(--Border-Interactive-Focus);
border-color: var(--Border-Interactive-Focus);
}
}
}
.containerWithLeftIcon {
padding-left: calc(var(--Space-x5) + 4px);
}
.containerWithRightIcon {
padding-right: calc(var(--Space-x5));
}
.input {
@@ -41,6 +80,7 @@
order: 2;
padding: 0;
transition: height 150ms ease;
width: 100%;
&:focus,
&:placeholder-shown,
@@ -56,6 +96,43 @@
}
}
/* Input with label on top - always has proper height */
.inputTopLabel {
height: 24px;
order: 2;
}
.inputContainer {
position: relative;
}
.leftIconContainer {
position: absolute;
width: 24px;
height: 24px;
top: 0;
bottom: 0;
margin: auto 0 auto var(--Space-x15);
}
.rightIconContainer {
position: absolute;
width: 24px;
height: 24px;
top: 0;
bottom: 0;
right: 0;
margin: auto var(--Space-x15) auto 0;
display: flex;
align-items: center;
justify-content: center;
}
.rightIconButton {
width: 24px;
height: 24px;
}
@media (hover: hover) {
.input:active:not(:disabled) {
height: 24px;

View File

@@ -3,4 +3,9 @@ import { Input } from 'react-aria-components'
export interface InputProps extends ComponentProps<typeof Input> {
label: string
labelPosition?: 'floating' | 'top'
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
onRightIconClick?: () => void
showClearContentIcon?: boolean
}

View File

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

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