utils/web_ui/src/range-slider.test.tsx
Krzysztof kuhy Rudnicki c5b3fceeae ts_core 0.1.1, web_ui 0.4.0: consume @kuhyx/ts-config
The two shared packages are the first repos on the preset (link:../ts_config
inside the monorepo; consumers outside use the git tag). Each eslint.config.js
is now the defineConfig() call plus one override with its reason: a public
API name (`nextChance`, `fuzzyMatch`) the boolean-prefix rule would rename,
which is a breaking change for the three repos importing it.

Everything else the preset flagged is fixed in code, not disabled:
key order (perfectionist), hex casing, sort compare functions in tests,
Iterator#map().toArray(), explicit returns on multiline arrows.

web_ui: react-refresh/only-export-components was a real finding --
range-slider.tsx exported `steppedValue` next to the component, which breaks
Vite HMR for the module. The keyboard contract moves to stepped-value.ts as
`steppedValue` + a new pure `keyTarget(key, values, current)`, which also
resolves unicorn/no-late-event-control: the handler now decides the target
first and calls preventDefault() only when it owns the key. Its tests move
with it (the 250-line gate caught the combined file). Minor bump for the new
export. dist/ rebuilt in both packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
2026-09-16 21:46:41 +02:00

191 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RangeSlider } from "./range-slider.tsx";
/** Ten evenly spaced values, so a quantile fraction maps to a predictable value. */
const VALUES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90];
/** The track width the jsdom rect stub reports (see src/test/setup.ts). */
const TRACK_WIDTH = 1000;
function renderSlider(
properties: Partial<React.ComponentProps<typeof RangeSlider>> = {},
): { onChange: ReturnType<typeof vi.fn> } {
const onChange = vi.fn();
render(
<RangeSlider
hi={90}
lo={0}
onChange={onChange}
values={VALUES}
{...properties}
/>,
);
return { onChange };
}
function thumbs(): HTMLElement[] {
return screen.getAllByRole("slider");
}
describe("RangeSlider", () => {
it("renders nothing when there is nothing to range over", () => {
const { container } = render(<RangeSlider hi={1} lo={1} onChange={vi.fn()} values={[1]} />);
expect(container).toBeEmptyDOMElement();
});
it("exposes both thumbs as sliders with the value trio", () => {
renderSlider();
const [low, high] = thumbs();
expect(low).toHaveAttribute("aria-valuemin", "0");
expect(low).toHaveAttribute("aria-valuemax", "90");
expect(low).toHaveAttribute("aria-valuenow", "0");
expect(high).toHaveAttribute("aria-valuenow", "90");
});
it("names the thumbs from the label", () => {
renderSlider({ label: "Stars" });
expect(screen.getByLabelText("Stars minimum")).toBeInTheDocument();
expect(screen.getByLabelText("Stars maximum")).toBeInTheDocument();
});
it("falls back to a generic name without a label", () => {
renderSlider();
expect(screen.getByLabelText("Range minimum")).toBeInTheDocument();
});
it("draws the head only when both label and format are given", () => {
renderSlider({ format: (v): string => `${String(v)}*`, label: "Stars" });
expect(screen.getByText("Stars")).toBeInTheDocument();
expect(screen.getByText("0* – 90*")).toBeInTheDocument();
});
it("omits the head when the caller draws its own chrome", () => {
renderSlider({ label: "Stars" });
expect(screen.queryByText("Stars")).not.toBeInTheDocument();
});
it("is focusable, so the control is a keyboard stop", () => {
renderSlider();
const [low] = thumbs();
low?.focus();
expect(low).toHaveFocus();
});
describe("keyboard", () => {
it.each([
["ArrowRight", 10],
["ArrowUp", 10],
])("%s steps the low thumb up", (key, expected) => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[0]!, { key });
expect(onChange).toHaveBeenCalledWith(expected, 90);
});
it.each([
["ArrowLeft", 80],
["ArrowDown", 80],
])("%s steps the high thumb down", (key, expected) => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[1]!, { key });
expect(onChange).toHaveBeenCalledWith(0, expected);
});
it("PageUp jumps ten samples", () => {
const { onChange } = renderSlider({ lo: 0 });
fireEvent.keyDown(thumbs()[0]!, { key: "PageUp" });
expect(onChange).toHaveBeenCalledWith(90, 90);
});
it("PageDown jumps ten samples down", () => {
const { onChange } = renderSlider({ hi: 90 });
fireEvent.keyDown(thumbs()[1]!, { key: "PageDown" });
expect(onChange).toHaveBeenCalledWith(0, 0);
});
it("Home sends a thumb to the floor", () => {
const { onChange } = renderSlider({ lo: 50 });
fireEvent.keyDown(thumbs()[0]!, { key: "Home" });
expect(onChange).toHaveBeenCalledWith(0, 90);
});
it("End sends a thumb to the ceiling", () => {
const { onChange } = renderSlider({ hi: 50 });
fireEvent.keyDown(thumbs()[1]!, { key: "End" });
expect(onChange).toHaveBeenCalledWith(0, 90);
});
it("ignores keys it does not handle", () => {
const { onChange } = renderSlider();
fireEvent.keyDown(thumbs()[0]!, { key: "a" });
expect(onChange).not.toHaveBeenCalled();
});
it("does not let the low thumb cross the high one", () => {
const { onChange } = renderSlider({ hi: 10, lo: 10 });
fireEvent.keyDown(thumbs()[0]!, { key: "ArrowRight" });
expect(onChange).toHaveBeenCalledWith(10, 10);
});
it("does not let the high thumb cross the low one", () => {
const { onChange } = renderSlider({ hi: 10, lo: 10 });
fireEvent.keyDown(thumbs()[1]!, { key: "ArrowLeft" });
expect(onChange).toHaveBeenCalledWith(10, 10);
});
});
describe("pointer", () => {
function track(): HTMLElement {
// Queried by class rather than by role: the track is presentational, so
// it has neither a role nor text to find it by. (The consumers run
// eslint-plugin-testing-library, which flags this; this package does not
// install that plugin, so a disable comment here would itself be an
// unknown-rule error.)
return document.querySelector(".slider-track")!;
}
it("grabs the nearer thumb when pressed anywhere on the track", () => {
const { onChange } = renderSlider();
// 10% along: nearer the low thumb, which sits at 0. The track is
// quantile-mapped over n-1 intervals, so 0.1 lands on 9, not 10.
fireEvent.pointerDown(track(), { clientX: TRACK_WIDTH * 0.1, pointerId: 1 });
expect(onChange).toHaveBeenCalledWith(9, 90);
});
it("grabs the high thumb when the press is nearer to it", () => {
const { onChange } = renderSlider();
fireEvent.pointerDown(track(), { clientX: TRACK_WIDTH * 0.9, pointerId: 1 });
expect(onChange).toHaveBeenCalledWith(0, 81);
});
it("follows the pointer while dragging", () => {
const { onChange } = renderSlider();
fireEvent.pointerDown(track(), { clientX: 0, pointerId: 1 });
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 });
expect(onChange).toHaveBeenLastCalledWith(27, 90);
});
it("ignores movement when no thumb is held", () => {
const { onChange } = renderSlider();
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.3, pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
});
it("stops dragging on pointer up", () => {
const { onChange } = renderSlider();
fireEvent.pointerDown(track(), { clientX: 0, pointerId: 1 });
fireEvent.pointerUp(track(), { pointerId: 1 });
onChange.mockClear();
fireEvent.pointerMove(track(), { clientX: TRACK_WIDTH * 0.5, pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores a pointer up that was never preceded by a press", () => {
const { onChange } = renderSlider();
fireEvent.pointerUp(track(), { pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
});
});
});