Files
Reactive-Resume/apps/web/src/components/input/chip-input.test.tsx
T
Santhi PrakashandAmruth Pillai ea3980cba0 fix(components/form): resolve FormControl label target regressions (#3369) (#3387)
* 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>
2026-09-05 09:35:00 -07:00

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");
});
});