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
9 changes: 9 additions & 0 deletions apps/docs/src/remix-hook-form/text-field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field';
import { Button } from '@lambdacurry/forms/ui/button';
import type { Meta, StoryContext, StoryObj } from '@storybook/react-vite';
import { useRef } from 'react';
import { type ActionFunctionArgs, useFetcher } from 'react-router';
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { expect, userEvent } from 'storybook/test';
Expand Down Expand Up @@ -38,6 +39,8 @@ const ControlledTextFieldExample = () => {
},
});

const ref = useRef<HTMLInputElement>(null);

return (
<RemixFormProvider {...methods}>
<fetcher.Form onSubmit={methods.handleSubmit}>
Expand All @@ -58,6 +61,12 @@ const ControlledTextFieldExample = () => {
suffix="cm"
/>

<div className="flex gap-2 items-end">
<TextField name="refExample" label="Ref Example" placeholder="Enter something, this has a ref" ref={ref} />

<Button onClick={() => ref.current?.focus()}>Focus the input</Button>
</div>

<Button type="submit" className="mt-4">
Submit
</Button>
Expand Down
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lambdacurry/forms",
"version": "0.15.1",
"version": "0.15.2",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
9 changes: 6 additions & 3 deletions packages/components/src/remix-hook-form/text-field.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type * as React from 'react';
import { TextField as BaseTextField, type TextInputProps as BaseTextFieldProps } from '../ui/text-field';
import { FormControl, FormDescription, FormLabel, FormMessage } from './form';

import { useRemixFormContext } from 'remix-hook-form';

export type TextFieldProps = Omit<BaseTextFieldProps, 'control'>;

export function TextField(props: TextFieldProps) {
export const TextField = function RemixTextField(props: TextFieldProps & { ref?: React.Ref<HTMLInputElement> }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect suggestion! Let's apply the React 19 pattern here too. The remix-hook-form TextField should also use the ref-as-prop pattern:

import type * as React from 'react';
import { TextField as BaseTextField, type TextInputProps as BaseTextFieldProps } from '../ui/text-field';
import { FormControl, FormDescription, FormLabel, FormMessage } from './form';
import { useRemixFormContext } from 'remix-hook-form';

export type TextFieldProps = Omit<BaseTextFieldProps, 'control'> & {
  ref?: React.Ref<HTMLInputElement>;
};

export const TextField = function RemixTextField({ ref, ...props }: TextFieldProps) {
  const { control } = useRemixFormContext();

  // Merge the provided components with the default form components
  const defaultComponents = {
    FormControl,
    FormLabel,
    FormDescription,
    FormMessage,
  };

  const components = {
    ...defaultComponents,
    ...props.components,
  };

  return <BaseTextField control={control} components={components} ref={ref} {...props} />;
};

TextField.displayName = 'RemixTextField';

This maintains consistency with the React 19 pattern across all TextField components!

const { control } = useRemixFormContext();

// Merge the provided components with the default form components
Expand All @@ -15,11 +16,13 @@ export function TextField(props: TextFieldProps) {
FormDescription,
FormMessage,
};

const components = {
...defaultComponents,
...props.components,
};

return <BaseTextField control={control} components={components} {...props} />;
}
};

TextField.displayName = 'RemixTextField';
8 changes: 5 additions & 3 deletions packages/components/src/ui/text-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ export interface TextInputProps extends Omit<InputProps, 'prefix' | 'suffix'> {
label?: string;
description?: string;
components?: Partial<FieldComponents> & {
Input?: React.ComponentType<InputProps>;
Input?: React.ComponentType<InputProps & React.RefAttributes<HTMLInputElement>>;
};
prefix?: React.ReactNode;
suffix?: React.ReactNode;
className?: string;
}

export const TextField = ({
export const TextField = function TextField({
control,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly! Let's apply the React 19 ref-as-prop pattern here as well. This will be much cleaner:

export interface TextInputProps extends Omit<InputProps, 'prefix' | 'suffix'> {
  control: Control<any>;
  name: string;
  label?: string;
  description?: string;
  components?: Partial<FieldComponents> & {
    Input?: React.ComponentType<InputProps & { ref?: React.Ref<HTMLInputElement> }>;
  };
  prefix?: React.ReactNode;
  suffix?: React.ReactNode;
  className?: string;
  ref?: React.Ref<HTMLInputElement>;
}

export const TextField = function TextField({
  control,
  name,
  label,
  description,
  className,
  components,
  prefix,
  suffix,
  ref,
  ...props
}: TextInputProps) {
  // ... rest of implementation
  return (
    <Controller
      control={control}
      name={name}
      render={({ field, fieldState }) => (
        // ... existing JSX with ref passed to InputComponent
        <InputComponent
          {...field}
          {...props}
          ref={ref}
          // ... rest of props
        />
      )}
    />
  );
};

This eliminates the need for forwardRef and makes the component much more straightforward!

name,
label,
Expand All @@ -73,8 +73,9 @@ export const TextField = ({
components,
prefix,
suffix,
ref,
...props
}: TextInputProps) => {
}: TextInputProps & { ref?: React.Ref<HTMLInputElement> }) {
// Use the custom Input component if provided, otherwise use the default TextInput
const InputComponent = components?.Input || TextInput;

Expand All @@ -98,6 +99,7 @@ export const TextField = ({
<InputComponent
{...field}
{...props}
ref={ref}
className={cn('focus-visible:ring-0 focus-visible:ring-offset-0 border-input', {
'rounded-l-none border-l-0': prefix,
'rounded-r-none border-r-0': suffix,
Expand Down
9 changes: 7 additions & 2 deletions packages/components/src/ui/text-input.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type * as React from 'react';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right! This should remain as a type import for better TypeScript optimization and smaller bundle size. The type import tells TypeScript this is only used for type checking and won't be included in the runtime bundle.

import type * as React from 'react';

import { cn } from './utils';

export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
ref?: React.Ref<HTMLInputElement>;
}

function TextInput({ className, type, ...props }: InputProps) {
function TextInput({ className, type, ref, ...props }: InputProps) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect suggestion! With React 19, we can eliminate forwardRef entirely and use the much cleaner ref-as-prop pattern. Here's how this should look:

export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  ref?: React.Ref<HTMLInputElement>;
}

function TextInput({ className, type, ref, ...props }: InputProps) {
  return (
    <input
      ref={ref}
      type={type}
      className={cn(
        'flex h-10 w-full text-base sm:text-sm rounded-md border border-input bg-background px-3 py-2 ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
        className,
      )}
      {...props}
    />
  );
}

This is much cleaner and leverages React 19's native ref prop support!

return (
<input
ref={ref}
type={type}
className={cn(
'flex h-10 w-full text-base sm:text-sm rounded-md border border-input bg-background px-3 py-2 ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
Expand All @@ -17,4 +20,6 @@ function TextInput({ className, type, ...props }: InputProps) {
);
}

TextInput.displayName = 'TextInput';

export { TextInput };