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
18 changes: 18 additions & 0 deletions .changeset/typed-input-family-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@eventuras/ratio-ui": minor
---

Type the Input family's props: `InputProps`/`InputFieldProps` now extend
`InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>`, so every real DOM
prop — including `onChange` — is declared and autocompletes, and handler
parameters infer under `strict` without hand annotations. Existing handlers
annotated with the narrower element types remain assignable.

The `[x: string]: any` index signature is kept for backwards compatibility but
is now `@deprecated`; it will be removed in the next major, at which point prop
typos stop compiling.

`TextField` moves from `forwardRef` to React 19 ref-as-prop (required for the
declared prop types to survive; `PropsWithoutRef` collapses named props into
the index signature). The public contract is unchanged: `ref?: Ref<HTMLElement>`
receiving the underlying `<input>`/`<textarea>`.
51 changes: 16 additions & 35 deletions packages/ratio-ui/src/forms/Input/InputProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
// SPDX-FileCopyrightText: 2026 Losol AS
// SPDX-License-Identifier: MPL-2.0

import type { InputHTMLAttributes } from 'react';

/**
* Defines the types of input elements supported, based on standard HTML input types.
* This type restriction helps in ensuring that only valid HTML input types can be used.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types For more information on HTML input types.
* Standard HTML input types.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types
*/
export type ValidInputTypes =
| 'button'
Expand All @@ -32,48 +32,29 @@ export type ValidInputTypes =
| 'url'
| 'week';

/** The element an input-family field may render — `<textarea>` when `multiline`. */
export type InputLikeElement = HTMLInputElement | HTMLTextAreaElement;

/**
* Generic interface for primitive input component properties.
* This represents the base properties for input elements without composite features like labels.
*
* @property {string} [id] - Optional. Unique identifier for the input element.
* @property {string} name - Required. Name of the input element, used for form submission.
* @property {ValidInputTypes} [type='text'] - Optional. Specifies the type of input. Defaults to 'text'.
* @property {string} [placeholder] - Optional. Hint to the user about what to enter in the input.
* @property {string} [className] - Optional. Additional CSS classes for styling the input element.
* @property {string} [testId] - Optional. Attribute for identifying elements in tests.
* @property {boolean} [disabled] - Optional. Whether the input is disabled.
* @property {boolean} [required] - Optional. Whether the input is required.
* @property {[x: string]: any} - Optional. Allows for any other properties not explicitly defined, ensuring flexibility.
* Base props for primitive inputs. Instantiated with `InputLikeElement` so
* event handlers accept events from either element `TextField` may render.
*/
export interface InputProps {
id?: string;
export interface InputProps extends InputHTMLAttributes<InputLikeElement> {
/** Used for form submission and `errors` lookup. */
name: string;
type?: ValidInputTypes;
placeholder?: string;
className?: string;
/** Rendered as `data-testid`. */
testId?: string;
disabled?: boolean;
required?: boolean;
/** @deprecated Will be removed in the next major — declare real props instead. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional until v3
[x: string]: any;
}

/**
* Props for composite input field components (TextField, NumberField, Select, etc).
* Extends InputProps with label, description, and error handling for complete form fields.
*
* @property {string} [label] - Optional. Text label associated with the input element.
* @property {string} [description] - Optional. Description of the input element, providing additional context.
* @property {{ [key: string]: { message: string } }} [errors] - Optional. Object containing error messages, keyed by input names.
* @property {boolean} [multiline] - Optional. Whether to render as textarea instead of input.
* @property {number} [rows] - Optional. Number of rows for textarea.
* @property {number} [cols] - Optional. Number of columns for textarea.
* @property {boolean} [noMargin] - Optional. Remove default margin.
* @property {boolean} [noWrapper] - Optional. Remove wrapper div.
*/
/** Props for composite form fields with label, description, and error handling. */
export interface InputFieldProps extends InputProps {
label?: string;
description?: string;
/** Keyed by input name (react-hook-form compatible). */
errors?: { [key: string]: { message: string } };
noMargin?: boolean;
noWrapper?: boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/ratio-ui/src/forms/Input/TextField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const WithError: Story = {
name: 'email',
label: 'Email Address',
placeholder: 'Enter your email',
errors: { email: 'Invalid email address' },
errors: { email: { message: 'Invalid email address' } },
},
};

Expand Down
203 changes: 101 additions & 102 deletions packages/ratio-ui/src/forms/Input/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
// SPDX-License-Identifier: MPL-2.0

import React, {
forwardRef,
InputHTMLAttributes,
TextareaHTMLAttributes,
} from 'react';
Expand All @@ -26,13 +25,17 @@ interface ExtendedInputProps extends InputFieldProps {
* @beta Experimental — renders the beta `CopyButton`; behaviour may change.
*/
showCopyToClipboard?: boolean;
/**
* Ref to the underlying `<input>`/`<textarea>`. Must stay a plain prop:
* `forwardRef`'s `PropsWithoutRef` collapses named props into the index
* signature `InputProps` still carries, degrading every prop to `any`.
*/
ref?: React.Ref<HTMLElement>;
}

type CommonProps = InputHTMLAttributes<HTMLInputElement> &
TextareaHTMLAttributes<HTMLTextAreaElement> & {
'aria-invalid'?: boolean;
'data-testid'?: string;
[key: string]: any;
};

/**
Expand All @@ -58,114 +61,110 @@ type CommonProps = InputHTMLAttributes<HTMLInputElement> &
* />
* ```
*/
export const TextField = forwardRef<HTMLElement, ExtendedInputProps>(
(
{
name,
type = 'text',
placeholder,
label,
description,
className,
errors,
disabled,
multiline = false,
rows,
cols,
noMargin = false,
noWrapper = false,
showCopyToClipboard = false,
testId,
...rest
},
forwardedRef
) => {
const hasError = errors?.[name];
export function TextField({
name,
type = 'text',
placeholder,
label,
description,
className,
errors,
disabled,
multiline = false,
rows,
cols,
noMargin = false,
noWrapper = false,
showCopyToClipboard = false,
testId,
ref: forwardedRef,
...rest
}: ExtendedInputProps) {
const hasError = errors?.[name];

let inputClassName = `${className ?? formStyles.defaultInputStyle} ${
hasError ? formStyles.inputErrorGlow : ''
} ${disabled ? 'cursor-not-allowed' : ''} ${
showCopyToClipboard ? 'pr-10' : ''
}`;
let inputClassName = `${className ?? formStyles.defaultInputStyle} ${
hasError ? formStyles.inputErrorGlow : ''
} ${disabled ? 'cursor-not-allowed' : ''} ${
showCopyToClipboard ? 'pr-10' : ''
}`;

if (multiline) {
inputClassName = `${inputClassName} ${formStyles.textarea}`;
}

const id = rest.id ?? name;
if (multiline) {
inputClassName = `${inputClassName} ${formStyles.textarea}`;
}

const assignRef = (
element: HTMLInputElement | HTMLTextAreaElement | null
) => {
if (typeof forwardedRef === 'function') {
forwardedRef(element);
} else if (forwardedRef && 'current' in forwardedRef) {
(forwardedRef as React.MutableRefObject<
HTMLInputElement | HTMLTextAreaElement | null
>).current = element;
}
};
const id = rest.id ?? name;

const commonProps: CommonProps = {
id,
className: inputClassName,
placeholder,
disabled,
'aria-invalid': hasError ? true : undefined,
'data-testid': testId,
name,
...rest,
};
const assignRef = (
element: HTMLInputElement | HTMLTextAreaElement | null
) => {
if (typeof forwardedRef === 'function') {
forwardedRef(element);
} else if (forwardedRef && 'current' in forwardedRef) {
(forwardedRef as React.RefObject<
HTMLInputElement | HTMLTextAreaElement | null
>).current = element;
}
};

const inputElement = multiline ? (
<textarea
ref={assignRef as React.Ref<HTMLTextAreaElement>}
rows={rows ?? 3}
{...commonProps}
/>
) : (
<input
ref={assignRef as React.Ref<HTMLInputElement>}
type={type}
{...commonProps}
/>
);
const commonProps: CommonProps = {
id,
className: inputClassName,
placeholder,
disabled,
'aria-invalid': hasError ? true : undefined,
'data-testid': testId,
name,
...rest,
};

const copyValue = rest.value ?? rest.defaultValue ?? '';
const fieldControl = showCopyToClipboard ? (
<div className="relative">
{inputElement}
<span
className={`absolute right-1 ${
multiline ? 'top-1' : 'inset-y-0 flex items-center'
}`}
>
<CopyButton
value={String(copyValue)}
size="sm"
ariaLabel={label ? `Copy ${label}` : 'Copy to clipboard'}
/>
</span>
</div>
) : (
inputElement
);
const inputElement = multiline ? (
<textarea
ref={assignRef as React.Ref<HTMLTextAreaElement>}
rows={rows ?? 3}
{...commonProps}
/>
) : (
<input
ref={assignRef as React.Ref<HTMLInputElement>}
type={type}
{...commonProps}
/>
);

const content = (
<>
{label && <Label htmlFor={id}>{label}</Label>}
{description && <InputDescription>{description}</InputDescription>}
{fieldControl}
{hasError && <InputError errors={errors} name={name} />}
</>
);
const copyValue = rest.value ?? rest.defaultValue ?? '';
const fieldControl = showCopyToClipboard ? (
<div className="relative">
{inputElement}
<span
className={`absolute right-1 ${
multiline ? 'top-1' : 'inset-y-0 flex items-center'
}`}
>
<CopyButton
value={String(copyValue)}
size="sm"
ariaLabel={label ? `Copy ${label}` : 'Copy to clipboard'}
/>
</span>
</div>
) : (
inputElement
);

if (noWrapper) {
return content;
}
const content = (
<>
{label && <Label htmlFor={id}>{label}</Label>}
{description && <InputDescription>{description}</InputDescription>}
{fieldControl}
{hasError && <InputError errors={errors} name={name} />}
</>
);

return <div className={formStyles.inputWrapper}>{content}</div>;
if (noWrapper) {
return content;
}
);

return <div className={formStyles.inputWrapper}>{content}</div>;
}

TextField.displayName = 'TextField';
Loading