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:
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user