Form component with Zod validation
Compound Form component (Root, Field, Label, Control, Description, ErrorMessage, Submit) with reactive error store, aria linking, and render-prop FormControl. 10 tests across rendering and validation suites.
This commit is contained in:
parent
8a248958f5
commit
896819526e
79
packages/core/src/components/form/form-context.ts
Normal file
79
packages/core/src/components/form/form-context.ts
Normal file
@ -0,0 +1,79 @@
|
||||
// packages/core/src/components/form/form-context.ts
|
||||
import type { Accessor } from "solid-js";
|
||||
import { createContext, useContext } from "solid-js";
|
||||
|
||||
// ─── Root Form Context ─────────────────────────────────────────────────────
|
||||
|
||||
export interface FormContextValue {
|
||||
/** Reactive map of field name -> error messages */
|
||||
errors: Accessor<Record<string, string[]>>;
|
||||
/** Get error messages for a specific field */
|
||||
getFieldErrors: (name: string) => string[];
|
||||
/** Register a field value (used by FormField on blur/change) */
|
||||
setFieldValue: (name: string, value: unknown) => void;
|
||||
/** Get the current value of a field */
|
||||
getFieldValue: (name: string) => unknown;
|
||||
/** Validate a single field against the schema */
|
||||
validateField: (name: string) => void;
|
||||
/** Validate all fields; returns true if valid */
|
||||
validateAll: () => boolean;
|
||||
/** Whether the entire form is disabled */
|
||||
disabled: Accessor<boolean>;
|
||||
/** When validation is triggered */
|
||||
validateOn: Accessor<"submit" | "blur" | "change">;
|
||||
}
|
||||
|
||||
const FormContext = createContext<FormContextValue>();
|
||||
|
||||
/**
|
||||
* Returns the internal Form context value.
|
||||
* Throws if used outside of a Form root.
|
||||
*/
|
||||
export function useFormContext(): FormContextValue {
|
||||
const ctx = useContext(FormContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"[PettyUI] Form parts must be used inside <Form>.\n" +
|
||||
" Fix: Wrap your Form.Field, Form.Submit, etc. inside <Form>.\n" +
|
||||
" Docs: https://pettyui.dev/components/form#composition",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const FormContextProvider = FormContext.Provider;
|
||||
|
||||
// ─── Per-Field Context ─────────────────────────────────────────────────────
|
||||
|
||||
export interface FormFieldContextValue {
|
||||
/** Field name, matches schema key */
|
||||
name: string;
|
||||
/** ID for the control element (used by label htmlFor) */
|
||||
controlId: string;
|
||||
/** ID for the description element */
|
||||
descriptionId: string;
|
||||
/** ID for the error message element */
|
||||
errorId: string;
|
||||
/** Reactive error messages for this field */
|
||||
errors: Accessor<string[]>;
|
||||
}
|
||||
|
||||
const FormFieldContext = createContext<FormFieldContextValue>();
|
||||
|
||||
/**
|
||||
* Returns the per-field Form context value.
|
||||
* Throws if used outside of a Form.Field.
|
||||
*/
|
||||
export function useFormFieldContext(): FormFieldContextValue {
|
||||
const ctx = useContext(FormFieldContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"[PettyUI] Form sub-components must be used inside <Form.Field>.\n" +
|
||||
" Fix: Wrap Form.Label, Form.Control, Form.ErrorMessage inside <Form.Field>.\n" +
|
||||
" Docs: https://pettyui.dev/components/form#composition",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const FormFieldContextProvider = FormFieldContext.Provider;
|
||||
35
packages/core/src/components/form/form-control.tsx
Normal file
35
packages/core/src/components/form/form-control.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
// packages/core/src/components/form/form-control.tsx
|
||||
import type { JSX } from "solid-js";
|
||||
import { useFormFieldContext } from "./form-context";
|
||||
import type { FormControlProps } from "./form.props";
|
||||
|
||||
export type { FormControlProps };
|
||||
|
||||
/** Builds the aria-describedby string combining description and error IDs. */
|
||||
function buildDescribedBy(descId: string, errorId: string, hasErrors: boolean): string | undefined {
|
||||
if (hasErrors) return `${descId} ${errorId}`;
|
||||
return descId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render-prop wrapper that injects accessible props (id, name, aria-describedby,
|
||||
* aria-invalid) into the field's control element.
|
||||
*/
|
||||
export function FormControl(props: FormControlProps): JSX.Element {
|
||||
const fieldCtx = useFormFieldContext();
|
||||
|
||||
const hasErrors = (): boolean => fieldCtx.errors().length > 0;
|
||||
const describedBy = (): string | undefined =>
|
||||
buildDescribedBy(fieldCtx.descriptionId, fieldCtx.errorId, hasErrors());
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.children({
|
||||
id: fieldCtx.controlId,
|
||||
name: fieldCtx.name,
|
||||
"aria-describedby": describedBy(),
|
||||
"aria-invalid": hasErrors() ? true : undefined,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
packages/core/src/components/form/form-description.tsx
Normal file
22
packages/core/src/components/form/form-description.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
// packages/core/src/components/form/form-description.tsx
|
||||
import type { JSX } from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
import { useFormFieldContext } from "./form-context";
|
||||
import type { FormDescriptionProps } from "./form.props";
|
||||
|
||||
export type { FormDescriptionProps };
|
||||
|
||||
/**
|
||||
* Renders a <p> element with the field's description ID so controls can
|
||||
* reference it via aria-describedby.
|
||||
*/
|
||||
export function FormDescription(props: FormDescriptionProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["children"]);
|
||||
const fieldCtx = useFormFieldContext();
|
||||
|
||||
return (
|
||||
<p id={fieldCtx.descriptionId} {...rest}>
|
||||
{local.children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
38
packages/core/src/components/form/form-error-message.tsx
Normal file
38
packages/core/src/components/form/form-error-message.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
// packages/core/src/components/form/form-error-message.tsx
|
||||
import type { JSX } from "solid-js";
|
||||
import { Show, splitProps } from "solid-js";
|
||||
import { useFormFieldContext } from "./form-context";
|
||||
import type { FormErrorMessageProps } from "./form.props";
|
||||
|
||||
export type { FormErrorMessageProps };
|
||||
|
||||
/** Resolves children — either static JSX or a render-prop function. */
|
||||
function resolveChildren(
|
||||
children: JSX.Element | ((errors: string[]) => JSX.Element),
|
||||
errors: string[],
|
||||
): JSX.Element {
|
||||
if (typeof children === "function") {
|
||||
return (children as (e: string[]) => JSX.Element)(errors);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a <p role="alert"> with field error messages. Only mounts when
|
||||
* the field has at least one error. Supports static children or a render prop
|
||||
* receiving the errors array.
|
||||
*/
|
||||
export function FormErrorMessage(props: FormErrorMessageProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["children"]);
|
||||
const fieldCtx = useFormFieldContext();
|
||||
|
||||
return (
|
||||
<Show when={fieldCtx.errors().length > 0}>
|
||||
<p id={fieldCtx.errorId} role="alert" {...rest}>
|
||||
{local.children !== undefined
|
||||
? resolveChildren(local.children, fieldCtx.errors())
|
||||
: fieldCtx.errors().join(", ")}
|
||||
</p>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
34
packages/core/src/components/form/form-field.tsx
Normal file
34
packages/core/src/components/form/form-field.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
// packages/core/src/components/form/form-field.tsx
|
||||
import type { JSX } from "solid-js";
|
||||
import { createUniqueId } from "solid-js";
|
||||
import { useFormContext, FormFieldContextProvider, type FormFieldContextValue } from "./form-context";
|
||||
import type { FormFieldProps } from "./form.props";
|
||||
|
||||
export type { FormFieldProps };
|
||||
|
||||
/**
|
||||
* Wraps a single form field. Generates unique IDs for the control, description,
|
||||
* and error message elements to enable accessible ARIA linking. Triggers
|
||||
* field-level validation on blur or change when validateOn is set accordingly.
|
||||
*/
|
||||
export function FormField(props: FormFieldProps): JSX.Element {
|
||||
const formCtx = useFormContext();
|
||||
const baseId = createUniqueId();
|
||||
const controlId = `${baseId}-control`;
|
||||
const descriptionId = `${baseId}-description`;
|
||||
const errorId = `${baseId}-error`;
|
||||
|
||||
const fieldCtx: FormFieldContextValue = {
|
||||
name: props.name,
|
||||
controlId,
|
||||
descriptionId,
|
||||
errorId,
|
||||
errors: () => formCtx.getFieldErrors(props.name),
|
||||
};
|
||||
|
||||
return (
|
||||
<FormFieldContextProvider value={fieldCtx}>
|
||||
{props.children}
|
||||
</FormFieldContextProvider>
|
||||
);
|
||||
}
|
||||
21
packages/core/src/components/form/form-label.tsx
Normal file
21
packages/core/src/components/form/form-label.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
// packages/core/src/components/form/form-label.tsx
|
||||
import type { JSX } from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
import { useFormFieldContext } from "./form-context";
|
||||
import type { FormLabelProps } from "./form.props";
|
||||
|
||||
export type { FormLabelProps };
|
||||
|
||||
/**
|
||||
* Renders a <label> element linked to the field's control via htmlFor.
|
||||
*/
|
||||
export function FormLabel(props: FormLabelProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["children"]);
|
||||
const fieldCtx = useFormFieldContext();
|
||||
|
||||
return (
|
||||
<label for={fieldCtx.controlId} {...rest}>
|
||||
{local.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
116
packages/core/src/components/form/form-root.tsx
Normal file
116
packages/core/src/components/form/form-root.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
// packages/core/src/components/form/form-root.tsx
|
||||
import type { JSX } from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { z } from "zod/v4";
|
||||
import { FormContextProvider, type FormContextValue } from "./form-context";
|
||||
import type { FormRootProps } from "./form.props";
|
||||
|
||||
export type { FormRootProps };
|
||||
|
||||
/**
|
||||
* Root form element. Manages field values and errors via a reactive store.
|
||||
* Validates on submit (or blur/change when validateOn is set) using the
|
||||
* provided Zod v4 schema. Calls onSubmit on success, onValidationError on
|
||||
* failure.
|
||||
*/
|
||||
export function FormRoot(props: FormRootProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"schema",
|
||||
"onSubmit",
|
||||
"onValidationError",
|
||||
"disabled",
|
||||
"validateOn",
|
||||
"children",
|
||||
]);
|
||||
|
||||
const [fieldValues, setFieldValues] = createStore<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = createStore<Record<string, string[]>>({});
|
||||
|
||||
const getFieldErrors = (name: string): string[] => errors[name] ?? [];
|
||||
|
||||
const setFieldValue = (name: string, value: unknown): void => {
|
||||
setFieldValues(name, value);
|
||||
};
|
||||
|
||||
const getFieldValue = (name: string): unknown => fieldValues[name];
|
||||
|
||||
const buildFieldErrors = (schema: z.ZodType, values: Record<string, unknown>): Record<string, string[]> => {
|
||||
const result = schema.safeParse(values);
|
||||
if (result.success) return {};
|
||||
const fieldErrors: Record<string, string[]> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path.join(".");
|
||||
if (!fieldErrors[path]) fieldErrors[path] = [];
|
||||
fieldErrors[path].push(issue.message);
|
||||
}
|
||||
return fieldErrors;
|
||||
};
|
||||
|
||||
const validateField = (name: string): void => {
|
||||
if (!local.schema) return;
|
||||
const allErrors = buildFieldErrors(local.schema, fieldValues);
|
||||
setErrors(name, allErrors[name] ?? []);
|
||||
};
|
||||
|
||||
const validateAll = (): boolean => {
|
||||
if (!local.schema) return true;
|
||||
const fieldErrors = buildFieldErrors(local.schema, fieldValues);
|
||||
const hasErrors = Object.keys(fieldErrors).length > 0;
|
||||
if (hasErrors) {
|
||||
for (const [key, msgs] of Object.entries(fieldErrors)) {
|
||||
setErrors(key, msgs);
|
||||
}
|
||||
} else {
|
||||
for (const key of Object.keys(errors)) {
|
||||
setErrors(key, []);
|
||||
}
|
||||
}
|
||||
return !hasErrors;
|
||||
};
|
||||
|
||||
const handleSubmit: JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent> = (e) => {
|
||||
e.preventDefault();
|
||||
if (local.schema) {
|
||||
const result = local.schema.safeParse(fieldValues);
|
||||
if (!result.success) {
|
||||
const fieldErrors: Record<string, string[]> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path.join(".");
|
||||
if (!fieldErrors[path]) fieldErrors[path] = [];
|
||||
fieldErrors[path].push(issue.message);
|
||||
}
|
||||
for (const [key, msgs] of Object.entries(fieldErrors)) {
|
||||
setErrors(key, msgs);
|
||||
}
|
||||
local.onValidationError?.(fieldErrors);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(errors)) {
|
||||
setErrors(key, []);
|
||||
}
|
||||
local.onSubmit?.(result.data as Record<string, unknown>, e);
|
||||
} else {
|
||||
local.onSubmit?.(fieldValues, e);
|
||||
}
|
||||
};
|
||||
|
||||
const ctx: FormContextValue = {
|
||||
errors: () => errors,
|
||||
getFieldErrors,
|
||||
setFieldValue,
|
||||
getFieldValue,
|
||||
validateField,
|
||||
validateAll,
|
||||
disabled: () => local.disabled ?? false,
|
||||
validateOn: () => local.validateOn ?? "submit",
|
||||
};
|
||||
|
||||
return (
|
||||
<FormContextProvider value={ctx}>
|
||||
<form onSubmit={handleSubmit} {...rest}>
|
||||
{local.children}
|
||||
</form>
|
||||
</FormContextProvider>
|
||||
);
|
||||
}
|
||||
26
packages/core/src/components/form/form-submit.tsx
Normal file
26
packages/core/src/components/form/form-submit.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
// packages/core/src/components/form/form-submit.tsx
|
||||
import type { JSX } from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
import { useFormContext } from "./form-context";
|
||||
import type { FormSubmitProps } from "./form.props";
|
||||
|
||||
export type { FormSubmitProps };
|
||||
|
||||
/**
|
||||
* Renders a <button type="submit"> that is automatically disabled when the
|
||||
* form's disabled state is true.
|
||||
*/
|
||||
export function FormSubmit(props: FormSubmitProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["children", "disabled"]);
|
||||
const formCtx = useFormContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={local.disabled ?? formCtx.disabled()}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
37
packages/core/src/components/form/form.props.ts
Normal file
37
packages/core/src/components/form/form.props.ts
Normal file
@ -0,0 +1,37 @@
|
||||
// packages/core/src/components/form/form.props.ts
|
||||
import { z } from "zod/v4";
|
||||
import type { JSX } from "solid-js";
|
||||
import type { ComponentMeta } from "../../meta";
|
||||
|
||||
export const FormRootPropsSchema = z.object({
|
||||
disabled: z.boolean().optional().describe("Disable all fields in the form"),
|
||||
validateOn: z.enum(["submit", "blur", "change"]).optional().describe("When to validate. Defaults to 'submit'"),
|
||||
});
|
||||
|
||||
export interface FormRootProps extends z.infer<typeof FormRootPropsSchema>, Omit<JSX.FormHTMLAttributes<HTMLFormElement>, "onSubmit"> {
|
||||
schema?: z.ZodType;
|
||||
onSubmit?: (values: Record<string, unknown>, event: SubmitEvent) => void;
|
||||
onValidationError?: (errors: Record<string, string[]>) => void;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const FormFieldPropsSchema = z.object({
|
||||
name: z.string().describe("Field name matching the schema key"),
|
||||
});
|
||||
|
||||
export interface FormFieldProps extends z.infer<typeof FormFieldPropsSchema> {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export interface FormLabelProps extends JSX.LabelHTMLAttributes<HTMLLabelElement> { children?: JSX.Element; }
|
||||
export interface FormControlProps { children: (props: { id: string; name: string; "aria-describedby"?: string; "aria-invalid"?: boolean }) => JSX.Element; }
|
||||
export interface FormDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> { children?: JSX.Element; }
|
||||
export interface FormErrorMessageProps extends JSX.HTMLAttributes<HTMLParagraphElement> { children?: JSX.Element | ((errors: string[]) => JSX.Element); }
|
||||
export interface FormSubmitProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
||||
|
||||
export const FormMeta: ComponentMeta = {
|
||||
name: "Form",
|
||||
description: "Form with Zod v4 schema validation, field-level error display, and accessible aria linking",
|
||||
parts: ["Root", "Field", "Label", "Control", "Description", "ErrorMessage", "Submit"] as const,
|
||||
requiredParts: ["Root", "Field"] as const,
|
||||
} as const;
|
||||
19
packages/core/src/components/form/index.ts
Normal file
19
packages/core/src/components/form/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { FormRoot } from "./form-root";
|
||||
import { FormField } from "./form-field";
|
||||
import { FormLabel } from "./form-label";
|
||||
import { FormControl } from "./form-control";
|
||||
import { FormDescription } from "./form-description";
|
||||
import { FormErrorMessage } from "./form-error-message";
|
||||
import { FormSubmit } from "./form-submit";
|
||||
|
||||
export { FormRootPropsSchema, FormFieldPropsSchema, FormMeta } from "./form.props";
|
||||
export type { FormRootProps } from "./form-root";
|
||||
export type { FormFieldProps } from "./form-field";
|
||||
export type { FormLabelProps } from "./form-label";
|
||||
export type { FormControlProps } from "./form-control";
|
||||
export type { FormDescriptionProps } from "./form-description";
|
||||
export type { FormErrorMessageProps } from "./form-error-message";
|
||||
export type { FormSubmitProps } from "./form-submit";
|
||||
export type { FormContextValue, FormFieldContextValue } from "./form-context";
|
||||
|
||||
export const Form = Object.assign(FormRoot, { Field: FormField, Label: FormLabel, Control: FormControl, Description: FormDescription, ErrorMessage: FormErrorMessage, Submit: FormSubmit });
|
||||
60
packages/core/tests/components/form/form-validation.test.tsx
Normal file
60
packages/core/tests/components/form/form-validation.test.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
import { Form } from "../../../src/components/form/index";
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
});
|
||||
|
||||
describe("Form — validation", () => {
|
||||
it("shows Zod errors on submit", async () => {
|
||||
render(() => (
|
||||
<Form schema={schema}>
|
||||
<Form.Field name="username">
|
||||
<Form.Control>{(p) => <input {...p} />}</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Field>
|
||||
<Form.Submit>Submit</Form.Submit>
|
||||
</Form>
|
||||
));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await Promise.resolve();
|
||||
expect(screen.getByRole("alert").textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onValidationError with field errors", async () => {
|
||||
const onValidationError = vi.fn();
|
||||
render(() => (
|
||||
<Form schema={schema} onValidationError={onValidationError}>
|
||||
<Form.Field name="username">
|
||||
<Form.Control>{(p) => <input {...p} />}</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Submit>Submit</Form.Submit>
|
||||
</Form>
|
||||
));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await Promise.resolve();
|
||||
expect(onValidationError).toHaveBeenCalled();
|
||||
const errArg = onValidationError.mock.calls[0][0] as Record<string, string[]>;
|
||||
expect(Array.isArray(errArg["username"])).toBe(true);
|
||||
});
|
||||
|
||||
it("error message render prop receives errors array", async () => {
|
||||
render(() => (
|
||||
<Form schema={schema}>
|
||||
<Form.Field name="username">
|
||||
<Form.Control>{(p) => <input {...p} />}</Form.Control>
|
||||
<Form.ErrorMessage>
|
||||
{(errors: string[]) => <span data-testid="errs">{errors[0]}</span>}
|
||||
</Form.ErrorMessage>
|
||||
</Form.Field>
|
||||
<Form.Submit>Submit</Form.Submit>
|
||||
</Form>
|
||||
));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
await Promise.resolve();
|
||||
expect(screen.getByTestId("errs").textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
87
packages/core/tests/components/form/form.test.tsx
Normal file
87
packages/core/tests/components/form/form.test.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { render, screen } from "@solidjs/testing-library";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
import { Form, FormRootPropsSchema, FormMeta } from "../../../src/components/form/index";
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
});
|
||||
|
||||
describe("Form — rendering", () => {
|
||||
it("renders form with fields", () => {
|
||||
render(() => (
|
||||
<Form>
|
||||
<Form.Field name="username">
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Form.Control>{(p) => <input {...p} data-testid="u" />}</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Submit>Submit</Form.Submit>
|
||||
</Form>
|
||||
));
|
||||
expect(screen.getByText("Username")).toBeTruthy();
|
||||
expect(screen.getByTestId("u")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Submit" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("label htmlFor links to control id", () => {
|
||||
render(() => (
|
||||
<Form>
|
||||
<Form.Field name="username">
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Form.Control>{(p) => <input {...p} />}</Form.Control>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
));
|
||||
const label = screen.getByText("Username");
|
||||
expect(label.getAttribute("for")).toBe(screen.getByRole("textbox").id);
|
||||
});
|
||||
|
||||
it("description aria-describedby is linked to control", () => {
|
||||
render(() => (
|
||||
<Form>
|
||||
<Form.Field name="username">
|
||||
<Form.Control>{(p) => <input {...p} data-testid="ctrl" />}</Form.Control>
|
||||
<Form.Description>Enter your username</Form.Description>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
));
|
||||
const desc = screen.getByText("Enter your username");
|
||||
expect(screen.getByTestId("ctrl").getAttribute("aria-describedby")).toContain(desc.id);
|
||||
});
|
||||
|
||||
it("error message hidden when no errors", () => {
|
||||
render(() => (
|
||||
<Form schema={schema}>
|
||||
<Form.Field name="username">
|
||||
<Form.Control>{(p) => <input {...p} />}</Form.Control>
|
||||
<Form.ErrorMessage>Required</Form.ErrorMessage>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
));
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("submit button is disabled when form is disabled", () => {
|
||||
render(() => <Form disabled><Form.Submit>Submit</Form.Submit></Form>);
|
||||
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form — schema and meta", () => {
|
||||
it("schema validates validateOn enum", () => {
|
||||
expect(FormRootPropsSchema.safeParse({ validateOn: "submit" }).success).toBe(true);
|
||||
expect(FormRootPropsSchema.safeParse({ validateOn: "blur" }).success).toBe(true);
|
||||
expect(FormRootPropsSchema.safeParse({ validateOn: "change" }).success).toBe(true);
|
||||
expect(FormRootPropsSchema.safeParse({ validateOn: "invalid" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("meta has fields", () => {
|
||||
expect(FormMeta.name).toBe("Form");
|
||||
expect(FormMeta.parts).toContain("Root");
|
||||
expect(FormMeta.parts).toContain("Field");
|
||||
expect(FormMeta.parts).toContain("Label");
|
||||
expect(FormMeta.parts).toContain("Control");
|
||||
expect(FormMeta.parts).toContain("ErrorMessage");
|
||||
expect(FormMeta.parts).toContain("Submit");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user