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,15 +1,26 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import {
|
||||
useAuthenticatedQueries,
|
||||
useAuthenticatedQuery,
|
||||
} from "./authenticated-query";
|
||||
import { apiQueryClient, configureAuthClient } from "./client";
|
||||
|
||||
const useSingleQuery = () =>
|
||||
useAuthenticatedQuery(
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
);
|
||||
const useMultipleQueries = () =>
|
||||
useAuthenticatedQueries([
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
])[0];
|
||||
|
||||
describe("useAuthenticatedQuery", () => {
|
||||
it.each(["single", "multiple"])(
|
||||
"keeps %s protected queries idle without a session and loads after session configuration",
|
||||
@@ -23,35 +34,27 @@ describe("useAuthenticatedQuery", () => {
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
type: "BROKERAGE",
|
||||
openedOn: "2024-08-13",
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const useSingleQuery = () =>
|
||||
useAuthenticatedQuery(
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
);
|
||||
const useMultipleQueries = () =>
|
||||
useAuthenticatedQueries([
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
])[0];
|
||||
const { result } = renderHook(
|
||||
mode === "single" ? useSingleQuery : useMultipleQueries,
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(requests).not.toHaveBeenCalled();
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
@@ -60,8 +63,8 @@ describe("useAuthenticatedQuery", () => {
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
type: "BROKERAGE",
|
||||
openedOn: "2024-08-13",
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -1,32 +1,51 @@
|
||||
import {
|
||||
type QueryKey,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
useQueries,
|
||||
useQuery,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import {
|
||||
getAuthenticatedSessionSnapshot,
|
||||
subscribeAuthenticatedSession,
|
||||
} from "./client";
|
||||
|
||||
function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
|
||||
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
|
||||
) {
|
||||
const hasSession = useHasAuthenticatedSession();
|
||||
return useQueries({
|
||||
queries: queries.map(
|
||||
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
|
||||
...options,
|
||||
enabled: query =>
|
||||
hasSession
|
||||
&& (typeof options.enabled === "function"
|
||||
? options.enabled(query)
|
||||
: (options.enabled ?? true)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useAuthenticatedQuery<
|
||||
TQueryFnData = unknown,
|
||||
TQueryFunctionData = unknown,
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
TData = TQueryFunctionData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
options: UseQueryOptions<TQueryFunctionData, TError, TData, TQueryKey>,
|
||||
): UseQueryResult<TData, TError> {
|
||||
const hasSession = useHasAuthenticatedSession();
|
||||
const enabled = options.enabled;
|
||||
|
||||
return useQuery({
|
||||
...options,
|
||||
enabled: (query) =>
|
||||
hasSession &&
|
||||
(typeof enabled === "function" ? enabled(query) : (enabled ?? true)),
|
||||
enabled: query =>
|
||||
hasSession
|
||||
&& (typeof enabled === "function" ? enabled(query) : (enabled ?? true)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,22 +57,4 @@ function useHasAuthenticatedSession() {
|
||||
);
|
||||
}
|
||||
|
||||
function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
|
||||
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
|
||||
) {
|
||||
const hasSession = useHasAuthenticatedSession();
|
||||
return useQueries({
|
||||
queries: queries.map(
|
||||
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
|
||||
...options,
|
||||
enabled: (query) =>
|
||||
hasSession &&
|
||||
(typeof options.enabled === "function"
|
||||
? options.enabled(query)
|
||||
: (options.enabled ?? true)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export { useAuthenticatedQueries, useAuthenticatedQuery };
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import { apiClient, configureAuthClient } from "./client";
|
||||
|
||||
describe("apiClient authentication", () => {
|
||||
it("refreshes once and retries an unauthorized protected request", async () => {
|
||||
let token = "expired";
|
||||
const refresh = vi.fn(async () => {
|
||||
const refresh = vi.fn(() => {
|
||||
token = "fresh";
|
||||
return true;
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
const authorization: string[] = [];
|
||||
server.use(
|
||||
@@ -20,7 +22,7 @@ describe("apiClient authentication", () => {
|
||||
: HttpResponse.json(resource([]));
|
||||
}),
|
||||
);
|
||||
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
|
||||
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
|
||||
|
||||
const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
@@ -38,8 +40,8 @@ describe("apiClient authentication", () => {
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "expired",
|
||||
refresh: async () => false,
|
||||
clear,
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
|
||||
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
@@ -48,7 +50,7 @@ describe("apiClient authentication", () => {
|
||||
});
|
||||
|
||||
it("does not attach Bearer auth to authentication endpoints", async () => {
|
||||
let authorization: string | null = null;
|
||||
let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
@@ -57,8 +59,8 @@ describe("apiClient authentication", () => {
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await apiClient.POST("/api/v1/auth/refresh");
|
||||
@@ -68,10 +70,8 @@ describe("apiClient authentication", () => {
|
||||
it("discards a retried request response after another session takes over", async () => {
|
||||
let generation = 0;
|
||||
let calls = 0;
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", async () => {
|
||||
if (++calls === 1)
|
||||
@@ -82,9 +82,9 @@ describe("apiClient authentication", () => {
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
generation: () => generation,
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
generation: () => generation,
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const response = expect(
|
||||
|
||||
+58
-59
@@ -2,65 +2,44 @@ import createClient from "openapi-fetch";
|
||||
import createQueryClient from "openapi-react-query";
|
||||
|
||||
import type { paths } from "./generated/schema";
|
||||
|
||||
import { apiErrorCode } from "./problem-details";
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
|
||||
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
|
||||
const apiBaseUrl =
|
||||
typeof configuredBaseUrl === "string"
|
||||
const apiBaseUrl
|
||||
= typeof configuredBaseUrl === "string"
|
||||
? configuredBaseUrl
|
||||
: "http://localhost:3000";
|
||||
|
||||
type AuthHandlers = {
|
||||
generation?: () => number;
|
||||
accessToken: () => string | null;
|
||||
refresh: () => Promise<boolean>;
|
||||
accessToken: () => null | string | undefined;
|
||||
clear: () => void;
|
||||
generation?: () => number;
|
||||
refresh: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const inFlight = new Set<{ pathname: string; invalidated: boolean }>();
|
||||
const latestReads = new Map<string, { sequence: number; pending: number }>();
|
||||
function invalidateApiRequests(matches: (pathname: string) => boolean) {
|
||||
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
|
||||
const latestReads = new Map<string, { pending: number; sequence: number }>();
|
||||
function invalidateApiRequests(isMatches: (pathname: string) => boolean) {
|
||||
for (const request of inFlight)
|
||||
if (matches(request.pathname)) request.invalidated = true;
|
||||
if (isMatches(request.pathname)) request.invalidated = true;
|
||||
}
|
||||
|
||||
let auth: AuthHandlers | null = null;
|
||||
let hasAuthenticatedSession = false;
|
||||
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = { hasSession: false };
|
||||
const authSessionListeners = new Set<() => void>();
|
||||
|
||||
function setAuthenticatedSession(next: boolean) {
|
||||
if (next === hasAuthenticatedSession) return;
|
||||
hasAuthenticatedSession = next;
|
||||
for (const listener of authSessionListeners) listener();
|
||||
}
|
||||
|
||||
function configureAuthClient(handlers: AuthHandlers | null) {
|
||||
if (!handlers) invalidateApiRequests(() => true);
|
||||
auth = handlers;
|
||||
setAuthenticatedSession(Boolean(auth?.accessToken()));
|
||||
}
|
||||
|
||||
function getAuthenticatedSessionSnapshot() {
|
||||
return hasAuthenticatedSession;
|
||||
}
|
||||
|
||||
function subscribeAuthenticatedSession(listener: () => void) {
|
||||
authSessionListeners.add(listener);
|
||||
return () => authSessionListeners.delete(listener);
|
||||
}
|
||||
|
||||
async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("sync");
|
||||
url.searchParams.sort();
|
||||
const key = `${request.method} ${url}`;
|
||||
const ticket = { pathname: url.pathname, invalidated: false };
|
||||
const read =
|
||||
request.method === "GET"
|
||||
? (latestReads.get(key) ?? { sequence: 0, pending: 0 })
|
||||
: null;
|
||||
const ticket = { invalidated: false, pathname: url.pathname };
|
||||
const read
|
||||
= request.method === "GET"
|
||||
? (latestReads.get(key) ?? { pending: 0, sequence: 0 })
|
||||
: undefined;
|
||||
if (read) {
|
||||
read.sequence += 1;
|
||||
read.pending += 1;
|
||||
@@ -73,12 +52,19 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
request,
|
||||
() => !ticket.invalidated && (!read || sequence === read.sequence),
|
||||
);
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
inFlight.delete(ticket);
|
||||
if (read && --read.pending === 0) latestReads.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function configureAuthClient(handlers?: AuthHandlers) {
|
||||
if (!handlers) invalidateApiRequests(() => true);
|
||||
sessionState.handlers = handlers;
|
||||
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
|
||||
}
|
||||
|
||||
async function fetchCurrent(
|
||||
request: Request,
|
||||
isLatest: () => boolean,
|
||||
@@ -86,45 +72,43 @@ async function fetchCurrent(
|
||||
const isAuthRequest = new URL(request.url).pathname.startsWith(
|
||||
"/api/v1/auth/",
|
||||
);
|
||||
const session = auth;
|
||||
const session = sessionState.handlers;
|
||||
const generation = session?.generation?.();
|
||||
const isCurrent = () =>
|
||||
(session?.generation
|
||||
? generation === session.generation() &&
|
||||
generation === auth?.generation?.()
|
||||
: session === auth) && isLatest();
|
||||
? generation === session.generation()
|
||||
&& generation === sessionState.handlers?.generation?.()
|
||||
: session === sessionState.handlers) && isLatest();
|
||||
const token = session?.accessToken();
|
||||
const withToken = (source: Request, value: string) => {
|
||||
const headers = new Headers(source.headers);
|
||||
headers.set("Authorization", `Bearer ${value}`);
|
||||
return new Request(source, { headers });
|
||||
};
|
||||
const authorized =
|
||||
token && !isAuthRequest ? withToken(request, token) : request;
|
||||
const authorized
|
||||
= token && !isAuthRequest ? withToken(request, token) : request;
|
||||
const retryRequest = authorized.clone();
|
||||
const response = await fetch(authorized);
|
||||
await response.clone().arrayBuffer();
|
||||
if (!isAuthRequest && !isCurrent())
|
||||
throw new DOMException("Session changed", "AbortError");
|
||||
if (
|
||||
response.status !== HTTP_UNAUTHORIZED ||
|
||||
isAuthRequest ||
|
||||
!auth ||
|
||||
apiErrorCode(
|
||||
await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null),
|
||||
) !== "UNAUTHORIZED"
|
||||
)
|
||||
if (isAuthRequest || !sessionState.handlers || response.status !== HTTP_UNAUTHORIZED)
|
||||
return response;
|
||||
let problem: unknown;
|
||||
try {
|
||||
problem = await response.clone().json();
|
||||
}
|
||||
catch {
|
||||
return response;
|
||||
}
|
||||
if (apiErrorCode(problem) !== "UNAUTHORIZED") return response;
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
if (!(await auth.refresh())) {
|
||||
if (isCurrent()) auth.clear();
|
||||
if (!(await sessionState.handlers.refresh())) {
|
||||
if (isCurrent()) sessionState.handlers.clear();
|
||||
return response;
|
||||
}
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
const refreshedToken = auth.accessToken();
|
||||
const refreshedToken = sessionState.handlers.accessToken();
|
||||
if (!refreshedToken) return response;
|
||||
const retried = await fetch(withToken(retryRequest, refreshedToken));
|
||||
await retried.clone().arrayBuffer();
|
||||
@@ -132,6 +116,21 @@ async function fetchCurrent(
|
||||
return retried;
|
||||
}
|
||||
|
||||
function hasAuthenticatedSession() {
|
||||
return sessionState.hasSession;
|
||||
}
|
||||
|
||||
function setAuthenticatedSession(hasSession: boolean) {
|
||||
if (hasSession === sessionState.hasSession) return;
|
||||
sessionState.hasSession = hasSession;
|
||||
for (const listener of authSessionListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeAuthenticatedSession(listener: () => void) {
|
||||
authSessionListeners.add(listener);
|
||||
return () => authSessionListeners.delete(listener);
|
||||
}
|
||||
|
||||
const apiClient = createClient<paths>({
|
||||
baseUrl: apiBaseUrl,
|
||||
credentials: "include",
|
||||
@@ -144,7 +143,7 @@ export {
|
||||
apiClient,
|
||||
apiQueryClient,
|
||||
configureAuthClient,
|
||||
getAuthenticatedSessionSnapshot,
|
||||
hasAuthenticatedSession as getAuthenticatedSessionSnapshot,
|
||||
invalidateApiRequests,
|
||||
subscribeAuthenticatedSession,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
import { type QueryClient,
|
||||
type QueryFunctionContext,
|
||||
type QueryKey,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { components } from "./generated/schema";
|
||||
|
||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||
import { getAuthenticatedSessionSnapshot } from "./client";
|
||||
import type { components } from "./generated/schema";
|
||||
import { apiErrorCode } from "./problem-details";
|
||||
|
||||
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
||||
@@ -18,31 +18,6 @@ type Resource = {
|
||||
data: unknown;
|
||||
meta: components["schemas"]["InvestmentResourceMetaDto"];
|
||||
};
|
||||
function unwrapResourceResult<T extends Resource, E>(
|
||||
result: UseQueryResult<T, E>,
|
||||
) {
|
||||
const missing = result.isSuccess && result.data.data === null;
|
||||
const data: T["data"] | undefined = result.data?.data;
|
||||
const meta: T["meta"] | undefined = result.data?.meta;
|
||||
const status: UseQueryResult<T, E>["status"] = missing
|
||||
? "pending"
|
||||
: result.status;
|
||||
// Project resource fields; refetch still returns the transport envelope.
|
||||
return {
|
||||
...result,
|
||||
transport: {
|
||||
status: result.status,
|
||||
fetchStatus: result.fetchStatus,
|
||||
error: result.error,
|
||||
},
|
||||
data: data ?? undefined,
|
||||
meta,
|
||||
isPending: result.isPending || missing,
|
||||
isSuccess: result.isSuccess && !missing,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateResourceAccess(
|
||||
client: QueryClient,
|
||||
group: string,
|
||||
@@ -50,10 +25,10 @@ function invalidateResourceAccess(
|
||||
) {
|
||||
if (
|
||||
![
|
||||
"NOT_FOUND",
|
||||
"FORBIDDEN",
|
||||
"TBANK_NOT_CONNECTED",
|
||||
"INVALID_TBANK_TOKEN",
|
||||
"NOT_FOUND",
|
||||
"TBANK_NOT_CONNECTED",
|
||||
].includes(apiErrorCode(error) ?? "")
|
||||
)
|
||||
return;
|
||||
@@ -69,19 +44,44 @@ function invalidateResourceAccess(
|
||||
cached.setState({
|
||||
data: undefined,
|
||||
dataUpdatedAt: 0,
|
||||
status: "error",
|
||||
error: error as Error,
|
||||
fetchStatus: "idle",
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
|
||||
function unwrapResourceResult<T extends Resource, E>(
|
||||
result: UseQueryResult<T, E>,
|
||||
) {
|
||||
const isMissing = result.isSuccess && result.data.data === null;
|
||||
const data: T["data"] | undefined = result.data?.data;
|
||||
const meta: T["meta"] | undefined = result.data?.meta;
|
||||
const status: UseQueryResult<T, E>["status"] = isMissing
|
||||
? "pending"
|
||||
: result.status;
|
||||
// Project resource fields; refetch still returns the transport envelope.
|
||||
return {
|
||||
...result,
|
||||
data: data ?? undefined,
|
||||
isPending: result.isPending || isMissing,
|
||||
isSuccess: result.isSuccess && !isMissing,
|
||||
meta,
|
||||
status,
|
||||
transport: {
|
||||
error: result.error,
|
||||
fetchStatus: result.fetchStatus,
|
||||
status: result.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Observation changes transport mode, never the generated cache identity.
|
||||
function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
options: UseQueryOptions<T, E, T, K>,
|
||||
) {
|
||||
const client = useQueryClient();
|
||||
const group =
|
||||
typeof options.meta?.accessGroup === "string"
|
||||
const group
|
||||
= typeof options.meta?.accessGroup === "string"
|
||||
? options.meta.accessGroup
|
||||
: undefined;
|
||||
let epochs = accessEpochs.get(client);
|
||||
@@ -90,40 +90,38 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
accessEpochs.set(client, epochs);
|
||||
}
|
||||
const groupEpochs = epochs;
|
||||
const queryFn = options.queryFn;
|
||||
const guarded =
|
||||
typeof queryFn === "function"
|
||||
const queryFunction = options.queryFn;
|
||||
const guarded
|
||||
= typeof queryFunction === "function"
|
||||
? async (context: QueryFunctionContext<K>) => {
|
||||
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
|
||||
const result = await queryFn(context);
|
||||
if (group && epoch !== (groupEpochs.get(group) ?? 0))
|
||||
throw new DOMException("Access changed", "AbortError");
|
||||
return result;
|
||||
}
|
||||
: queryFn;
|
||||
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
|
||||
const result = await queryFunction(context);
|
||||
if (group && epoch !== (groupEpochs.get(group) ?? 0))
|
||||
throw new DOMException("Access changed", "AbortError");
|
||||
return result;
|
||||
}
|
||||
: queryFunction;
|
||||
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
|
||||
useEffect(() => {
|
||||
if (group) invalidateResourceAccess(client, group, query.error);
|
||||
}, [client, group, query.error]);
|
||||
const observe = async (expectedStateVersion?: string) => {
|
||||
const queryFn = guarded;
|
||||
const queryFunction = guarded;
|
||||
if (
|
||||
!query.isEnabled ||
|
||||
!getAuthenticatedSessionSnapshot() ||
|
||||
typeof queryFn !== "function"
|
||||
typeof queryFunction !== "function"
|
||||
|| !query.isEnabled
|
||||
|| !getAuthenticatedSessionSnapshot()
|
||||
)
|
||||
return;
|
||||
// Cancellation is synchronous; start the replacement in the same boundary.
|
||||
void client.cancelQueries({ queryKey: options.queryKey, exact: true });
|
||||
return client.fetchQuery({
|
||||
void client.cancelQueries({ exact: true, queryKey: options.queryKey });
|
||||
return client.query({
|
||||
...options,
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
queryFn: async (context: QueryFunctionContext<K>) => {
|
||||
const [method, path, input] = context.queryKey;
|
||||
const init = input as
|
||||
| { params?: { query?: Record<string, unknown> } }
|
||||
| undefined;
|
||||
| undefined
|
||||
| { params?: { query?: Record<string, unknown> } };
|
||||
const queryKey = [
|
||||
method,
|
||||
path,
|
||||
@@ -135,25 +133,29 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
},
|
||||
},
|
||||
] as unknown as K;
|
||||
const response = await queryFn({ ...context, queryKey });
|
||||
const response = await queryFunction({ ...context, queryKey });
|
||||
if (
|
||||
expectedStateVersion &&
|
||||
response.meta.stateVersion !== expectedStateVersion
|
||||
expectedStateVersion
|
||||
&& response.meta.stateVersion !== expectedStateVersion
|
||||
) {
|
||||
// One more observational read; never turn a stale notification into an import.
|
||||
const current = await queryFn({ ...context, queryKey });
|
||||
const current = await queryFunction({ ...context, queryKey });
|
||||
if (current.meta.stateVersion !== expectedStateVersion)
|
||||
throw new DOMException("Resource changed", "AbortError");
|
||||
return current;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
retry: false,
|
||||
// Observation validates raw resource metadata, before any observer projection.
|
||||
select: undefined,
|
||||
staleTime: 0,
|
||||
});
|
||||
};
|
||||
return {
|
||||
...unwrapResourceResult(query),
|
||||
queryKey: options.queryKey,
|
||||
observe,
|
||||
queryKey: options.queryKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import i18n from "i18next";
|
||||
import { createInstance } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import { ru } from "./locales/ru";
|
||||
|
||||
const i18n = createInstance();
|
||||
// eslint-disable-next-line unicorn/no-top-level-side-effects -- The application and test bootstrap import this initialized translation instance.
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: { ru },
|
||||
lng: "ru",
|
||||
fallbackLng: "ru",
|
||||
interpolation: { escapeValue: false },
|
||||
lng: "ru",
|
||||
resources: { ru },
|
||||
});
|
||||
|
||||
export { i18n };
|
||||
|
||||
+635
-635
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Code,
|
||||
@@ -9,9 +12,7 @@ import {
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { colorRoles, designTokens } from "@wealthledger/ui/theme";
|
||||
import type { FC } from "react";
|
||||
|
||||
const ColorsView: FC = () => {
|
||||
const theme = useMantineTheme();
|
||||
@@ -22,31 +23,39 @@ const ColorsView: FC = () => {
|
||||
Светлая схема WealthLedger. Источник цветов и ролей —
|
||||
@wealthledger/tokens/tokens.json. Цвет не заменяет подпись состояния.
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
<SimpleGrid cols={{ base: 1, lg: 3, sm: 2 }}>
|
||||
{Object.entries(designTokens.color.roles).map(([role, token]) => (
|
||||
<Paper key={role} p="md" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Box h={48} bg={`var(--wl-color-${role})`} aria-hidden="true" />
|
||||
<Box aria-hidden="true" bg={`var(--wl-color-${role})`} h={48} />
|
||||
<Text fw={700}>{role}</Text>
|
||||
<Code>--wl-color-{role}</Code>
|
||||
<Code>
|
||||
--wl-color-
|
||||
{role}
|
||||
</Code>
|
||||
<Text size="sm">
|
||||
{colorRoles[role]} ·{" "}
|
||||
{parseThemeColor({ color: colorRoles[role], theme }).value}
|
||||
{/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ colorRoles[role]}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{parseThemeColor({ color: /* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ colorRoles[role], theme }).value}
|
||||
</Text>
|
||||
<Text size="sm">{token.description}</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{Object.keys(designTokens.color.palettes).map((palette) => (
|
||||
<Stack key={palette} gap="xs">
|
||||
{Object.keys(designTokens.color.palettes).map(palette => (
|
||||
<Stack gap="xs" key={palette}>
|
||||
<Title order={2}>{palette}</Title>
|
||||
<SimpleGrid cols={{ base: 2, sm: 5, lg: 10 }}>
|
||||
{theme.colors[palette].map((value, shade) => (
|
||||
<SimpleGrid cols={{ base: 2, lg: 10, sm: 5 }}>
|
||||
{/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ theme.colors[palette].map((value, shade) => (
|
||||
<Paper key={value} p="xs" withBorder>
|
||||
<Box h={40} bg={value} aria-hidden="true" />
|
||||
<Box aria-hidden="true" bg={value} h={40} />
|
||||
<Text size="sm">
|
||||
{palette}.{shade}
|
||||
{palette}
|
||||
.
|
||||
{shade}
|
||||
</Text>
|
||||
<Text size="xs">{value}</Text>
|
||||
</Paper>
|
||||
@@ -69,37 +78,65 @@ const TypographyView: FC = () => {
|
||||
return (
|
||||
<Stack style={{ overflowWrap: "anywhere" }}>
|
||||
<Title order={1}>Типографика</Title>
|
||||
<Text>Основной шрифт: {theme.fontFamily}</Text>
|
||||
<Text ff="monospace">Моноширинный: {theme.fontFamilyMonospace}</Text>
|
||||
<Text>
|
||||
Основной шрифт:
|
||||
{" "}
|
||||
{theme.fontFamily}
|
||||
</Text>
|
||||
<Text ff="monospace">
|
||||
Моноширинный:
|
||||
{" "}
|
||||
{theme.fontFamilyMonospace}
|
||||
</Text>
|
||||
<Text>
|
||||
Title задаёт смысловую иерархию; выбирайте order по структуре страницы.
|
||||
Text — основной текст, size sm/xs — подписи; mono — идентификаторы.
|
||||
</Text>
|
||||
{Object.entries(theme.headings.sizes).map(([name, value]) => (
|
||||
<Paper key={name} withBorder p="md">
|
||||
<Paper key={name} p="md" withBorder>
|
||||
<Text
|
||||
fw={theme.headings.fontWeight}
|
||||
fz={value.fontSize}
|
||||
lh={value.lineHeight}
|
||||
fw={theme.headings.fontWeight}
|
||||
>
|
||||
{name} · Заголовок WealthLedger
|
||||
{name}
|
||||
{" "}
|
||||
· Заголовок WealthLedger
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{value.fontSize} / {value.lineHeight} · weight{" "}
|
||||
{value.fontSize}
|
||||
{" "}
|
||||
/
|
||||
{" "}
|
||||
{value.lineHeight}
|
||||
{" "}
|
||||
· weight
|
||||
{" "}
|
||||
{theme.headings.fontWeight}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
{Object.entries(theme.fontSizes).map(([size, value]) => (
|
||||
<Paper key={size} p="md" withBorder>
|
||||
<Text size={size}>Размер {size} · Пример основного текста</Text>
|
||||
<Text size={size}>
|
||||
Размер
|
||||
{" "}
|
||||
{size}
|
||||
{" "}
|
||||
· Пример основного текста
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{value} / {theme.lineHeights[size]}
|
||||
{value}
|
||||
{" "}
|
||||
/
|
||||
{" "}
|
||||
{/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ theme.lineHeights[size]}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
<Text>
|
||||
Веса:{" "}
|
||||
Веса:
|
||||
{" "}
|
||||
{Object.entries(theme.fontWeights)
|
||||
.map(([name, value]) => `${name} ${value}`)
|
||||
.join("; ")}
|
||||
@@ -117,31 +154,43 @@ const LayoutView: FC = () => {
|
||||
Используйте Mantine props gap/p/radius/shadow или --mantine-* в CSS.
|
||||
Шкалы принадлежат tokens.json; адаптер Mantine сохраняет
|
||||
масштабирование. Default radius:
|
||||
{theme.defaultRadius}; scale: {theme.scale}. Размеры конкретных карточек
|
||||
{theme.defaultRadius}
|
||||
; scale:
|
||||
{" "}
|
||||
{theme.scale}
|
||||
. Размеры конкретных карточек
|
||||
и их особые тени остаются локальными.
|
||||
</Text>
|
||||
<Title order={2}>Spacing</Title>
|
||||
{Object.entries(theme.spacing).map(([name, value]) => (
|
||||
<Paper key={name} p="sm" withBorder>
|
||||
<Text>
|
||||
{name} · {value}
|
||||
{name}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{value}
|
||||
</Text>
|
||||
<Box w={value} h={16} bg="blue" aria-hidden="true" />
|
||||
<Box aria-hidden="true" bg="blue" h={16} w={value} />
|
||||
</Paper>
|
||||
))}
|
||||
<Title order={2}>Radius</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
{Object.entries(theme.radius).map(([name, value]) => (
|
||||
<Paper key={name} radius={name} p="md" withBorder>
|
||||
<Paper key={name} p="md" radius={name} withBorder>
|
||||
<Text>
|
||||
{name} · {value}
|
||||
{name}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{value}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Title order={2}>Shadows</Title>
|
||||
{Object.entries(theme.shadows).map(([name, value]) => (
|
||||
<Paper key={name} shadow={name} p="md" withBorder>
|
||||
<Paper key={name} p="md" shadow={name} withBorder>
|
||||
<Text fw={700}>{name}</Text>
|
||||
<Text size="sm">{value}</Text>
|
||||
</Paper>
|
||||
@@ -153,7 +202,11 @@ const LayoutView: FC = () => {
|
||||
</Text>
|
||||
{Object.entries(theme.breakpoints).map(([name, value]) => (
|
||||
<Text key={name}>
|
||||
{name} · {value}
|
||||
{name}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{value}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -161,8 +214,6 @@ const LayoutView: FC = () => {
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Foundations",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
controls: { disable: true },
|
||||
docs: {
|
||||
@@ -172,6 +223,8 @@ const meta = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
title: "Design System/Foundations",
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
@@ -185,18 +238,18 @@ export const SecondaryText: Story = {
|
||||
"var(--wl-color-surface)",
|
||||
"var(--wl-color-canvas)",
|
||||
"var(--wl-color-subtle)",
|
||||
...Object.keys(designTokens.color.palettes)
|
||||
.filter((palette) => palette !== "dark")
|
||||
.flatMap((palette) => [
|
||||
`var(--mantine-color-${palette}-0)`,
|
||||
`color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
|
||||
]),
|
||||
].map((background) => (
|
||||
<Paper key={background} p="md" withBorder style={{ background }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
...Object.keys(designTokens.color.palettes).flatMap(palette => palette === "dark"
|
||||
? []
|
||||
: [
|
||||
`var(--mantine-color-${palette}-0)`,
|
||||
`color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
|
||||
]),
|
||||
].map(background => (
|
||||
<Paper key={background} p="md" style={{ background }} withBorder>
|
||||
<Text c="dimmed" size="sm">
|
||||
22.09.2026 · Синтетические данные
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text c="dimmed" size="xs">
|
||||
Не удалось обновить данные. Сохранён предыдущий результат.
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
function luminance(color: string) {
|
||||
const channels = [1, 3, 5]
|
||||
.map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255)
|
||||
.map((value) =>
|
||||
.map(index => Number.parseInt(color.slice(index, index + 2), 16) / 255)
|
||||
.map(value =>
|
||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4,
|
||||
);
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
@@ -13,10 +13,10 @@ describe("semantic text contrast", () => {
|
||||
it.each(["positive", "negative", "muted"] as const)(
|
||||
"keeps %s readable on canvas, white, subtle and tinted surfaces",
|
||||
(tone) => {
|
||||
const role = tokens.color.roles[tone];
|
||||
const role = /* eslint-disable-line security/detect-object-injection -- Keys come from the repository’s trusted design-token definitions. */ tokens.color.roles[tone];
|
||||
const palettes = tokens.color.palettes;
|
||||
const foreground =
|
||||
palettes[role.color as keyof typeof palettes][role.shade];
|
||||
const foreground
|
||||
= palettes[role.color as keyof typeof palettes][role.shade];
|
||||
const surfaces = [
|
||||
"#ffffff",
|
||||
palettes.gray[0],
|
||||
@@ -29,11 +29,11 @@ describe("semantic text contrast", () => {
|
||||
.map(
|
||||
([, palette]) =>
|
||||
`#${[1, 3, 5]
|
||||
.map((index) =>
|
||||
.map(index =>
|
||||
Math.round(
|
||||
Number.parseInt(palette[0].slice(index, index + 2), 16) *
|
||||
0.45 +
|
||||
255 * 0.55,
|
||||
Number.parseInt(palette[0].slice(index, index + 2), 16)
|
||||
* 0.45
|
||||
+ 255 * 0.55,
|
||||
)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
|
||||
type ButtonStoryArgs = ButtonProps & { onClick: () => void };
|
||||
type ButtonStoryArguments = ButtonProps & { onClick: () => void };
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Components/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: "Выполнить пример",
|
||||
onClick: fn(),
|
||||
loading: false,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
onClick: fn(),
|
||||
variant: "filled",
|
||||
},
|
||||
argTypes: {
|
||||
children: { control: "text" },
|
||||
variant: { control: "select", options: ["filled", "default", "subtle"] },
|
||||
loading: { control: "boolean" },
|
||||
disabled: { control: "boolean" },
|
||||
loading: { control: "boolean" },
|
||||
onClick: { control: false },
|
||||
variant: { control: "select", options: ["filled", "default", "subtle"] },
|
||||
},
|
||||
component: Button,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -30,13 +29,15 @@ const meta = {
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<ButtonStoryArgs>;
|
||||
tags: ["autodocs"],
|
||||
title: "Design System/Components/Button",
|
||||
} satisfies Meta<ButtonStoryArguments>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<ButtonStoryArgs>;
|
||||
type Story = StoryObj<ButtonStoryArguments>;
|
||||
export const Primary: Story = {
|
||||
name: "Основное действие",
|
||||
play: async ({ canvasElement, args }) => {
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const button = within(canvasElement).getByRole("button", {
|
||||
name: typeof args.children === "string" ? args.children : undefined,
|
||||
});
|
||||
@@ -47,12 +48,12 @@ export const Primary: Story = {
|
||||
},
|
||||
};
|
||||
export const Secondary: Story = {
|
||||
name: "Вторичное действие",
|
||||
args: { variant: "default" },
|
||||
name: "Вторичное действие",
|
||||
};
|
||||
export const Subtle: Story = {
|
||||
name: "Вспомогательное действие",
|
||||
args: { variant: "subtle" },
|
||||
name: "Вспомогательное действие",
|
||||
};
|
||||
export const Loading: Story = { name: "Выполнение", args: { loading: true } };
|
||||
export const Disabled: Story = { name: "Недоступно", args: { disabled: true } };
|
||||
export const Loading: Story = { args: { loading: true }, name: "Выполнение" };
|
||||
export const Disabled: Story = { args: { disabled: true }, name: "Недоступно" };
|
||||
|
||||
Reference in New Issue
Block a user