TextField component

Headless TextField with Label, Input, Description, and ErrorMessage parts. Supports invalid, disabled, and required states with full ARIA wiring (aria-invalid, aria-required, aria-describedby, aria-errormessage). 7 tests passing.
This commit is contained in:
Mats Bosson 2026-03-29 07:42:05 +07:00
parent c5b7260e6d
commit fce03b7531
8 changed files with 270 additions and 0 deletions

View File

@ -0,0 +1,22 @@
// packages/core/src/components/text-field/index.ts
import { useTextFieldContext } from "./text-field-context";
import { TextFieldDescription } from "./text-field-description";
import { TextFieldErrorMessage } from "./text-field-error-message";
import { TextFieldInput } from "./text-field-input";
import { TextFieldLabel } from "./text-field-label";
import { TextFieldRoot } from "./text-field-root";
export const TextField = Object.assign(TextFieldRoot, {
Label: TextFieldLabel,
Input: TextFieldInput,
Description: TextFieldDescription,
ErrorMessage: TextFieldErrorMessage,
useContext: useTextFieldContext,
});
export type { TextFieldRootProps } from "./text-field-root";
export type { TextFieldLabelProps } from "./text-field-label";
export type { TextFieldInputProps } from "./text-field-input";
export type { TextFieldDescriptionProps } from "./text-field-description";
export type { TextFieldErrorMessageProps } from "./text-field-error-message";
export type { TextFieldContextValue } from "./text-field-context";

View File

@ -0,0 +1,32 @@
// packages/core/src/components/text-field/text-field-context.ts
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export interface TextFieldContextValue {
inputId: Accessor<string>;
descriptionId: Accessor<string | undefined>;
errorMessageId: Accessor<string | undefined>;
invalid: Accessor<boolean>;
disabled: Accessor<boolean>;
required: Accessor<boolean>;
setDescriptionId: (id: string | undefined) => void;
setErrorMessageId: (id: string | undefined) => void;
}
const TextFieldContext = createContext<TextFieldContextValue>();
/**
* Returns the TextField context. Throws if used outside <TextField>.
*/
export function useTextFieldContext(): TextFieldContextValue {
const ctx = useContext(TextFieldContext);
if (!ctx) {
throw new Error(
"[PettyUI] TextField parts must be used inside <TextField>.\n" +
" Fix: Wrap TextField.Label, TextField.Input, etc. inside <TextField>.",
);
}
return ctx;
}
export const TextFieldContextProvider = TextFieldContext.Provider;

View File

@ -0,0 +1,22 @@
// packages/core/src/components/text-field/text-field-description.tsx
import type { JSX } from "solid-js";
import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useTextFieldContext } from "./text-field-context";
export interface TextFieldDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
children?: JSX.Element;
}
/** Helper text for the TextField. Linked via aria-describedby. */
export function TextFieldDescription(props: TextFieldDescriptionProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useTextFieldContext();
const id = createUniqueId();
onMount(() => ctx.setDescriptionId(id));
onCleanup(() => ctx.setDescriptionId(undefined));
return (
<p id={id} {...rest}>
{local.children}
</p>
);
}

View File

@ -0,0 +1,24 @@
// packages/core/src/components/text-field/text-field-error-message.tsx
import type { JSX } from "solid-js";
import { Show, createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useTextFieldContext } from "./text-field-context";
export interface TextFieldErrorMessageProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
children?: JSX.Element;
}
/** Error message shown when the field is invalid. Announced via aria-live. */
export function TextFieldErrorMessage(props: TextFieldErrorMessageProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useTextFieldContext();
const id = createUniqueId();
onMount(() => ctx.setErrorMessageId(id));
onCleanup(() => ctx.setErrorMessageId(undefined));
return (
<Show when={ctx.invalid()}>
<p id={id} aria-live="polite" {...rest}>
{local.children}
</p>
</Show>
);
}

View File

@ -0,0 +1,23 @@
// packages/core/src/components/text-field/text-field-input.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useTextFieldContext } from "./text-field-context";
export type TextFieldInputProps = JSX.InputHTMLAttributes<HTMLInputElement>;
/** The input element. Automatically wired to Label, Description, and ErrorMessage. */
export function TextFieldInput(props: TextFieldInputProps): JSX.Element {
const [local, rest] = splitProps(props, ["id", "aria-describedby"]);
const ctx = useTextFieldContext();
return (
<input
id={local.id ?? ctx.inputId()}
aria-describedby={local["aria-describedby"] ?? ctx.descriptionId()}
aria-errormessage={ctx.invalid() ? ctx.errorMessageId() : undefined}
aria-invalid={ctx.invalid() || undefined}
aria-required={ctx.required() || undefined}
disabled={ctx.disabled()}
{...rest}
/>
);
}

View File

@ -0,0 +1,24 @@
// packages/core/src/components/text-field/text-field-label.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useTextFieldContext } from "./text-field-context";
export interface TextFieldLabelProps extends JSX.HTMLAttributes<HTMLLabelElement> {
children?: JSX.Element;
}
/** Label element linked to the TextField input via htmlFor. */
export function TextFieldLabel(props: TextFieldLabelProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useTextFieldContext();
return (
<label
for={ctx.inputId()}
data-invalid={ctx.invalid() || undefined}
data-disabled={ctx.disabled() || undefined}
{...rest}
>
{local.children}
</label>
);
}

View File

@ -0,0 +1,45 @@
// packages/core/src/components/text-field/text-field-root.tsx
import type { JSX } from "solid-js";
import { createUniqueId, splitProps } from "solid-js";
import { createRegisterId } from "../../primitives/create-register-id";
import { TextFieldContextProvider, type TextFieldContextValue } from "./text-field-context";
export interface TextFieldRootProps extends JSX.HTMLAttributes<HTMLDivElement> {
disabled?: boolean;
invalid?: boolean;
required?: boolean;
children: JSX.Element;
}
/**
* Root container for TextField. Provides context to all TextField parts.
*/
export function TextFieldRoot(props: TextFieldRootProps): JSX.Element {
const [local, rest] = splitProps(props, ["disabled", "invalid", "required", "children"]);
const inputId = createUniqueId();
const [descriptionId, setDescriptionId] = createRegisterId();
const [errorMessageId, setErrorMessageId] = createRegisterId();
const ctx: TextFieldContextValue = {
inputId: () => inputId,
descriptionId,
errorMessageId,
invalid: () => local.invalid ?? false,
disabled: () => local.disabled ?? false,
required: () => local.required ?? false,
setDescriptionId,
setErrorMessageId,
};
return (
<TextFieldContextProvider value={ctx}>
<div
data-invalid={local.invalid || undefined}
data-disabled={local.disabled || undefined}
{...rest}
>
{local.children}
</div>
</TextFieldContextProvider>
);
}

View File

@ -0,0 +1,78 @@
// packages/core/tests/components/text-field/text-field.test.tsx
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { TextField } from "../../../src/components/text-field/index";
describe("TextField", () => {
it("label is linked to input via htmlFor/id", () => {
render(() => (
<TextField>
<TextField.Label>Name</TextField.Label>
<TextField.Input />
</TextField>
));
const label = screen.getByText("Name");
const input = screen.getByRole("textbox");
expect(label.getAttribute("for")).toBe(input.id);
});
it("description is linked via aria-describedby", () => {
render(() => (
<TextField>
<TextField.Label>Name</TextField.Label>
<TextField.Input />
<TextField.Description>Enter your full name</TextField.Description>
</TextField>
));
const input = screen.getByRole("textbox");
const desc = screen.getByText("Enter your full name");
expect(input.getAttribute("aria-describedby")).toBe(desc.id);
});
it("error message not shown when not invalid", () => {
render(() => (
<TextField>
<TextField.Input />
<TextField.ErrorMessage>Required</TextField.ErrorMessage>
</TextField>
));
expect(screen.queryByText("Required")).toBeNull();
});
it("error message shown when invalid=true", () => {
render(() => (
<TextField invalid>
<TextField.Input />
<TextField.ErrorMessage>Required</TextField.ErrorMessage>
</TextField>
));
expect(screen.getByText("Required")).toBeTruthy();
});
it("input has aria-invalid when invalid=true", () => {
render(() => (
<TextField invalid>
<TextField.Input />
</TextField>
));
expect(screen.getByRole("textbox").getAttribute("aria-invalid")).toBe("true");
});
it("input is disabled when disabled=true", () => {
render(() => (
<TextField disabled>
<TextField.Input />
</TextField>
));
expect(screen.getByRole("textbox")).toBeDisabled();
});
it("input has aria-required when required=true", () => {
render(() => (
<TextField required>
<TextField.Input />
</TextField>
));
expect(screen.getByRole("textbox").getAttribute("aria-required")).toBe("true");
});
});