mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-14 00:10:59 +01:00
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
191 lines
6.8 KiB
TypeScript
191 lines
6.8 KiB
TypeScript
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();
|
||
});
|
||
});
|
||
});
|