Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/text-input-merge-input-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': patch
---

Fix `inputProps` on `TextInput`, `TextArea`, `PasswordInput`, `SearchInput`, `NumberInput` and `CommandTextArea`. Each of these passes its own React Aria props down under the same `inputProps` name as the caller's, and one side was overwriting the other wholesale: `TextInput`, `TextArea`, `NumberInput` and `CommandTextArea` dropped the caller's `inputProps` entirely, while `PasswordInput` and `SearchInput` did the opposite and replaced the hook's value tracking, ids and ARIA attributes with it. The two are now merged, so a key you set wins and event handlers are chained rather than replaced.
37 changes: 21 additions & 16 deletions src/components/fields/CommandTextArea/CommandTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ function CommandTextArea<T extends object>(
maxRows = 10,
rows = 3,
labelProps: userLabelProps,
inputProps: userInputProps,
inputRef: propsInputRef,
value,
defaultValue,
Expand Down Expand Up @@ -650,22 +651,26 @@ function CommandTextArea<T extends object>(
};

// ---- assemble input props ---------------------------------------------
const commandInputProps = mergeProps(inputProps, {
role: 'combobox',
'aria-autocomplete': 'list',
'aria-expanded': shouldShowPopover,
'aria-haspopup': 'listbox',
'aria-controls': shouldShowPopover ? listBoxId : undefined,
'aria-activedescendant':
shouldShowPopover && focusedKey != null
? `ListBoxItem-${focusedKey}`
: undefined,
onKeyDown: onKeyDownHandler,
onSelect: syncCaret,
onKeyUp: syncCaret,
onClick: syncCaret,
'data-input-type': 'command-textarea',
});
const commandInputProps = mergeProps(
inputProps,
{
role: 'combobox',
'aria-autocomplete': 'list',
'aria-expanded': shouldShowPopover,
'aria-haspopup': 'listbox',
'aria-controls': shouldShowPopover ? listBoxId : undefined,
'aria-activedescendant':
shouldShowPopover && focusedKey != null
? `ListBoxItem-${focusedKey}`
: undefined,
onKeyDown: onKeyDownHandler,
onSelect: syncCaret,
onKeyUp: syncCaret,
onClick: syncCaret,
'data-input-type': 'command-textarea',
},
userInputProps,
);

// ---- render -----------------------------------------------------------
const field = (
Expand Down
2 changes: 1 addition & 1 deletion src/components/fields/NumberInput/NumberInput.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ A number input component that allows users to enter numeric values and increment
- **`isWheelDisabled`** `boolean` — Whether scrolling the mouse wheel changes the value
- **`decrementAriaLabel`** `string` — Custom aria-label for the decrement button
- **`incrementAriaLabel`** `string` — Custom aria-label for the increment button
- **`inputProps`** `Props` — Direct props passed to the native input element
- **`inputProps`** `Props` — Direct props passed to the native input element, merged over the props React Aria generates: a key you set wins, and event handlers are chained rather than replaced
- **`wrapperProps`** `Props` — Direct props passed to the input wrapper element
- **`onChange`** `function` — Callback fired when the input value changes
- **`onBlur`** `function` — Callback fired when the input loses focus
Expand Down
7 changes: 6 additions & 1 deletion src/components/fields/NumberInput/NumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function NumberInput(
onChange,
inputRef,
labelProps: userLabelProps,
inputProps: userInputProps,
...otherProps
} = props;
let showStepper = !hideStepper;
Expand Down Expand Up @@ -94,7 +95,11 @@ function NumberInput(
{...otherProps}
ref={ref}
labelProps={mergedLabelProps}
inputProps={{ ...inputProps, 'data-input-type': 'numberinput' }}
inputProps={mergeProps(
inputProps,
{ 'data-input-type': 'numberinput' },
userInputProps,
)}
inputRef={inputRef}
wrapperProps={groupProps}
suffixPosition="after"
Expand Down
2 changes: 1 addition & 1 deletion src/components/fields/PasswordInput/PasswordInput.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ A password input is a specialized text field that masks user input to protect se
- **`suffixPosition`** `'before' | 'after'` (default: `after`) — Position of suffix relative to validation/loading icons
- **`size`** `'small' | 'medium' | 'large'` (default: `medium`) — Input size
- **`autoComplete`** `string` — HTML [`autocomplete`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) token that tells the browser what to autofill (`current-password`, `new-password`, `off`, …). The lowercase `autocomplete` alias is deprecated.
- **`inputProps`** `Props` — Direct props passed to the native input element
- **`inputProps`** `Props` — Direct props passed to the native input element, merged over the props React Aria generates: a key you set wins, and event handlers are chained rather than replaced
- **`wrapperProps`** `Props` — Direct props passed to the input wrapper element
- **`onChange`** `function` — Callback fired when the input value changes
- **`onBlur`** `function` — Callback fired when the input loses focus
Expand Down
7 changes: 6 additions & 1 deletion src/components/fields/PasswordInput/PasswordInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function PasswordInput(
let [type, setType] = useState('password');
let {
labelProps: userLabelProps,
inputProps: userInputProps,
suffix,
multiLine,
inputRef: propsInputRef,
Expand Down Expand Up @@ -101,7 +102,11 @@ function PasswordInput(
<TextInputBase
ref={ref}
labelProps={mergedLabelProps}
inputProps={{ ...inputProps, 'data-input-type': 'passwordinput' }}
inputProps={mergeProps(
inputProps,
{ 'data-input-type': 'passwordinput' },
userInputProps,
)}
inputRef={inputRef}
inputStyles={{ paddingRight: '4x' }}
type={type}
Expand Down
2 changes: 1 addition & 1 deletion src/components/fields/SearchInput/SearchInput.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ A search input provides a specialized text field for search functionality, featu
- **`isClearable`** `boolean` (default: `false`) — Whether the search input shows a clear button
- **`size`** `'small' | 'medium' | 'large'` (default: `medium`) — Input size
- **`autoComplete`** `string` — HTML [`autocomplete`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) token that tells the browser what to autofill (`off` to suppress autofill on a filter field). The lowercase `autocomplete` alias is deprecated.
- **`inputProps`** `Props` — Direct props passed to the native input element
- **`inputProps`** `Props` — Direct props passed to the native input element, merged over the props React Aria generates: a key you set wins, and event handlers are chained rather than replaced
- **`wrapperProps`** `Props` — Direct props passed to the input wrapper element
- **`onChange`** `function` — Callback fired when the search value changes
- **`onSubmit`** `function` — Callback fired when search is submitted
Expand Down
7 changes: 6 additions & 1 deletion src/components/fields/SearchInput/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const SearchInput = forwardRef(function SearchInput(
isValid,
onClear,
labelProps: userLabelProps,
inputProps: userInputProps,
isBuffered,
...restProps
} = props;
Expand Down Expand Up @@ -95,7 +96,11 @@ export const SearchInput = forwardRef(function SearchInput(
<TextInputBase
ref={ref}
labelProps={mergedLabelProps}
inputProps={{ ...inputProps, 'data-input-type': 'searchinput' }}
inputProps={mergeProps(
inputProps,
{ 'data-input-type': 'searchinput' },
userInputProps,
)}
inputRef={inputRef}
type="search"
icon={<SearchIcon />}
Expand Down
2 changes: 1 addition & 1 deletion src/components/fields/TextArea/TextArea.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ These properties allow direct style application without using the `styles` prop:
- **`autoComplete`** `string` — HTML [`autocomplete`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) token that tells the browser what to autofill (`street-address`, `off`, …). The lowercase `autocomplete` alias is deprecated.
- **`maxLength`** `number` — Maximum number of characters allowed
- **`minLength`** `number` — Minimum number of characters required
- **`inputProps`** `Props` — Direct HTML props passed to the inner textarea element
- **`inputProps`** `Props` — Direct HTML props passed to the inner textarea element, merged over the props React Aria generates: a key you set wins, and event handlers are chained rather than replaced
- **`wrapperProps`** `Props` — Additional HTML props passed to the input wrapper container
- **`inputRef`** `RefObject<HTMLTextAreaElement>` — Ref to the inner textarea element
- **`wrapperRef`** `RefObject<HTMLDivElement>` — Ref to the input wrapper container
Expand Down
7 changes: 6 additions & 1 deletion src/components/fields/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function TextArea(
maxRows = 10,
rows = 3,
labelProps: userLabelProps,
inputProps: userInputProps,
inputRef: propsInputRef,
value,
isBuffered,
Expand Down Expand Up @@ -99,7 +100,11 @@ function TextArea(
multiLine
inputRef={inputRef}
labelProps={mergedLabelProps}
inputProps={{ ...inputProps, 'data-input-type': 'textarea' }}
inputProps={mergeProps(
inputProps,
{ 'data-input-type': 'textarea' },
userInputProps,
)}
isDisabled={isDisabled}
isReadOnly={isReadOnly}
isRequired={isRequired}
Expand Down
2 changes: 1 addition & 1 deletion src/components/fields/TextInput/TextInput.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ A text input component that allows users to input custom text entries with a key
- **`autoComplete`** `string` — HTML [`autocomplete`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) token that tells the browser what to autofill (`email`, `username`, `off`, …). The lowercase `autocomplete` alias is deprecated.
- **`maxLength`** `number` — Maximum number of characters allowed
- **`minLength`** `number` — Minimum number of characters required
- **`inputProps`** `Props` — Direct HTML props passed to the inner input element
- **`inputProps`** `Props` — Direct HTML props passed to the inner input element, merged over the props React Aria generates: a key you set wins, and event handlers are chained rather than replaced
- **`inputRef`** `RefObject<HTMLInputElement | HTMLTextAreaElement>` — Ref to the inner input element
- **`wrapperRef`** `RefObject<HTMLDivElement>` — Ref to the input wrapper container
- **`loadingIndicator`** `ReactNode` — Custom loading indicator element
Expand Down
3 changes: 2 additions & 1 deletion src/components/fields/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const TextInput = forwardRef(function TextInput(

let {
labelProps: userLabelProps,
inputProps: userInputProps,
inputRef: propsInputRef,
form,
isBuffered,
Expand Down Expand Up @@ -68,7 +69,7 @@ export const TextInput = forwardRef(function TextInput(
{...restProps}
ref={ref}
labelProps={mergedLabelProps}
inputProps={inputProps}
inputProps={mergeProps(inputProps, userInputProps)}
inputRef={inputRef}
/>
);
Expand Down
120 changes: 120 additions & 0 deletions src/components/fields/input-props.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
CommandTextArea,
NumberInput,
PasswordInput,
SearchInput,
TextArea,
TextInput,
} from '../../index';
import { act, renderWithRoot, userEvent } from '../../test';

import type { Props } from '../../props';

/** Every field built on `TextInputBase` marks its control with `data-input-type`. */
function getInput(container: HTMLElement) {
return container.querySelector<HTMLInputElement | HTMLTextAreaElement>(
'input[data-input-type], textarea[data-input-type]',
)!;
}

const FIELDS = [
{
name: 'TextInput',
render: (props: Props) => <TextInput label="field" {...props} />,
},
{
name: 'TextArea',
render: (props: Props) => <TextArea label="field" {...props} />,
},
{
name: 'PasswordInput',
render: (props: Props) => <PasswordInput label="field" {...props} />,
},
{
name: 'SearchInput',
render: (props: Props) => <SearchInput label="field" {...props} />,
},
{
name: 'NumberInput',
render: (props: Props) => <NumberInput label="field" {...props} />,
},
{
name: 'CommandTextArea',
render: (props: Props) => <CommandTextArea label="field" {...props} />,
},
] as const;

describe('inputProps', () => {
describe.each(FIELDS)('<$name />', ({ render }) => {
it('should pass caller props through to the input element', () => {
const { container } = renderWithRoot(
render({
inputProps: { 'data-custom': 'yes', autoCapitalize: 'none' },
}),
);

const input = getInput(container);

expect(input).toHaveAttribute('data-custom', 'yes');
expect(input).toHaveAttribute('autocapitalize', 'none');
});

it('should keep the React Aria wiring when inputProps is set', async () => {
// The hook's own props used to be replaced wholesale by the caller's `inputProps` (or the
// other way round, depending on the component), which broke value tracking and the ids that
// tie the input to its label.
const { container } = renderWithRoot(
render({ inputProps: { 'data-custom': 'yes' } }),
);

const input = getInput(container);

await act(async () => await userEvent.type(input, '1'));

expect(input).toHaveValue('1');
expect(input).toHaveAttribute('id');
});

it('should chain a handler the hook sets on the same key', async () => {
// `onChange` is set by the React Aria hook on every one of these fields, so a caller's own
// handler on that key is the case where one side silently replacing the other would show.
const onChange = vi.fn();
const { container } = renderWithRoot(
render({ inputProps: { onChange } }),
);

const input = getInput(container);

await act(async () => await userEvent.type(input, '1'));

expect(onChange).toHaveBeenCalled();
expect(input).toHaveValue('1');
});
});

it('should let inputProps carry an autoComplete through TextInput', () => {
const { container } = renderWithRoot(
<TextInput
label="field"
inputProps={{ autoComplete: 'one-time-code' }}
/>,
);

expect(getInput(container)).toHaveAttribute(
'autocomplete',
'one-time-code',
);
});

it('should let an explicit prop win over the same key in inputProps', () => {
const { container } = renderWithRoot(
<TextInput
label="field"
autoComplete="email"
inputProps={{ autoComplete: 'one-time-code' }}
/>,
);

expect(getInput(container)).toHaveAttribute('autocomplete', 'email');
});
});
Loading