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:
parent
c5b7260e6d
commit
fce03b7531
22
packages/core/src/components/text-field/index.ts
Normal file
22
packages/core/src/components/text-field/index.ts
Normal 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";
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
packages/core/src/components/text-field/text-field-input.tsx
Normal file
23
packages/core/src/components/text-field/text-field-input.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/core/src/components/text-field/text-field-label.tsx
Normal file
24
packages/core/src/components/text-field/text-field-label.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
packages/core/src/components/text-field/text-field-root.tsx
Normal file
45
packages/core/src/components/text-field/text-field-root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user