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