# Глобальный фикс позиционирования выпадающих меню — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Включать обход бага позиционирования меню Vuetify автоматически и глобально, чтобы выпадающие списки не уезжали за экран ни в одном окне, и убрать ручные пометки.

**Architecture:** Один `MutationObserver` (ставится при запуске SPA из `app.ts`) ловит появление любого `.v-overlay.v-menu` в DOM и один раз запускает уже существующую стабилизацию позиции (`scheduleStabilize` — ядро прежнего `repositionMenuAfterOpen`). Ручные `@update:menu`/`@update:model-value` снимаются — остаётся одно место правды.

**Tech Stack:** Vue 3, Vuetify 3, TypeScript, Vitest + jsdom (`app/tests/Frontend/**`), vue-tsc, Vite.

**Спека:** [docs/superpowers/specs/2026-06-27-menu-reposition-global-fix-design.md](../specs/2026-06-27-menu-reposition-global-fix-design.md)

---

## Структура файлов

| Файл | Ответственность | Действие |
|---|---|---|
| `app/resources/js/utils/menuRepositionFix.ts` | стабилизация позиции меню + глобальный установщик | Modify |
| `app/resources/js/app.ts` | точка запуска SPA — один вызов установщика | Modify |
| `app/tests/Frontend/menuRepositionFix.spec.ts` | тест механизма (observer → один resize) | Create |
| `app/resources/js/views/projects/NewProjectDialog.vue` | убрать ручной обход (регионы) + импорт | Modify |
| `app/resources/js/components/projects/RegionsBulkDialog.vue` | убрать ×2 обход + импорт | Modify |
| `app/resources/js/components/projects/ProjectDetailsDrawer.vue` | убрать ×1 обход + импорт | Modify |
| `app/resources/js/components/layout/AppTopbar.vue` | убрать ×2 обход + импорт | Modify |

Все команды запускаются из каталога `app/` (там `package.json`). В PowerShell: `cd app; npm run ...`.

---

## Task 1: Глобальный установщик `installMenuRepositionFix` (TDD)

На этом шаге старый экспорт `repositionMenuAfterOpen` НЕ удаляем — он останется до Task 4, чтобы сборка оставалась зелёной (его ещё импортируют 4 окна). Добавляем новую функцию рядом, общее ядро `scheduleStabilize`.

**Files:**
- Test: `app/tests/Frontend/menuRepositionFix.spec.ts` (создать)
- Modify: `app/resources/js/utils/menuRepositionFix.ts`

- [ ] **Step 1: Написать падающий тест**

Создать `app/tests/Frontend/menuRepositionFix.spec.ts`:

```ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installMenuRepositionFix } from '../../resources/js/utils/menuRepositionFix';

// Ручной requestAnimationFrame: кадры прогоняем детерминированно.
let rafQueue: FrameRequestCallback[] = [];
function flushFrames(n: number): void {
    for (let i = 0; i < n; i++) {
        const batch = rafQueue;
        rafQueue = [];
        batch.forEach((cb) => cb(0));
    }
}

// Дать MutationObserver (микротаска jsdom) сработать.
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));

function makeMenu(): HTMLElement {
    const menu = document.createElement('div');
    menu.className = 'v-overlay v-menu';
    const content = document.createElement('div');
    content.className = 'v-overlay__content';
    menu.appendChild(content);
    return menu;
}

let teardown: (() => void) | undefined;
let rectWidth = 200;
let rectLeft = 100;
let origRect: typeof HTMLElement.prototype.getBoundingClientRect;
let resizeSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
    rafQueue = [];
    rectWidth = 200;
    rectLeft = 100;
    vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
        rafQueue.push(cb);
        return rafQueue.length;
    });
    origRect = HTMLElement.prototype.getBoundingClientRect;
    HTMLElement.prototype.getBoundingClientRect = function (): DOMRect {
        return { width: rectWidth, height: 10, left: rectLeft, top: 0, right: rectLeft + rectWidth, bottom: 10, x: rectLeft, y: 0, toJSON: () => ({}) } as DOMRect;
    };
    resizeSpy = vi.fn();
    window.addEventListener('resize', resizeSpy);
});

afterEach(() => {
    teardown?.();
    teardown = undefined;
    HTMLElement.prototype.getBoundingClientRect = origRect;
    window.removeEventListener('resize', resizeSpy);
    vi.unstubAllGlobals();
    document.body.innerHTML = '';
});

describe('installMenuRepositionFix', () => {
    it('при появлении меню стабилизирует позицию и шлёт один resize', async () => {
        teardown = installMenuRepositionFix();
        document.body.appendChild(makeMenu());
        await tick();
        flushFrames(6);
        expect(resizeSpy).toHaveBeenCalledTimes(1);
    });

    it('на посторонний узел не реагирует', async () => {
        teardown = installMenuRepositionFix();
        const other = document.createElement('div');
        other.className = 'some-card';
        document.body.appendChild(other);
        await tick();
        flushFrames(6);
        expect(resizeSpy).not.toHaveBeenCalled();
    });

    it('идемпотентна: двойная установка не даёт двойной resize', async () => {
        teardown = installMenuRepositionFix();
        installMenuRepositionFix(); // второй вызов — noop
        document.body.appendChild(makeMenu());
        await tick();
        flushFrames(6);
        expect(resizeSpy).toHaveBeenCalledTimes(1);
    });

    it('предохранитель: если геометрия не устаканилась — не виснет и не шлёт resize', async () => {
        rectWidth = 0; // контент «нулевой» → условие стабилизации не выполняется
        teardown = installMenuRepositionFix();
        document.body.appendChild(makeMenu());
        await tick();
        flushFrames(120); // больше предохранителя (90 кадров)
        expect(resizeSpy).not.toHaveBeenCalled();
        expect(rafQueue.length).toBe(0); // цикл остановился, не зациклился
    });
});
```

- [ ] **Step 2: Прогнать тест — убедиться, что падает**

Run (из `app/`): `npm run test:vue -- menuRepositionFix`
Expected: FAIL — `installMenuRepositionFix` не экспортируется из `menuRepositionFix.ts`.

- [ ] **Step 3: Реализовать установщик**

Переписать `app/resources/js/utils/menuRepositionFix.ts` так (шапку-комментарий сохранить, ниже — тело):

```ts
// Ядро: дождаться, пока геометрия последнего меню устаканится, и один раз
// послать resize — Vuetify пересчитает позицию по стабильной геометрии.
function scheduleStabilize(): void {
    if (typeof window === 'undefined') return;

    let prevLeft = Number.NaN;
    let stableFrames = 0;
    let totalFrames = 0;

    const tick = (): void => {
        const menus = document.querySelectorAll<HTMLElement>('.v-overlay.v-menu .v-overlay__content');
        const el = menus[menus.length - 1];

        if (el && el.getBoundingClientRect().width > 0) {
            const left = Math.round(el.getBoundingClientRect().left);
            stableFrames = left === prevLeft ? stableFrames + 1 : 0;
            prevLeft = left;
            if (stableFrames >= 3) {
                window.dispatchEvent(new Event('resize'));
                return;
            }
        }

        if (++totalFrames < 90) requestAnimationFrame(tick);
    };

    requestAnimationFrame(tick);
}

/**
 * @deprecated Ручной per-instance обход. Заменён глобальным
 * installMenuRepositionFix(). Оставлен временно до снятия ручных пометок.
 */
export function repositionMenuAfterOpen(open: boolean): void {
    if (!open) return;
    scheduleStabilize();
}

let installed = false;

/**
 * Глобально включает обход бага позиционирования меню Vuetify: один
 * MutationObserver ловит появление любого `.v-overlay.v-menu` и запускает
 * стабилизацию. Идемпотентна (повторный вызов — noop). SSR-safe.
 * Возвращает teardown (для тестов / явной остановки).
 */
export function installMenuRepositionFix(): () => void {
    const noop = (): void => {};
    if (installed) return noop;
    if (
        typeof window === 'undefined' ||
        typeof document === 'undefined' ||
        typeof MutationObserver === 'undefined' ||
        !document.body
    ) {
        return noop;
    }

    installed = true;
    const observer = new MutationObserver((mutations) => {
        for (const m of mutations) {
            for (const node of m.addedNodes) {
                if (!(node instanceof HTMLElement)) continue;
                if (node.matches('.v-overlay.v-menu') || node.querySelector('.v-overlay.v-menu')) {
                    scheduleStabilize();
                    return; // одного запуска на пачку мутаций достаточно
                }
            }
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    return () => {
        observer.disconnect();
        installed = false;
    };
}
```

> Примечание: блок JSDoc-шапки в начале файла (описание бага) сохранить как есть — он остаётся актуальным.

- [ ] **Step 4: Прогнать тест — зелёный**

Run (из `app/`): `npm run test:vue -- menuRepositionFix`
Expected: PASS — все 4 кейса.

- [ ] **Step 5: Коммит**

```bash
git add app/resources/js/utils/menuRepositionFix.ts app/tests/Frontend/menuRepositionFix.spec.ts
git commit -m "feat(fe): глобальный installMenuRepositionFix + тест механизма"
```

---

## Task 2: Подключить установщик в точке запуска

**Files:**
- Modify: `app/resources/js/app.ts`

- [ ] **Step 1: Добавить импорт и вызов после mount**

В `app/resources/js/app.ts` добавить импорт рядом с остальными:

```ts
import { installMenuRepositionFix } from './utils/menuRepositionFix';
```

И сразу после `app.mount('#app');` добавить строку:

```ts
app.mount('#app');
installMenuRepositionFix();
```

- [ ] **Step 2: Проверка типов и сборки**

Run (из `app/`): `npm run type-check`
Expected: PASS (0 ошибок).

- [ ] **Step 3: Коммит**

```bash
git add app/resources/js/app.ts
git commit -m "feat(fe): подключить installMenuRepositionFix при запуске SPA"
```

---

## Task 3: Снять ручные пометки в 4 окнах

Глобальный обход уже активен (Task 2). Ручные `@update:menu` / `@update:model-value="repositionMenuAfterOpen"` и их импорты теперь лишние — снимаем. Точные строки могли сдвинуться; ориентир — по атрибуту и импорту.

**Files:**
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`
- Modify: `app/resources/js/components/projects/RegionsBulkDialog.vue`
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue`
- Modify: `app/resources/js/components/layout/AppTopbar.vue`

- [ ] **Step 1: Найти все вхождения**

Run (из корня репо): `grep -rn "repositionMenuAfterOpen" app/resources/js`
Expected: вхождения только в 4 файлах выше (по 1 импорту + использования: NewProjectDialog ×1, RegionsBulkDialog ×2, ProjectDetailsDrawer ×1, AppTopbar ×2).

- [ ] **Step 2: Удалить строки-атрибуты и импорты**

В каждом из 4 файлов:
1. Удалить строку(и) атрибута на компоненте: `@update:menu="repositionMenuAfterOpen"` (NewProjectDialog, RegionsBulkDialog ×2, ProjectDetailsDrawer) либо `@update:model-value="repositionMenuAfterOpen"` (AppTopbar ×2).
2. Удалить строку импорта: `import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';` (путь относительный — как в файле).

Ничего другого в этих строках не трогать (атрибут на отдельной строке — удаляется целиком).

- [ ] **Step 3: Убедиться, что ссылок не осталось**

Run (из корня репо): `grep -rn "repositionMenuAfterOpen" app/resources/js`
Expected: вхождения ТОЛЬКО в `app/resources/js/utils/menuRepositionFix.ts` (определение `@deprecated`-функции). В `.vue`-файлах — ничего.

- [ ] **Step 4: Тесты затронутых окон + типы**

Run (из `app/`): `npm run test:vue -- NewDealDialog RegionsBulkDialog ProjectDetailsDrawer DealsFilters TenantsFilters`
Expected: PASS (затронутые окна рендерятся, обработчик отсутствует — не ломает).
Run (из `app/`): `npm run type-check`
Expected: PASS — нет «неиспользуемый импорт».

- [ ] **Step 5: Коммит**

```bash
git add app/resources/js/views/projects/NewProjectDialog.vue app/resources/js/components/projects/RegionsBulkDialog.vue app/resources/js/components/projects/ProjectDetailsDrawer.vue app/resources/js/components/layout/AppTopbar.vue
git commit -m "refactor(fe): снять ручные обходы меню - заменены глобальным установщиком"
```

---

## Task 4: Удалить мёртвый экспорт `repositionMenuAfterOpen`

После Task 3 функция нигде не используется. Удаляем экспорт — ядро остаётся внутренним, наружу торчит только `installMenuRepositionFix`.

**Files:**
- Modify: `app/resources/js/utils/menuRepositionFix.ts`

- [ ] **Step 1: Удалить `@deprecated`-функцию**

В `app/resources/js/utils/menuRepositionFix.ts` удалить весь блок:

```ts
/**
 * @deprecated Ручной per-instance обход. Заменён глобальным
 * installMenuRepositionFix(). Оставлен временно до снятия ручных пометок.
 */
export function repositionMenuAfterOpen(open: boolean): void {
    if (!open) return;
    scheduleStabilize();
}
```

`scheduleStabilize` и `installMenuRepositionFix` остаются. (`scheduleStabilize` остаётся используемым из `installMenuRepositionFix`.)

- [ ] **Step 2: Полная верификация**

Run (из `app/`): `npm run test:vue -- menuRepositionFix`
Expected: PASS (4 кейса).
Run (из `app/`): `npm run type-check`
Expected: PASS — нет неиспользуемого `scheduleStabilize`/мёртвого кода.
Run (из `app/`): `npm run build`
Expected: сборка успешна.

- [ ] **Step 3: Коммит**

```bash
git add app/resources/js/utils/menuRepositionFix.ts
git commit -m "refactor(fe): убрать мёртвый repositionMenuAfterOpen - ядро внутреннее"
```

---

## Финальная проверка (после всех задач)

- [ ] Run (из `app/`): `npm run test:vue` — весь фронт-набор зелёный.
- [ ] Run (из `app/`): `npm run type-check` — 0 ошибок.
- [ ] Run (из `app/`): `npm run build` — сборка ок.
- [ ] Глазами (после выката на прод по «выкатывай»): открыть «Тип лица» в визарде нового проекта, списки в «Новой сделке» и в диалоге импорта статусов — меню встают на место (особенно под Windows / reduced-motion).

---

## Self-review (выполнено автором плана)

- **Покрытие спеки:** §4.1 ядро+установщик → Task 1; §4.2 точка запуска → Task 2; §4.3 уборка пометок → Task 3 + Task 4 (удаление экспорта); §6 тесты → Task 1 (4 кейса) + финальная проверка. Гэпов нет.
- **Плейсхолдеры:** нет TBD/TODO; весь код приведён целиком.
- **Согласованность имён:** `scheduleStabilize`, `installMenuRepositionFix`, `repositionMenuAfterOpen` — употреблены одинаково во всех задачах; экспорт `repositionMenuAfterOpen` живёт Task 1→3, удаляется в Task 4 (по тексту согласовано).
- **Зелёные коммиты:** старый экспорт держится до снятия всех ссылок (Task 3), только потом удаляется (Task 4) — сборка не ломается между коммитами.
