Skip to content

Instantly share code, notes, and snippets.

@ellemedit
Last active July 15, 2024 04:53
Show Gist options
  • Save ellemedit/236761bdc754ea762d605ad56f004909 to your computer and use it in GitHub Desktop.
Save ellemedit/236761bdc754ea762d605ad56f004909 to your computer and use it in GitHub Desktop.
[React 19][React Compiler] Accessible and Declarative Form Component
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import {
FormDescription,
FormError,
FormField,
FormInput,
FormLabel,
} from "./FormField";
test("FormField", async () => {
const fixture = render(
<FormField>
<FormLabel>Test</FormLabel>
<FormInput />
</FormField>,
);
const textbox = screen.getByLabelText("Test");
expect(textbox).toBeInTheDocument();
expect(textbox).toHaveRole("textbox");
fixture.rerender(
<FormField>
<FormLabel>Test</FormLabel>
<FormInput />
<FormDescription>Test Description</FormDescription>
<FormError>Test Error</FormError>
</FormField>,
);
expect(textbox).toHaveAccessibleDescription("Test Description");
expect(textbox).toBeInvalid();
expect(textbox).toHaveAccessibleErrorMessage("Test Error");
fixture.rerender(
<FormField>
<FormLabel>Test</FormLabel>
<FormInput />
<FormError>Test Error 2</FormError>
</FormField>,
);
expect(textbox).toHaveAccessibleErrorMessage("Test Error 2");
expect(textbox).toBeInvalid();
expect(textbox).not.toHaveAccessibleDescription();
fixture.rerender(
<FormField>
<FormLabel>Test</FormLabel>
<FormInput />
</FormField>,
);
expect(textbox).not.toHaveAccessibleErrorMessage();
expect(textbox).toBeValid();
expect(textbox).not.toHaveAccessibleDescription();
});
"use client";
import {
ComponentPropsWithRef,
ReactNode,
createContext,
use,
useEffect,
useId,
useState,
} from "react";
const FormFieldStateContext = createContext({
id: "__this_is_not_a_valid_id__",
hasError: false,
hasDescription: false,
markHasError() {
console.error(
"This is a bug. You perhaps not wrapped your component into FormField",
);
},
markHasNoError() {
console.error(
"This is a bug. You perhaps not wrapped your component into FormField",
);
},
markHasDescription() {
console.error(
"This is a bug. You perhaps not wrapped your component into FormField",
);
},
markHasNoDescription() {
console.error(
"This is a bug. You perhaps not wrapped your component into FormField",
);
},
});
interface FormFieldProps extends ComponentPropsWithRef<"div"> {
render?: (props: ComponentPropsWithRef<"div">) => ReactNode;
}
export function FormField({ render, ...otherProps }: FormFieldProps) {
const id = useId();
const [hasError, setHasError] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
function markHasError() {
setHasError(true);
}
function markHasNoError() {
setHasError(false);
}
function markHasDescription() {
setHasDescription(true);
}
function markHasNoDescription() {
setHasDescription(false);
}
// we leverage context memoization with react compiler
const fieldStateContext = {
id,
hasError,
hasDescription,
markHasError,
markHasNoError,
markHasDescription,
markHasNoDescription,
};
return (
<FormFieldStateContext.Provider value={fieldStateContext}>
{render ? render(otherProps) : <div {...otherProps} />}
</FormFieldStateContext.Provider>
);
}
export function FormLabel(props: ComponentPropsWithRef<"label">) {
const { id } = use(FormFieldStateContext);
return <label htmlFor={`${id}-input`} {...props} />;
}
export function FormInput(props: ComponentPropsWithRef<"input">) {
const { id, hasError, hasDescription } = use(FormFieldStateContext);
return (
<input
id={`${id}-input`}
aria-errormessage={hasError ? `${id}-error` : void 0}
aria-invalid={hasError}
aria-describedby={hasDescription ? `${id}-description` : void 0}
{...props}
/>
);
}
export function FormSelect(props: ComponentPropsWithRef<"select">) {
const { id, hasError, hasDescription } = use(FormFieldStateContext);
return (
<select
id={`${id}-input`}
aria-errormessage={hasError ? `${id}-error` : void 0}
aria-invalid={hasError}
aria-describedby={hasDescription ? `${id}-description` : void 0}
{...props}
/>
);
}
export function FormTextarea(props: ComponentPropsWithRef<"textarea">) {
const { id, hasError, hasDescription } = use(FormFieldStateContext);
return (
<textarea
id={`${id}-input`}
aria-errormessage={hasError ? `${id}-error` : void 0}
aria-invalid={hasError}
aria-describedby={hasDescription ? `${id}-description` : void 0}
{...props}
/>
);
}
export function FormError(props: ComponentPropsWithRef<"p">) {
const { id, markHasError, markHasNoError } = use(FormFieldStateContext);
useEffect(() => {
if (!props.children) {
// skip if children is falsy
return;
}
// this ensure mark no error when unmount component
markHasError();
return () => {
markHasNoError();
};
}, [id, markHasError, markHasNoError, props.children]);
return <p id={`${id}-error`} {...props} />;
}
export function FormDescription(props: ComponentPropsWithRef<"p">) {
const { id, markHasDescription, markHasNoDescription } = use(
FormFieldStateContext,
);
useEffect(() => {
if (!props.children) {
// skip if children is falsy
return;
}
// this ensure mark no description when unmount component
markHasDescription();
return () => {
markHasNoDescription();
};
}, [markHasDescription, markHasNoDescription, props.children]);
return <p id={`${id}-description`} {...props} />;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment