diff --git a/.changeset/hot-llamas-fry.md b/.changeset/hot-llamas-fry.md new file mode 100644 index 0000000000..cdcf98b38d --- /dev/null +++ b/.changeset/hot-llamas-fry.md @@ -0,0 +1,6 @@ +--- +"@ultraviolet/form": patch +--- + +- Minor fixes to `` component +- Fixed issue with `` component preventing the input to be edited diff --git a/.changeset/shy-parrots-matter.md b/.changeset/shy-parrots-matter.md new file mode 100644 index 0000000000..a65b0b0895 --- /dev/null +++ b/.changeset/shy-parrots-matter.md @@ -0,0 +1,16 @@ +--- +"@ultraviolet/ui": patch +--- + +Fix `` prop `onChange` will now take function with event. + +```tsx +// Before +onChange={value => value} + +// After +onChangeValue={value => value} +onChange={event => event.target.value} +``` + +This will also fix `` issues such as editing the input value. diff --git a/packages/form/src/components/TextInputFieldV2/index.tsx b/packages/form/src/components/TextInputFieldV2/index.tsx index 9b38078289..eefa3d8506 100644 --- a/packages/form/src/components/TextInputFieldV2/index.tsx +++ b/packages/form/src/components/TextInputFieldV2/index.tsx @@ -1,6 +1,6 @@ import { TextInputV2 } from '@ultraviolet/ui' import type { ComponentProps } from 'react' -import type { FieldPath, FieldValues, Path, PathValue } from 'react-hook-form' +import type { FieldPath, FieldValues } from 'react-hook-form' import { useController } from 'react-hook-form' import { useErrors } from '../../providers' import type { BaseFieldProps } from '../../types' @@ -10,10 +10,7 @@ type TextInputFieldProps< TFieldValues extends FieldValues, TFieldName extends FieldPath, > = BaseFieldProps & - Omit< - ComponentProps, - 'value' | 'error' | 'name' | 'onChange' - > & { + Omit, 'value' | 'error' | 'name'> & { regex?: (RegExp | RegExp[])[] } @@ -116,7 +113,7 @@ export const TextInputField = < }} onChange={event => { field.onChange(event) - onChange?.(event as PathValue>) + onChange?.(event) }} onFocus={event => { onFocus?.(event) @@ -128,7 +125,7 @@ export const TextInputField = < tabIndex={tabIndex} tooltip={tooltip} type={type} - value={field.value} + value={field.value === undefined ? '' : field.value} id={id} prefix={prefix} suffix={suffix} diff --git a/packages/ui/src/components/DateInput/__stories__/Localized.stories.tsx b/packages/ui/src/components/DateInput/__stories__/Localized.stories.tsx index f878077bcc..d65c1651a3 100644 --- a/packages/ui/src/components/DateInput/__stories__/Localized.stories.tsx +++ b/packages/ui/src/components/DateInput/__stories__/Localized.stories.tsx @@ -12,7 +12,13 @@ const locales = [ export const Localized = (props: ComponentProps) => locales.map(({ label, locale }) => ( - {}} label={label} locale={locale} /> + {}} + label={label} + locale={locale} + /> )) Localized.args = Template.args diff --git a/packages/ui/src/components/DateInput/__stories__/Size.stories.tsx b/packages/ui/src/components/DateInput/__stories__/Size.stories.tsx index 2fad76eac0..69acf72812 100644 --- a/packages/ui/src/components/DateInput/__stories__/Size.stories.tsx +++ b/packages/ui/src/components/DateInput/__stories__/Size.stories.tsx @@ -5,7 +5,7 @@ import { Stack } from '../../Stack' export const Size: StoryFn = args => ( {(['small', 'medium', 'large'] as const).map(size => ( - + ))} ) diff --git a/packages/ui/src/components/DateInput/index.tsx b/packages/ui/src/components/DateInput/index.tsx index c58a1eba56..09c650ac92 100644 --- a/packages/ui/src/components/DateInput/index.tsx +++ b/packages/ui/src/components/DateInput/index.tsx @@ -303,7 +303,7 @@ export const DateInput = ({ id={localId} label={label} labelDescription={labelDescription} - value={valueFormat || ''} + value={valueFormat} disabled={disabled} size={size} suffix={ diff --git a/packages/ui/src/components/SearchInput/index.tsx b/packages/ui/src/components/SearchInput/index.tsx index 75180fe59c..a0eb4c4a16 100644 --- a/packages/ui/src/components/SearchInput/index.tsx +++ b/packages/ui/src/components/SearchInput/index.tsx @@ -287,7 +287,7 @@ export const SearchInput = forwardRef( label={label} placeholder={placeholder} loading={loading} - onChange={onSearchCallback} + onChange={event => onSearchCallback(event.target.value)} clearable disabled={disabled} className={className} diff --git a/packages/ui/src/components/SelectInputV2/SearchBarDropdown.tsx b/packages/ui/src/components/SelectInputV2/SearchBarDropdown.tsx index a31196ce7a..5f5de52583 100644 --- a/packages/ui/src/components/SelectInputV2/SearchBarDropdown.tsx +++ b/packages/ui/src/components/SelectInputV2/SearchBarDropdown.tsx @@ -143,7 +143,7 @@ export const SearchBarDropdown = ({ return ( handleChange(event)} + onChange={event => handleChange(event.target.value)} placeholder={placeholder} onFocus={() => setSearchBarActive(true)} onBlur={() => setSearchBarActive(false)} diff --git a/packages/ui/src/components/TextInputV2/__stories__/ControlledVSUncontrolled.stories.tsx b/packages/ui/src/components/TextInputV2/__stories__/ControlledVSUncontrolled.stories.tsx new file mode 100644 index 0000000000..e2b9ff5b94 --- /dev/null +++ b/packages/ui/src/components/TextInputV2/__stories__/ControlledVSUncontrolled.stories.tsx @@ -0,0 +1,38 @@ +import type { StoryFn } from '@storybook/react' +import { useState } from 'react' +import { TextInputV2 } from '..' +import { Stack } from '../../Stack' +import { Text } from '../../Text' + +export const ControlledVSUncontrolled: StoryFn = props => { + const [value, setValue] = useState('content') + + return ( + + + + setValue(event.target.value)} + {...props} + /> + + We can get the value from the input, which is:{' '} + + {value} + + + + + ) +} + +ControlledVSUncontrolled.parameters = { + docs: { + description: { + story: + 'The component can be controlled or uncontrolled.\n\n The difference is that in the controlled version, the `value` and `onChange` is passed as a prop and the component does not manage its own state.\n\n In the uncontrolled version, the component manages its own state. For more information check [React documentation](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components).', + }, + }, +} diff --git a/packages/ui/src/components/TextInputV2/__stories__/Examples.stories.tsx b/packages/ui/src/components/TextInputV2/__stories__/Examples.stories.tsx index 9dcd3c8a4b..fd92caa742 100644 --- a/packages/ui/src/components/TextInputV2/__stories__/Examples.stories.tsx +++ b/packages/ui/src/components/TextInputV2/__stories__/Examples.stories.tsx @@ -16,14 +16,14 @@ export const Examples: StoryFn = args => { label="With prefix" prefix="https://" value={value} - onChange={setValue} + onChange={event => setValue(event.target.value)} /> setValue(event.target.value)} onRandomize={() => setValue(randomName())} /> = args => { label="Password input with random hook" prefix="https://" value={value} - onChange={setValue} + onChange={event => setValue(event.target.value)} onRandomize={() => setValue(randomName())} type="password" /> @@ -41,7 +41,7 @@ export const Examples: StoryFn = args => { prefix="https://" suffix=".com" value={value} - onChange={setValue} + onChange={event => setValue(event.target.value)} onRandomize={() => setValue(randomName())} success="Field has been updated!" loading @@ -53,7 +53,7 @@ export const Examples: StoryFn = args => { disabled helper="Notice to fill the field" value={value} - onChange={setValue} + onChange={event => setValue(event.target.value)} onRandomize={() => setValue(randomName())} loading /> @@ -69,7 +69,7 @@ export const Examples: StoryFn = args => { } value={value} - onChange={setValue} + onChange={event => setValue(event.target.value)} onRandomize={() => setValue(randomName())} loading /> diff --git a/packages/ui/src/components/TextInputV2/__stories__/OnRandomize.stories.tsx b/packages/ui/src/components/TextInputV2/__stories__/OnRandomize.stories.tsx index a71d566ac2..465cd73e5b 100644 --- a/packages/ui/src/components/TextInputV2/__stories__/OnRandomize.stories.tsx +++ b/packages/ui/src/components/TextInputV2/__stories__/OnRandomize.stories.tsx @@ -9,7 +9,7 @@ export const OnRandomize: StoryFn = ({ ...args }) => { setValue(event.target.value)} onRandomize={() => { setValue(`randomValue-${Math.round(Math.random() * 1000)}`) }} diff --git a/packages/ui/src/components/TextInputV2/__stories__/Size.stories.tsx b/packages/ui/src/components/TextInputV2/__stories__/Size.stories.tsx index d71e240d93..9940cf3fcd 100644 --- a/packages/ui/src/components/TextInputV2/__stories__/Size.stories.tsx +++ b/packages/ui/src/components/TextInputV2/__stories__/Size.stories.tsx @@ -15,10 +15,11 @@ export const Size: StoryFn = args => { ).map(size => ( setValue(event.target.value)} placeholder="Placeholder" /> ))} diff --git a/packages/ui/src/components/TextInputV2/__stories__/Template.stories.tsx b/packages/ui/src/components/TextInputV2/__stories__/Template.stories.tsx index 79b1e72c0f..f07d6c54bb 100644 --- a/packages/ui/src/components/TextInputV2/__stories__/Template.stories.tsx +++ b/packages/ui/src/components/TextInputV2/__stories__/Template.stories.tsx @@ -5,7 +5,13 @@ import { TextInputV2 } from '..' export const Template: StoryFn = ({ ...args }) => { const [value, setValue] = useState(args.value) - return + return ( + setValue(event.target.value)} + /> + ) } Template.args = { diff --git a/packages/ui/src/components/TextInputV2/__stories__/index.stories.tsx b/packages/ui/src/components/TextInputV2/__stories__/index.stories.tsx index 61ff4c20da..ad6c7d56f6 100644 --- a/packages/ui/src/components/TextInputV2/__stories__/index.stories.tsx +++ b/packages/ui/src/components/TextInputV2/__stories__/index.stories.tsx @@ -19,4 +19,5 @@ export { ReadOnly } from './ReadOnly.stories' export { Loading } from './Loading.stories' export { Success } from './Success.stories' export { Error } from './Error.stories' +export { ControlledVSUncontrolled } from './ControlledVSUncontrolled.stories' export { Examples } from './Examples.stories' diff --git a/packages/ui/src/components/TextInputV2/__tests__/index.test.tsx b/packages/ui/src/components/TextInputV2/__tests__/index.test.tsx index 04e9a66748..b4d41dd050 100644 --- a/packages/ui/src/components/TextInputV2/__tests__/index.test.tsx +++ b/packages/ui/src/components/TextInputV2/__tests__/index.test.tsx @@ -12,16 +12,22 @@ describe('TextInputV2', () => { test('should control the value', () => { const onChange = vi.fn() + const onChangeValue = vi.fn() renderWithTheme( - , + , ) const textarea = screen.getByLabelText('Test') expect(textarea.value).toBe('test') - // userEvent.type do not work here at the moment fireEvent.change(textarea, { target: { value: 'another value' } }) - expect(onChange).toHaveBeenCalledWith('another value') + expect(onChange).toHaveBeenCalled() + expect(onChangeValue).toHaveBeenCalledWith('another value') }) test('should be clearable', async () => { @@ -35,7 +41,10 @@ describe('TextInputV2', () => { expect(textarea.value).toBe('test') const clearableButton = screen.getByLabelText('clear value') await userEvent.click(clearableButton) - expect(onChange).toHaveBeenCalledWith('') + expect(onChange).toHaveBeenCalledWith({ + target: { value: '' }, + currentTarget: { value: '' }, + }) }) test('should render correctly when input is disabled', () => diff --git a/packages/ui/src/components/TextInputV2/index.tsx b/packages/ui/src/components/TextInputV2/index.tsx index 154d71abf1..cff4d7e675 100644 --- a/packages/ui/src/components/TextInputV2/index.tsx +++ b/packages/ui/src/components/TextInputV2/index.tsx @@ -1,7 +1,20 @@ import styled from '@emotion/styled' import { Icon } from '@ultraviolet/icons' -import type { InputHTMLAttributes, ReactNode } from 'react' -import { forwardRef, useId, useMemo, useState } from 'react' +import type { + ChangeEvent, + ChangeEventHandler, + InputHTMLAttributes, + ReactNode, +} from 'react' +import { + forwardRef, + useCallback, + useId, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import { Button } from '../Button' import { Loader } from '../Loader' import { Stack } from '../Stack' @@ -129,7 +142,6 @@ type TextInputProps = { minLength?: number maxLength?: number onRandomize?: () => void - onChange?: (newValue: string) => void prefix?: ReactNode size?: TextInputSize success?: string | boolean @@ -137,6 +149,8 @@ type TextInputProps = { tooltip?: string type?: 'text' | 'password' | 'url' | 'email' value?: string + defaultValue?: string + onChangeValue?: (value: string) => void } & Pick< InputHTMLAttributes, | 'onFocus' @@ -156,10 +170,13 @@ type TextInputProps = { | 'role' | 'aria-live' | 'aria-atomic' + | 'onChange' > /** - * This component offers an extended input HTML + * This component offers an extended input HTML. The component can be controlled or uncontrolled. + * To control the component, you need to pass the value and the `onChange` function. + * If you don't pass the `onChange` function, the component will be uncontrolled and you can set the default value using `defaultValue` */ export const TextInputV2 = forwardRef( ( @@ -169,6 +186,7 @@ export const TextInputV2 = forwardRef( tabIndex, value, onChange, + onChangeValue, placeholder, disabled = false, readOnly = false, @@ -200,11 +218,14 @@ export const TextInputV2 = forwardRef( role, 'aria-live': ariaLive, 'aria-atomic': ariaAtomic, + defaultValue, }, ref, ) => { const localId = useId() const [hasFocus, setHasFocus] = useState(false) + const inputRef = useRef(null) + useImperativeHandle(ref, () => inputRef.current as HTMLInputElement) const [isPasswordVisible, setIsPasswordVisible] = useState(false) const computedType = @@ -222,6 +243,14 @@ export const TextInputV2 = forwardRef( return 'neutral' }, [error, success]) + const onChangeCallback: ChangeEventHandler = useCallback( + event => { + onChange?.(event) + onChangeValue?.(event.target.value) + }, + [onChange, onChangeValue], + ) + const computedClearable = clearable && !!value return ( @@ -246,7 +275,7 @@ export const TextInputV2 = forwardRef( {label} {required ? ( - + ) : null} ) : null} @@ -286,11 +315,10 @@ export const TextInputV2 = forwardRef( tabIndex={tabIndex} autoFocus={autoFocus} disabled={disabled} - ref={ref} - value={value === null || value === undefined ? '' : value} - onChange={event => { - onChange?.(event.currentTarget.value) - }} + ref={inputRef} + value={value} + defaultValue={defaultValue} + onChange={onChangeCallback} data-size={size} placeholder={placeholder} data-testid={dataTestId} @@ -322,7 +350,13 @@ export const TextInputV2 = forwardRef( size={size === 'small' ? 'xsmall' : 'small'} icon="close" onClick={() => { - onChange?.('') + if (inputRef?.current) { + inputRef.current.value = '' + onChangeCallback({ + target: { value: '' }, + currentTarget: { value: '' }, + } as ChangeEvent) + } }} sentiment="neutral" />