mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
* fix(components/form): resolve FormControl label target regressions (#3369) - Expose FormControlContext and wrap FormControl children in Base UI's LabelableProvider so the generated control id reaches the actual labelable element. - Update InputGroup/InputGroupInput to consume the context and place the id on the real input instead of the fieldset. - Update Slider to discard the wrapper id and use the context via LabelableProvider so the thumb input receives the id and aria-labelledby. - Update ChipInput to consume the context, set id and aria-labelledby on the inner input, and only fall back to aria-label when not inside a FormItem. - Restructure the sidebar layout so a single FormControl labels the numeric input and the visible FormLabel is referenced by id for the sibling Slider, removing the duplicate-id defect. - Add a dev-time warning when the generated id lands on a non-labelable or missing element. - Extend form.test.tsx with regression coverage. * test(form): add regression coverage for chip-input and dual-control layout * fix(ui): surface FormControl error state as aria-invalid on the Slider control - Problem: FormControl injects aria-invalid={hasError} onto its rendered element, but Slider stripped it without re-applying it anywhere, so the error state never reached the DOM (flagged by Codacy/Greptile/CodeRabbit). - Fix: bridge aria-invalid onto Base UI's native range input via the Thumb's public inputRef prop; Base UI v1.7 has no prop path for it (its validation props only apply through Base UI Field context). id stays stripped since LabelableProvider already delivers it to the input. - Verification: new regression test in form.test.tsx fails on the pre-fix head (aria-invalid null) and passes post-fix; packages/ui 363/363 tests green; tsc --noEmit on packages/ui clean. * fix(ui): let a caller-supplied data-slot override the Slider default - Problem: the FormControl label-target fix moved data-slot="slider" after {...props} on SliderPrimitive.Root, so a caller's data-slot was silently overwritten with the default — a prop-ordering regression against both the prior file and the repo-wide convention (FormItem, FormLabel, InputGroup all place data-slot before the spread). - Fix: restore data-slot="slider" before {...props} so caller values win. - Verification: packages/ui — vitest src/components/slider.test.tsx src/components/form.test.tsx = 30/30 passing; new regression test ("lets a caller-supplied data-slot override the default") fails on the pre-fix head (data-slot="slider" wins) and passes with the fix; tsc --noEmit clean. * fix(ui): preserve standalone Slider and InputGroup identity props - Problem: the FormControl prop strip dropped a standalone caller's id on Slider and id/aria-describedby/aria-invalid on InputGroup, so standalone compositions rendered no element carrying those attributes (regression vs main, flagged by maintainer review on this PR). - Fix: strip the FormControl-generated props only when a FormControl ancestor is present (useFormControl context); preserve explicit caller props for standalone usage in both components. - Verification: new standalone + FormControl-wrapped tests fail on the prior head and pass after the fix; packages/ui 367/367, apps/web 595/595, tsgo --noEmit clean. * fix(ui): remove internal label provider dependency --------- Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { i18n } from "@lingui/core";
|
|
import { I18nProvider } from "@lingui/react";
|
|
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
|
|
import { ChipInput } from "./chip-input";
|
|
|
|
beforeAll(() => {
|
|
i18n.loadAndActivate({ locale: "en", messages: {} });
|
|
});
|
|
|
|
const renderInput = (props: Partial<React.ComponentProps<typeof ChipInput>> = {}) =>
|
|
render(
|
|
<I18nProvider i18n={i18n}>
|
|
<ChipInput defaultValue={[]} onChange={vi.fn()} {...props} />
|
|
</I18nProvider>,
|
|
);
|
|
|
|
describe("ChipInput", () => {
|
|
it("renders the supplied chips as Badges", () => {
|
|
renderInput({ defaultValue: ["alpha", "beta", "gamma"] });
|
|
expect(screen.getByText("alpha")).toBeInTheDocument();
|
|
expect(screen.getByText("beta")).toBeInTheDocument();
|
|
expect(screen.getByText("gamma")).toBeInTheDocument();
|
|
});
|
|
|
|
it("adds a chip on Enter, calling onChange with the new list", () => {
|
|
const onChange = vi.fn();
|
|
renderInput({ defaultValue: ["a"], onChange });
|
|
|
|
const input = document.querySelector("input") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "b" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(onChange).toHaveBeenCalledWith(["a", "b"]);
|
|
});
|
|
|
|
it("adds a chip on comma keypress", () => {
|
|
const onChange = vi.fn();
|
|
renderInput({ defaultValue: [], onChange });
|
|
|
|
const input = document.querySelector("input") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "new-tag" } });
|
|
fireEvent.keyDown(input, { key: "," });
|
|
|
|
expect(onChange).toHaveBeenCalledWith(["new-tag"]);
|
|
});
|
|
|
|
it("does not add a duplicate chip", () => {
|
|
const onChange = vi.fn();
|
|
renderInput({ defaultValue: ["a"], onChange });
|
|
|
|
const input = document.querySelector("input") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "a" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
// chips set should remain ["a"]; onChange not invoked with the same array.
|
|
const callsAddingA = onChange.mock.calls.filter((args) => Array.isArray(args[0]) && args[0].length > 1);
|
|
expect(callsAddingA.length).toBe(0);
|
|
});
|
|
|
|
it("does not add an empty / whitespace-only chip", () => {
|
|
const onChange = vi.fn();
|
|
renderInput({ defaultValue: ["a"], onChange });
|
|
|
|
const input = document.querySelector("input") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: " " } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hides the description copy when hideDescription is true", () => {
|
|
const { container } = renderInput({ defaultValue: ["a"], hideDescription: true });
|
|
// We don't know the exact translated text, just confirm no <Kbd> hint banner is rendered.
|
|
expect(container.querySelector("kbd")).toBeNull();
|
|
});
|
|
|
|
it("shows the description copy by default", () => {
|
|
const { container } = renderInput({ defaultValue: ["a"] });
|
|
expect(container.querySelector("kbd")).not.toBeNull();
|
|
});
|
|
|
|
it("wires the inner input to a FormLabel and lets it outrank the generic aria-label", () => {
|
|
render(
|
|
<I18nProvider i18n={i18n}>
|
|
<FormItem>
|
|
<FormLabel>Tags</FormLabel>
|
|
<FormControl render={<ChipInput defaultValue={[]} onChange={vi.fn()} />} />
|
|
</FormItem>
|
|
</I18nProvider>,
|
|
);
|
|
|
|
const label = screen.getByText("Tags");
|
|
const input = document.querySelector("input") as HTMLInputElement;
|
|
|
|
expect(input).toHaveAttribute("id");
|
|
expect(input.id).toMatch(/-form-item$/);
|
|
expect(label).toHaveAttribute("for", input.id);
|
|
expect(input).toHaveAttribute("aria-labelledby", label.id);
|
|
expect(input).toHaveAccessibleName("Tags");
|
|
});
|
|
|
|
it("keeps a generic accessible name when the FormControl has no FormLabel", () => {
|
|
render(
|
|
<I18nProvider i18n={i18n}>
|
|
<FormItem>
|
|
<FormControl render={<ChipInput defaultValue={[]} onChange={vi.fn()} />} />
|
|
</FormItem>
|
|
</I18nProvider>,
|
|
);
|
|
|
|
const input = document.querySelector("input") as HTMLInputElement;
|
|
|
|
expect(document.getElementById(input.getAttribute("aria-labelledby") ?? "")).toBeNull();
|
|
expect(input).toHaveAccessibleName("Add keyword");
|
|
});
|
|
});
|