Разные фиксы (#8)
Tests / frontend-test (push) Successful in 1m20s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
2026-08-18 08:45:42 +00:00
parent 5b2c062e81
commit 3a71d0df1f
22 changed files with 497 additions and 67 deletions
@@ -51,7 +51,10 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
// ключи блока ДО перестановки: после неё индексы уже другие.
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
const registerRow = useFlipRows(activeKeys, rowKeys);
// Обёртка таблицы: её высоту useFlipRows анимирует вместе с переездом строк,
// чтобы пикер и следующая карточка не подпрыгивали при удалении шага.
const scrollRef = React.useRef<HTMLDivElement>(null);
const registerRow = useFlipRows(activeKeys, rowKeys, scrollRef);
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
@@ -110,7 +113,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<div className={styles.matrixNote}>{emptyText}</div>
)}
{!waiting && !loading && !error && rows.length > 0 && (
<div className={styles.matrixScroll}>
<div className={styles.matrixScroll} ref={scrollRef}>
<table className={styles.matrix}>
<thead>
<tr>
@@ -95,6 +95,19 @@
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
.matrixScroll {
overflow-x: auto;
// overflow-y ОБЯЗАН быть задан явно. По спецификации, если одна ось получает
// прокрутку, вторая с `visible` вычисляется в `auto` — то есть один
// overflow-x: auto включал прокрутку и по вертикали тоже. А анимация
// перестановки (см. ui/useFlipRows) стартует с translateY на высоту строки:
// при удалении строки нижние сначала отрисованы СМЕЩЁННЫМИ ВНИЗ, вылезают за
// нижнюю границу контейнера — на кадр появляется вертикальная полоса
// прокрутки, из-за неё контейнер сужается, текст в колонках перевёрстывается,
// строки съезжают, а через 380 мс всё это откатывается назад. Отсюда и
// дрожание интерфейса на каждое удаление позиции.
//
// hidden, а не clip: `clip` не создаёт scroll container, и тогда вертикальный
// вылет анимации утёк бы наружу, прокручивая уже саму страницу.
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
@@ -22,8 +22,9 @@ import * as React from 'react';
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
// горизонтальной прокрутки.
// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось
// тоже становится auto, и вылет за край таблицы строку бы обрезало.
// • пружинистый перелёт (cubic-bezier с >1): вылет за край .matrixScroll
// обрезается (там overflow-y: hidden — см. approval.module.scss), так что
// перелёт был бы виден только как срезанная строка.
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
// пользователь (activeKeys).
@@ -108,10 +109,16 @@ function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, numbe
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
* которому решаем, действительно ли что-то переставили (см. ниже, почему
* геометрии для этого недостаточно).
* @param containerRef прокручиваемая обёртка таблицы (.matrixScroll). Её высоту
* анимируем в такт переезду строк: без этого React схлопывает высоту таблицы
* мгновенно, а строки едут 380 мс — и всё, что стоит НИЖЕ маршрута (пикер
* «Добавить согласующего», следующая карточка), рывком подпрыгивает на высоту
* удалённой строки. Необязателен: без него анимируются только строки.
*/
export function useFlipRows(
activeKeys: string[],
rowKeys: string[]
rowKeys: string[],
containerRef?: React.RefObject<HTMLElement>
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
): (key: string) => (el: HTMLTableRowElement | null) => void {
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
@@ -121,6 +128,10 @@ export function useFlipRows(
const prevTops = React.useRef(new Map<string, number>());
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
const prevRowKeys = React.useRef<string[]>([]);
// Высота обёртки на прошлый коммит. Нужна именно запомненная: layout-эффект
// работает УЖЕ ПОСЛЕ коммита, и к этому моменту высота на экране новая —
// «откуда ехать» из текущей раскладки не узнать.
const prevHeight = React.useRef<number | undefined>(undefined);
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
// вернул бы заодно чужие (CSS-переходы и т.п.).
const running = React.useRef<Animation[]>([]);
@@ -156,10 +167,13 @@ export function useFlipRows(
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
prevRowKeys.current = rowKeys;
const container = containerRef ? containerRef.current : null;
if (!orderChanged) {
// Порядок тот же — просто обновляем базовую геометрию для следующего
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
prevTops.current = measureTops(nodes.current);
prevHeight.current = container ? container.getBoundingClientRect().height : undefined;
return;
}
@@ -167,6 +181,10 @@ export function useFlipRows(
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
// текущего положения на экране, иначе будет рывок.
const visualTops = measureTops(nodes.current);
// То же и для высоты обёртки: если анимация высоты ещё идёт, здесь мы
// получим её промежуточное значение (после cancel ниже высота схлопнется к
// натуральной, и «откуда ехать» было бы уже не восстановить).
const visualHeight = container ? container.getBoundingClientRect().height : undefined;
running.current.forEach((a) => a.cancel());
running.current = [];
@@ -195,7 +213,38 @@ export function useFlipRows(
prevTops.current = current;
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0)) return;
// Высота: «куда» — натуральная (анимации уже отменены). «Откуда» — то, что
// было видно: промежуточное значение недоигранной анимации, если она шла
// (visualHeight разошёлся с натуральным), иначе высота прошлого коммита.
const heightTo = container ? container.getBoundingClientRect().height : undefined;
const heightFrom =
visualHeight !== undefined && heightTo !== undefined && Math.abs(visualHeight - heightTo) >= MIN_SHIFT_PX
? visualHeight
: prevHeight.current;
prevHeight.current = heightTo;
const heightChanged =
!!container &&
heightFrom !== undefined &&
heightTo !== undefined &&
Math.abs(heightFrom - heightTo) >= MIN_SHIFT_PX;
// heightChanged в условии — отдельным слагаемым: при удалении ПОСЛЕДНЕЙ
// строки ехать некому (moved и entered пусты), но высота всё равно
// меняется, и без анимации нижняя карточка снова подпрыгнула бы.
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0 && !heightChanged)) return;
if (heightChanged && container) {
// Та же длительность и easing, что у переезда строк: нижняя граница
// обёртки должна идти ровно за последней строкой, иначе рассинхрон видно.
// Обрезку лишнего на время анимации берёт на себя overflow-y: hidden
// (см. .matrixScroll в approval.module.scss).
running.current.push(
container.animate([{ height: `${heightFrom as number}px` }, { height: `${heightTo as number}px` }], {
duration: MOVE_MS,
easing: MOVE_EASING,
})
);
}
moved.forEach(({ el, key, shift }) => {
const isActive = activeKeys.indexOf(key) !== -1;