chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
+19 -21
View File
@@ -1,36 +1,33 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { bindIconAnimation } from "./bind-icon-animation";
afterEach(() => vi.restoreAllMocks());
it("finishes every delayed part after leaving and does not restart on quick re-entry", async () => {
vi.spyOn(window, "matchMedia").mockReturnValue({
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
const { container, getByRole } = render(
const { container } = render(
<button type="button">
<svg ref={bindIconAnimation} aria-hidden="true" />
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</button>,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = getByRole("button");
const target = screen.getByRole("button");
let finishFirst!: () => void;
let finishLast!: () => void;
const getAnimations = vi.fn(() => [
{
finished: new Promise<void>((resolve) => {
finishFirst = resolve;
}),
},
{
finished: new Promise<void>((resolve) => {
finishLast = resolve;
}),
},
]);
const getAnimations = vi.fn(() => {
const first = Promise.withResolvers<void>();
const last = Promise.withResolvers<void>();
finishFirst = first.resolve;
finishLast = last.resolve;
return [{ finished: first.promise }, { finished: last.promise }];
});
Object.defineProperty(icon, "getAnimations", { value: getAnimations });
fireEvent.mouseEnter(target);
fireEvent.mouseLeave(target);
@@ -50,18 +47,19 @@ it("finishes every delayed part after leaving and does not restart on quick re-e
});
it("keeps playing after keyboard blur and releases cancelled animations", async () => {
const { container, getByRole, unmount } = render(
const { container, unmount } = render(
<button type="button">
<svg ref={bindIconAnimation} aria-hidden="true" />
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</button>,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = getByRole("button");
const target = screen.getByRole("button");
// JSDOM does not model the browser keyboard-focus modality.
vi.spyOn(target, "matches").mockImplementation(
(selector) => selector === ":focus-visible",
selector => selector === ":focus-visible",
);
let cancel!: () => void;
Object.defineProperty(icon, "getAnimations", {
+25 -25
View File
@@ -1,54 +1,54 @@
// React ref cleanup keeps native animation lifetimes tied to the mounted icon.
const bindIconAnimation = (icon: SVGSVGElement | null) => {
const bindIconAnimation = (icon: null | SVGSVGElement) => {
if (!icon) return;
const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement;
if (!target) return;
let hovered = false;
let focused = false;
let running = false;
let disposed = false;
let isHovered = false;
let isFocused = false;
let isRunning = false;
let isDisposed = false;
const release = () => {
if (!running && !hovered && !focused) {
icon.removeAttribute("data-icon-animating");
if (!isRunning && !isHovered && !isFocused) {
delete icon.dataset.iconAnimating;
}
};
const start = () => {
if (icon.hasAttribute("data-icon-animating")) return;
icon.setAttribute("data-icon-animating", "");
running = true;
if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.dataset.iconAnimating = "";
isRunning = true;
// Includes staggered/delayed children and finite iterations, without timers.
const animations = icon.getAnimations({ subtree: true });
void Promise.allSettled(
animations.map((animation) => animation.finished),
animations.map(animation => animation.finished),
).then(() => {
if (disposed) return;
running = false;
if (isDisposed) return;
isRunning = false;
release();
});
};
const enter = () => {
if (!window.matchMedia("(hover: hover)").matches) return;
hovered = true;
if (!matchMedia("(hover: hover)").matches) return;
isHovered = true;
start();
};
const leave = () => {
hovered = false;
isHovered = false;
release();
};
const focus = () => {
focused =
target.matches(":focus-visible") ||
!!target.querySelector(":focus-visible");
if (focused) start();
isFocused
= target.matches(":focus-visible")
|| !!target.querySelector(":focus-visible");
if (isFocused) start();
};
const blur = (event: FocusEvent) => {
if (
event.relatedTarget instanceof Node &&
target.contains(event.relatedTarget)
event.relatedTarget instanceof Node
&& target.contains(event.relatedTarget)
)
return;
focused = false;
isFocused = false;
release();
};
target.addEventListener("mouseenter", enter);
@@ -56,12 +56,12 @@ const bindIconAnimation = (icon: SVGSVGElement | null) => {
target.addEventListener("focusin", focus);
target.addEventListener("focusout", blur);
return () => {
disposed = true;
isDisposed = true;
target.removeEventListener("mouseenter", enter);
target.removeEventListener("mouseleave", leave);
target.removeEventListener("focusin", focus);
target.removeEventListener("focusout", blur);
icon.removeAttribute("data-icon-animating");
delete icon.dataset.iconAnimating;
};
};
+9 -3
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import {
formatDate,
formatMoney,
@@ -16,11 +17,16 @@ describe("shared display helpers", () => {
);
});
it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ value: "-1234567.89", currency: "RUB" })).toBe(
expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe(
"-1 234 567,89 ₽",
);
expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽");
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
expect(formatMoney({ currency: "RUB", value: "10" })).toBe("10 ₽");
expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 USD");
expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe("0 001 234,50 ₽");
expect(formatMoney({ currency: "RUB", value: "123456789012345678901234567890.01" })).toBe(
"123 456 789 012 345 678 901 234 567 890,01 ₽",
);
expect(formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true })).toBe("1,50 ₽");
expect(formatMoney(null)).toBe("—");
});
+33 -31
View File
@@ -1,44 +1,46 @@
type Money = { currency: string; value: string };
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
type FormatMoneyOptions = {
withZeroCents?: boolean;
};
function formatDecimal(value: string): string {
const [integer, fraction] = value.split(".");
const sign = integer.startsWith("-") ? "-" : "";
const grouped = (sign ? integer.slice(1) : integer).replace(
/\B(?=(\d{3})+(?!\d))/g,
" ",
);
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
}
function formatMoney(
money: Money | null,
{ withZeroCents = false }: FormatMoneyOptions = {},
): string {
if (!money) return "—";
const [integerPart, fractionPart = ""] = money.value.split(".");
const normalizedFraction =
withZeroCents && !fractionPart
? "00"
: withZeroCents && fractionPart.length === 1
? `${fractionPart}0`
: fractionPart;
return `${formatDecimal(
`${integerPart}${normalizedFraction ? `.${normalizedFraction}` : ""}`,
)} ${money.currency === "RUB" ? "₽" : money.currency}`;
}
type Money = { currency: string; value: string };
function formatDate(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
year: "numeric",
}).format(new Date(value));
}
function formatDecimal(value: string): string {
const [integer, fraction] = value.split(".", DECIMAL_PART_COUNT);
const sign = integer.startsWith("-") ? "-" : "";
const digits = sign ? integer.slice(1) : integer;
const groupSize = 3;
const groups: string[] = [];
for (let end = digits.length; end > 0; end -= groupSize) {
groups.push(digits.slice(Math.max(0, end - groupSize), end));
}
const grouped = groups.toReversed().join(" ");
const suffix = fraction ? `,${fraction}` : "";
return `${sign}${grouped}${suffix}`;
}
function formatMoney(
money: Money | null | undefined,
{ withZeroCents = false }: FormatMoneyOptions = {},
): string {
if (!money) return "—";
const [integerPart, fractionPart = ""] = money.value.split(".", DECIMAL_PART_COUNT);
const normalizedFraction = withZeroCents ? fractionPart.padEnd(CENTS_PRECISION, "0") : fractionPart;
const decimal = normalizedFraction ? `${integerPart}.${normalizedFraction}` : integerPart;
const currency = money.currency === "RUB" ? "₽" : money.currency;
return `${formatDecimal(decimal)} ${currency}`;
}
function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
@@ -49,11 +51,11 @@ function formatSnapshotTime(value: string): string {
const logoSize = 160;
function instrumentLogoUrl(logoName: string | null | undefined): string | null {
function instrumentLogoUrl(logoName: null | string | undefined): null | string {
const stem = logoName?.trim().replace(/\.png$/i, "");
return stem
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
: null;
: /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null;
}
export {