This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js 22
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 10000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
for i in 1 2 3; do
|
||||||
|
npm install --legacy-peer-deps && break
|
||||||
|
echo "Attempt $i failed, retrying in 20s..."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
|
||||||
|
# heft test = сборка (sass + typescript + lint) и следом Jest. Это НЕ то же
|
||||||
|
# самое, что npm run build: там дополнительно пакуется .sppkg, а для проверки
|
||||||
|
# на PR упаковка не нужна.
|
||||||
|
- name: Run unit tests (Jest via heft)
|
||||||
|
run: npx heft test --clean
|
||||||
@@ -35,7 +35,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
// (не индекс) — иначе React переписывает строки на месте, и «переезжать»
|
// (не индекс) — иначе React переписывает строки на месте, и «переезжать»
|
||||||
// при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows).
|
// при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows).
|
||||||
const rowKeys = matrixRowKeys(rows);
|
const rowKeys = matrixRowKeys(rows);
|
||||||
const registerRow = useFlipRows();
|
// Шаг, который двигает пользователь: при обмене переезжают ОБА соседних шага,
|
||||||
|
// но подсветить и поднять надо только тот, по чьей стрелке кликнули, — иначе
|
||||||
|
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
|
||||||
|
// ключи блока ДО перестановки: после неё индексы уже другие.
|
||||||
|
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
|
||||||
|
const registerRow = useFlipRows(activeKeys);
|
||||||
|
|
||||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||||
@@ -46,6 +51,31 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const moveBlock = (rowIndex: number, direction: 'up' | 'down'): void => {
|
||||||
|
const b = blockByRow[rowIndex];
|
||||||
|
setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
|
||||||
|
if (direction === 'up') editor.moveBlockUp(rowIndex);
|
||||||
|
else editor.moveBlockDown(rowIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Остальные правки состава — не перестановка: сбрасываем «активный» шаг,
|
||||||
|
// иначе подъём и подсветка достались бы случайной строке, которая сдвинулась
|
||||||
|
// из-за перенумерации после удаления.
|
||||||
|
const removeRow = (rowIndex: number): void => {
|
||||||
|
setActiveKeys([]);
|
||||||
|
editor.remove(rowIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleParallel = (rowIndex: number): void => {
|
||||||
|
setActiveKeys([]);
|
||||||
|
editor.toggleParallel(rowIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addApprover = (user: ISiteUser): void => {
|
||||||
|
setActiveKeys([]);
|
||||||
|
editor.addApprover(user);
|
||||||
|
};
|
||||||
|
|
||||||
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
|
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
|
||||||
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
||||||
const hasDates = rows.some((r) => !!r.date);
|
const hasDates = rows.some((r) => !!r.date);
|
||||||
@@ -115,7 +145,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
disabled={block.index === 0}
|
disabled={block.index === 0}
|
||||||
onClick={() => editor.moveBlockUp(i)}
|
onClick={() => moveBlock(i, 'up')}
|
||||||
title="Шаг выше"
|
title="Шаг выше"
|
||||||
>
|
>
|
||||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||||
@@ -124,7 +154,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
disabled={block.index === blocks.length - 1}
|
disabled={block.index === blocks.length - 1}
|
||||||
onClick={() => editor.moveBlockDown(i)}
|
onClick={() => moveBlock(i, 'down')}
|
||||||
title="Шаг ниже"
|
title="Шаг ниже"
|
||||||
>
|
>
|
||||||
<Icon name="chevron-down" size="sm" />
|
<Icon name="chevron-down" size="sm" />
|
||||||
@@ -137,7 +167,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
|
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
onClick={() => editor.toggleParallel(i)}
|
onClick={() => toggleParallel(i)}
|
||||||
title={
|
title={
|
||||||
r.parallelWithPrev
|
r.parallelWithPrev
|
||||||
? 'Отцепить: выполнять после предыдущего шага'
|
? 'Отцепить: выполнять после предыдущего шага'
|
||||||
@@ -149,7 +179,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||||
onClick={() => editor.remove(i)}
|
onClick={() => removeRow(i)}
|
||||||
title="Удалить"
|
title="Удалить"
|
||||||
>
|
>
|
||||||
<Icon name="trash" size="sm" />
|
<Icon name="trash" size="sm" />
|
||||||
@@ -165,7 +195,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
)}
|
)}
|
||||||
{editable && !loading && (
|
{editable && !loading && (
|
||||||
<div className={styles.matrixPicker}>
|
<div className={styles.matrixPicker}>
|
||||||
<UserPicker search={search} onPick={editor.addApprover} placeholder={pickerPlaceholder} />
|
<UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,12 @@
|
|||||||
.matrix {
|
.matrix {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
|
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
|
||||||
border-collapse: collapse;
|
// separate, а НЕ collapse: при collapse границы принадлежат таблице, а не
|
||||||
|
// строкам, и во время анимации перестановки (см. ui/useFlipRows) уехавшая
|
||||||
|
// строка оставляла за собой полосы от собственных границ. Границы здесь и так
|
||||||
|
// заданы только снизу у th/td, поэтому при border-spacing: 0 вид не меняется.
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
|
|
||||||
th {
|
th {
|
||||||
|
|||||||
@@ -1,43 +1,85 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
// Анимация перестановки строк маршрута по технике FLIP (First–Last–Invert–Play):
|
// Анимация перестановки строк маршрута по технике FLIP (First–Last–Invert–Play):
|
||||||
// вместо того чтобы анимировать «переезд» вручную, мы даём React переставить
|
// вместо ручного «переезда» мы даём React переставить строки мгновенно, затем
|
||||||
// строки мгновенно, а затем ЗАПОМИНАЕМ, где строка была до этого, и проигрываем
|
// вспоминаем, где строка была до этого, и проигрываем её путь от старого
|
||||||
// её путь задом наперёд — от старого положения к новому. Так анимация всегда
|
// положения к новому. Так анимация всегда совпадает с реальной раскладкой
|
||||||
// совпадает с реальной раскладкой таблицы, даже когда у шага несколько
|
// таблицы, даже когда у шага несколько параллельных строк разной высоты
|
||||||
// параллельных строк разной высоты (см. rowSpan в ApprovalMatrix).
|
// (см. rowSpan в ApprovalMatrix).
|
||||||
//
|
//
|
||||||
// Работает ТОЛЬКО при устойчивых ключах строк (см. matrixRowKeys в
|
// Работает ТОЛЬКО при устойчивых ключах строк (см. matrixRowKeys в
|
||||||
// useMatrixEditor): с key={index} React не двигает <tr>, а переписывает их
|
// useMatrixEditor): с key={index} React не двигает <tr>, а переписывает их
|
||||||
// содержимое на месте — двигаться было бы нечему.
|
// содержимое на месте — двигаться было бы нечему.
|
||||||
//
|
//
|
||||||
// Проигрываем через Web Animations API (el.animate), а не через CSS-классы:
|
// Всё, что мы навешиваем на узлы, — это ИНЛАЙНОВЫЕ стили и анимации Web
|
||||||
// className у <tr> управляет React и затирает всё, что мы добавили бы руками,
|
// Animations API, и это принципиально: className у <tr> задаёт React (там
|
||||||
// а инлайновый style/анимации WAAPI он не трогает (эти пропсы у <tr> не заданы).
|
// живёт .parallelRow), и при перерисовке он затёр бы класс, добавленный руками.
|
||||||
|
// Свойство style у <tr>/<td> в разметке не задано, поэтому React его не трогает.
|
||||||
|
//
|
||||||
|
// Чего здесь намеренно НЕТ (проверено на макете — выглядело плохо):
|
||||||
|
// • scale у строки: ячейка «Шаг» объединена по вертикали (rowSpan) и живёт в
|
||||||
|
// ПЕРВОЙ строке блока, поэтому масштабирование каждой строки вокруг своего
|
||||||
|
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
|
||||||
|
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
|
||||||
|
// горизонтальной прокрутки.
|
||||||
|
// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось
|
||||||
|
// тоже становится auto, и вылет за край таблицы строку бы обрезало.
|
||||||
|
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
|
||||||
|
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
|
||||||
|
// пользователь (activeKeys).
|
||||||
|
|
||||||
/** Длительности и «пружинистость» — вынесены, чтобы правились в одном месте. */
|
const MOVE_MS = 380;
|
||||||
const MOVE_MS = 460;
|
const MOVE_EASING = 'cubic-bezier(.22, 1, .36, 1)';
|
||||||
const MOVE_EASING = 'cubic-bezier(.34, 1.4, .5, 1)'; // с лёгким перелётом — «пружинка»
|
const FLASH_MS = 620;
|
||||||
const FLASH_MS = 750;
|
|
||||||
const ENTER_MS = 320;
|
const ENTER_MS = 320;
|
||||||
const ENTER_STAGGER_MS = 45;
|
const ENTER_STAGGER_MS = 45;
|
||||||
/** Смещение меньше этого — не перестановка, а дребезг раскладки: не анимируем. */
|
/** Смещение меньше этого — дребезг раскладки, а не перестановка: не анимируем. */
|
||||||
const MIN_SHIFT_PX = 1;
|
const MIN_SHIFT_PX = 1;
|
||||||
|
|
||||||
|
// Пока блок летит, он поднят над остальными и непрозрачен — иначе при обмене
|
||||||
|
// двух шагов строки пересекаются и текст накладывается друг на друга. Значения
|
||||||
|
// повторяют $white и (чуть плотнее) $sh-2 из styles/_design-tokens.scss —
|
||||||
|
// инлайновыми они заданы потому, что снимать их надо тоже из JS.
|
||||||
|
const LIFT_BG = '#fff';
|
||||||
|
const LIFT_SHADOW = '0 6px 18px rgba(15, 36, 64, .13)';
|
||||||
|
const FLASH_FROM = 'rgba(212, 160, 30, .22)'; // $gold-500
|
||||||
|
const FLASH_TO = 'rgba(212, 160, 30, 0)';
|
||||||
|
|
||||||
function prefersReducedMotion(): boolean {
|
function prefersReducedMotion(): boolean {
|
||||||
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lift(row: HTMLTableRowElement): void {
|
||||||
|
row.style.position = 'relative';
|
||||||
|
row.style.zIndex = '2';
|
||||||
|
Array.prototype.forEach.call(row.cells, (td: HTMLTableCellElement) => {
|
||||||
|
td.style.background = LIFT_BG;
|
||||||
|
td.style.boxShadow = LIFT_SHADOW;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(row: HTMLTableRowElement): void {
|
||||||
|
row.style.position = '';
|
||||||
|
row.style.zIndex = '';
|
||||||
|
Array.prototype.forEach.call(row.cells, (td: HTMLTableCellElement) => {
|
||||||
|
td.style.background = '';
|
||||||
|
td.style.boxShadow = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её <tr>.
|
* Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её <tr>.
|
||||||
* Колбэк на один и тот же ключ — стабильный (см. refCache): иначе React дёргал
|
* @param activeKeys ключи строк того шага, который двигает пользователь —
|
||||||
* бы ref(null)/ref(el) на каждый рендер и карта узлов пересобиралась бы впустую.
|
* только он подсвечивается и поднимается над остальными (см. ApprovalMatrix).
|
||||||
|
* Для прочих изменений состава (добавление/удаление) — пустой массив.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
||||||
export function useFlipRows(): (key: string) => (el: HTMLTableRowElement | null) => void {
|
export function useFlipRows(activeKeys: string[]): (key: string) => (el: HTMLTableRowElement | null) => void {
|
||||||
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
|
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
|
||||||
|
// Колбэк на один и тот же ключ должен быть стабильным, иначе React дёргал бы
|
||||||
|
// ref(null)/ref(el) на каждый рендер и карта узлов пересобиралась бы впустую.
|
||||||
const refCache = React.useRef(new Map<string, (el: HTMLTableRowElement | null) => void>());
|
const refCache = React.useRef(new Map<string, (el: HTMLTableRowElement | null) => void>());
|
||||||
const prevRects = React.useRef(new Map<string, number>());
|
const prevTops = React.useRef(new Map<string, number>());
|
||||||
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
||||||
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
||||||
const running = React.useRef<Animation[]>([]);
|
const running = React.useRef<Animation[]>([]);
|
||||||
@@ -55,67 +97,68 @@ export function useFlipRows(): (key: string) => (el: HTMLTableRowElement | null)
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Без списка зависимостей — намеренно: измерять надо ПОСЛЕ КАЖДОГО коммита,
|
// Без списка зависимостей — намеренно: измерять надо ПОСЛЕ КАЖДОГО коммита,
|
||||||
// ведь строка могла переехать от любого изменения (перестановка, удаление,
|
// строка могла переехать от любого изменения. useLayoutEffect, а не
|
||||||
// добавление). useLayoutEffect, а не useEffect: инвертировать положение нужно
|
// useEffect: инвертировать положение нужно до того, как браузер покажет кадр,
|
||||||
// до того, как браузер покажет кадр, иначе будет заметен «скачок».
|
// иначе будет заметен скачок.
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
// Прошлые анимации отменяем ДО замеров: getBoundingClientRect у элемента
|
// Сначала запоминаем, где строка ВИДНА сейчас: при быстрых повторных кликах
|
||||||
// с активным transform вернул бы его текущее (промежуточное) положение, и
|
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
||||||
// новая анимация стартовала бы от неверной точки.
|
// текущего положения на экране, иначе будет рывок.
|
||||||
|
const visualTops = new Map<string, number>();
|
||||||
|
nodes.current.forEach((el, key) => visualTops.set(key, el.getBoundingClientRect().top));
|
||||||
|
|
||||||
running.current.forEach((a) => a.cancel());
|
running.current.forEach((a) => a.cancel());
|
||||||
running.current = [];
|
running.current = [];
|
||||||
|
nodes.current.forEach(drop);
|
||||||
|
|
||||||
const previous = prevRects.current;
|
const previous = prevTops.current;
|
||||||
const current = new Map<string, number>();
|
const current = new Map<string, number>();
|
||||||
const moved: Array<{ el: HTMLTableRowElement; shift: number }> = [];
|
const moved: Array<{ el: HTMLTableRowElement; key: string; shift: number }> = [];
|
||||||
const entered: HTMLTableRowElement[] = [];
|
const entered: HTMLTableRowElement[] = [];
|
||||||
|
|
||||||
nodes.current.forEach((el, key) => {
|
nodes.current.forEach((el, key) => {
|
||||||
const top = el.getBoundingClientRect().top;
|
// После отмены анимаций transform снят — это истинное новое положение.
|
||||||
current.set(key, top);
|
const layoutTop = el.getBoundingClientRect().top;
|
||||||
|
current.set(key, layoutTop);
|
||||||
|
|
||||||
const before = previous.get(key);
|
const before = previous.get(key);
|
||||||
if (before === undefined) {
|
if (before === undefined) {
|
||||||
entered.push(el);
|
entered.push(el);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shift = before - top;
|
// Недоигранный сдвиг прошлой анимации + переезд из-за перестановки.
|
||||||
if (Math.abs(shift) >= MIN_SHIFT_PX) moved.push({ el, shift });
|
const leftover = (visualTops.get(key) ?? layoutTop) - layoutTop;
|
||||||
|
const shift = before - layoutTop + leftover;
|
||||||
|
if (Math.abs(shift) >= MIN_SHIFT_PX) moved.push({ el, key, shift });
|
||||||
});
|
});
|
||||||
|
|
||||||
prevRects.current = current;
|
prevTops.current = current;
|
||||||
|
|
||||||
// Первый проход (карта пустая) — это просто появление таблицы, а не
|
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0)) return;
|
||||||
// перестановка: анимируем только выезд строк, и то по одному разу.
|
|
||||||
if (prefersReducedMotion()) return;
|
moved.forEach(({ el, key, shift }) => {
|
||||||
|
const isActive = activeKeys.indexOf(key) !== -1;
|
||||||
|
if (isActive) lift(el);
|
||||||
|
|
||||||
moved.forEach(({ el, shift }) => {
|
|
||||||
// Строка едет из старого положения в новое и на середине пути чуть
|
|
||||||
// «раздувается» — глазу проще проследить, какой именно шаг переехал.
|
|
||||||
running.current.push(
|
running.current.push(
|
||||||
el.animate(
|
el.animate([{ transform: `translateY(${shift}px)` }, { transform: 'translateY(0)' }], {
|
||||||
[
|
duration: MOVE_MS,
|
||||||
{ transform: `translateY(${shift}px) scale(1)` },
|
easing: MOVE_EASING,
|
||||||
{ transform: `translateY(${shift * 0.25}px) scale(1.02)`, offset: 0.45 },
|
})
|
||||||
{ transform: 'translateY(0) scale(1)' },
|
|
||||||
],
|
|
||||||
{ duration: MOVE_MS, easing: MOVE_EASING }
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Золотая вспышка по ячейкам (а не по <tr>): у параллельных строк фон
|
if (isActive) {
|
||||||
// задан на td (.parallelRow > td), и подсветка строки была бы им закрыта.
|
// Вспышка по ячейкам, а не по <tr>: у параллельных строк фон задан на td
|
||||||
Array.prototype.forEach.call(el.cells, (td: HTMLTableCellElement) => {
|
// (.parallelRow > td), и подсветка строки была бы им закрыта.
|
||||||
running.current.push(
|
Array.prototype.forEach.call(el.cells, (td: HTMLTableCellElement) => {
|
||||||
td.animate(
|
running.current.push(
|
||||||
[
|
td.animate([{ backgroundColor: FLASH_FROM }, { backgroundColor: FLASH_TO }], {
|
||||||
{ backgroundColor: 'rgba(212, 160, 30, .30)' },
|
duration: FLASH_MS,
|
||||||
{ backgroundColor: 'rgba(212, 160, 30, 0)' },
|
easing: 'ease-out',
|
||||||
],
|
})
|
||||||
{ duration: FLASH_MS, easing: 'ease-out' }
|
);
|
||||||
)
|
});
|
||||||
);
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
entered.forEach((el, i) => {
|
entered.forEach((el, i) => {
|
||||||
@@ -129,6 +172,14 @@ export function useFlipRows(): (key: string) => (el: HTMLTableRowElement | null)
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Приземлились — снимаем подъём. Отменённая анимация роняет finished
|
||||||
|
// с AbortError (это штатный путь при перехвате следующим кликом), поэтому
|
||||||
|
// ошибку гасим: подъём снимет уже тот проход, который её отменил.
|
||||||
|
const landing = running.current.map((a) => a.finished);
|
||||||
|
Promise.all(landing)
|
||||||
|
.then(() => nodes.current.forEach(drop))
|
||||||
|
.catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(
|
React.useEffect(
|
||||||
|
|||||||
Reference in New Issue
Block a user