diff --git a/packages/core/src/components/text-field/index.ts b/packages/core/src/components/text-field/index.ts new file mode 100644 index 0000000..341cb8c --- /dev/null +++ b/packages/core/src/components/text-field/index.ts @@ -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"; diff --git a/packages/core/src/components/text-field/text-field-context.ts b/packages/core/src/components/text-field/text-field-context.ts new file mode 100644 index 0000000..cf3dd82 --- /dev/null +++ b/packages/core/src/components/text-field/text-field-context.ts @@ -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; + descriptionId: Accessor; + errorMessageId: Accessor; + invalid: Accessor; + disabled: Accessor; + required: Accessor; + setDescriptionId: (id: string | undefined) => void; + setErrorMessageId: (id: string | undefined) => void; +} + +const TextFieldContext = createContext(); + +/** + * Returns the TextField context. Throws if used outside . + */ +export function useTextFieldContext(): TextFieldContextValue { + const ctx = useContext(TextFieldContext); + if (!ctx) { + throw new Error( + "[PettyUI] TextField parts must be used inside .\n" + + " Fix: Wrap TextField.Label, TextField.Input, etc. inside .", + ); + } + return ctx; +} + +export const TextFieldContextProvider = TextFieldContext.Provider; diff --git a/packages/core/src/components/text-field/text-field-description.tsx b/packages/core/src/components/text-field/text-field-description.tsx new file mode 100644 index 0000000..5308a9b --- /dev/null +++ b/packages/core/src/components/text-field/text-field-description.tsx @@ -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 { + 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 ( +

+ {local.children} +

+ ); +} diff --git a/packages/core/src/components/text-field/text-field-error-message.tsx b/packages/core/src/components/text-field/text-field-error-message.tsx new file mode 100644 index 0000000..de30b31 --- /dev/null +++ b/packages/core/src/components/text-field/text-field-error-message.tsx @@ -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 { + 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 ( + +

+ {local.children} +

+
+ ); +} diff --git a/packages/core/src/components/text-field/text-field-input.tsx b/packages/core/src/components/text-field/text-field-input.tsx new file mode 100644 index 0000000..8e6f257 --- /dev/null +++ b/packages/core/src/components/text-field/text-field-input.tsx @@ -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; + +/** 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 ( + + ); +} diff --git a/packages/core/src/components/text-field/text-field-label.tsx b/packages/core/src/components/text-field/text-field-label.tsx new file mode 100644 index 0000000..518773a --- /dev/null +++ b/packages/core/src/components/text-field/text-field-label.tsx @@ -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 { + 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 ( + + ); +} diff --git a/packages/core/src/components/text-field/text-field-root.tsx b/packages/core/src/components/text-field/text-field-root.tsx new file mode 100644 index 0000000..1f352f1 --- /dev/null +++ b/packages/core/src/components/text-field/text-field-root.tsx @@ -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 { + 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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/tests/components/text-field/text-field.test.tsx b/packages/core/tests/components/text-field/text-field.test.tsx new file mode 100644 index 0000000..ba3721f --- /dev/null +++ b/packages/core/tests/components/text-field/text-field.test.tsx @@ -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(() => ( + + Name + + + )); + 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(() => ( + + Name + + Enter your full name + + )); + 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(() => ( + + + Required + + )); + expect(screen.queryByText("Required")).toBeNull(); + }); + + it("error message shown when invalid=true", () => { + render(() => ( + + + Required + + )); + expect(screen.getByText("Required")).toBeTruthy(); + }); + + it("input has aria-invalid when invalid=true", () => { + render(() => ( + + + + )); + expect(screen.getByRole("textbox").getAttribute("aria-invalid")).toBe("true"); + }); + + it("input is disabled when disabled=true", () => { + render(() => ( + + + + )); + expect(screen.getByRole("textbox")).toBeDisabled(); + }); + + it("input has aria-required when required=true", () => { + render(() => ( + + + + )); + expect(screen.getByRole("textbox").getAttribute("aria-required")).toBe("true"); + }); +});