# Глобальный фикс позиционирования выпадающих меню (v-select / v-autocomplete / v-menu)

- **Дата:** 2026-06-27
- **Статус:** утверждён заказчиком (брейншторм), готов к плану реализации
- **Слой:** только frontend (Vue 3 + Vuetify 3). БД / бэкенд / схема — НЕ затрагиваются.
- **Ветка:** `feat/menu-reposition-global-fix`

## 1. Проблема

Живой клиент на проде (27.06.2026, IP 45.147.123.37, Opera/Windows) не смог создать
первый проект: на шаге «Реквизиты» в окне создания проекта не открывался список
**«Тип лица»** — выпадающее меню уезжало за край экрана, выбрать было нечего.
Тип лица оставался пустым → сервер отбивал `PUT /api/tenant/requisites` с 422
(`subject_type` обязателен) → 3 одинаковых отказа подряд в access-логе, клиент в тупике.

Поле «Тип лица» в этом окне — `v-select` внутри `v-dialog`
([NewProjectDialog.vue:24-34](../../../app/resources/js/views/projects/NewProjectDialog.vue#L24-L34)).

## 2. Корневая причина

Известный баг Vuetify (`connected` location-strategy): когда активатор
`v-select` / `v-autocomplete` находится внутри `position: fixed`-контейнера
(диалог, дровер), на переходном кадре геометрия оверлея считается неверно и меню
смещается на кратное X активатора — за край экрана. Под `prefers-reduced-motion: reduce`
(умолчание на Windows) пересчётов-сглаживаний нет, «плохой» кадр остаётся финальным.

В проекте уже есть рабочий обход — [menuRepositionFix.ts](../../../app/resources/js/utils/menuRepositionFix.ts):
дождаться, пока геометрия меню устаканится, и один раз послать `resize` —
Vuetify пересчитает позицию корректно. Обход идемпотентен и безопасен при motion ON
и для не-fixed контейнеров.

**Системная причина дефекта:** обход вешается **вручную на каждый список** через
`@update:menu` / `@update:model-value`. Это легко забыть — и забыли в трёх окнах:

| Окно | Списки без защиты |
|---|---|
| `NewProjectDialog.vue` (шаг «Реквизиты») | «Тип лица» — `v-select` (стр. 24) |
| `NewDealDialog.vue` (новая сделка) | 3 × `v-select` (стр. 236, 247, 272) |
| `UnknownStatusesDialog.vue` (импорт) | 1 × `v-select` (стр. 78) |

Защита проставлена только в части мест: `NewProjectDialog` (регионы, стр. 214),
`RegionsBulkDialog` (×2), `ProjectDetailsDrawer` (×1), `AppTopbar` (×2 меню).

Любая точечная доработка («дописать обход в забытые окна») оставляет ту же грабли:
в следующем новом окне снова забудут. Поэтому чиним в корне.

## 3. Цель и не-цели

**Цель:** обход должен включаться **автоматически** для любого выпадающего меню во всём
портале — в текущих окнах и в любых будущих, без ручной разметки в шаблонах.

**Не-цели:**
- Не переписываем Vuetify и его location-strategy.
- Не форсируем анимации (это сломало бы `prefers-reduced-motion` / a11y).
- Не делаем компонент-обёртку списка с миграцией 30 мест (Вариант 2 брейншторма — отклонён:
  большая переделка и всё равно «забываемо»).
- Не трогаем серверную валидацию реквизитов (она корректна; UX-улучшение текста ошибки
  ИНН и клиентских подсказок в визарде — отдельная возможная задача, **вне этого scope**).

## 4. Дизайн

### 4.1. Общий механизм (одно место правды)

В [menuRepositionFix.ts](../../../app/resources/js/utils/menuRepositionFix.ts):

- Сохраняем ядро — функцию стабилизации позиции последнего открытого меню
  (текущая `tick`-логика: ждать `stableFrames >= 3`, затем один `dispatchEvent(new Event('resize'))`,
  предохранитель ~1.5 c / 90 кадров).
- Добавляем `installMenuRepositionFix(): void` — устанавливает один глобальный
  `MutationObserver` на `document.body`, который при появлении в DOM узла
  `.v-overlay.v-menu` (или содержащего такой) запускает стабилизацию один раз.
- Защиты: ранний выход без `window`/`document` (SSR-safe); идемпотентная установка
  (повторный вызов не плодит наблюдателей); наблюдатель живёт на весь жизненный цикл SPA.

### 4.2. Точка подключения

В [app.ts](../../../app/resources/js/app.ts) после `app.mount('#app')` — один вызов
`installMenuRepositionFix()`. Больше нигде включать не нужно.

### 4.3. Уборка ручных пометок (одно место правды)

Снять ставшие лишними ручные обходы и неиспользуемые импорты `repositionMenuAfterOpen`:

| Файл | Что снять |
|---|---|
| `NewProjectDialog.vue` | `@update:menu` (стр. 214) + импорт (стр. 319) |
| `RegionsBulkDialog.vue` | `@update:menu` (стр. 25, 51) + импорт (стр. 81) |
| `ProjectDetailsDrawer.vue` | `@update:menu` (стр. 330) + импорт (стр. 7) |
| `AppTopbar.vue` | `@update:model-value` (стр. 119, 182) + импорт (стр. 12) |

Три «забытых» окна (Тип лица, Новая сделка, Импорт статусов) после глобальной установки
чинятся **сами** — отдельных правок в них не требуется.

Публичный экспорт `repositionMenuAfterOpen` после уборки не используется в шаблонах.
Решение по нему — в плане реализации: либо убрать экспорт (ядро становится внутренним),
либо оставить для обратной совместимости. По умолчанию — сделать ядро внутренним,
наружу торчит только `installMenuRepositionFix`.

## 5. Затронутые файлы

- `app/resources/js/utils/menuRepositionFix.ts` — рефактор + новая `installMenuRepositionFix`.
- `app/resources/js/app.ts` — один вызов установщика после mount.
- `app/resources/js/views/projects/NewProjectDialog.vue` — убрать ручной обход + импорт.
- `app/resources/js/components/projects/RegionsBulkDialog.vue` — убрать ×2 + импорт.
- `app/resources/js/components/projects/ProjectDetailsDrawer.vue` — убрать ×1 + импорт.
- `app/resources/js/components/layout/AppTopbar.vue` — убрать ×2 + импорт.
- Новый тест: `app/tests/Frontend/menuRepositionFix.spec.ts` (конвенция репо:
  Vitest+jsdom, `app/vitest.config.ts` → `tests/Frontend/**/*.{test,spec}.ts`).

## 6. Тестирование

**Автотест механизма (Vitest, jsdom)** — слой логики, не визуальное положение
(в jsdom нет раскладки):

1. После `installMenuRepositionFix()` добавление в `document.body` узла
   `.v-overlay.v-menu` с непустой шириной → после стабилизации геометрии
   ровно один раз диспатчится `resize`.
2. На посторонние узлы (без `.v-overlay.v-menu`) наблюдатель не реагирует.
3. Идемпотентность: повторный `installMenuRepositionFix()` не плодит дубль-резайзы.
4. Предохранитель: если меню так и не появилось/не стабилизировалось — цикл
   останавливается, не виснет.

Моки: фейковый `requestAnimationFrame` (детерминированный прогон кадров),
заглушка `getBoundingClientRect` (стабильная ширина/`left`), счётчик `resize`-событий.

**Прочая верификация:** `npm run type-check` (vue-tsc), `npm run test:vue` (Vitest),
сборка `vite build`. Глазами на проде — после выката (открыть «Тип лица» в визарде,
списки в «Новой сделке» и в импорте; убедиться, что меню встают на место под Windows).

## 7. Риски и откат

- **Риск:** глобальный `resize` при каждом открытии меню пересчитывает позиции всех
  оверлеев. Это ровно то, что уже делает текущий ручной обход, только централизованно;
  эффект — один кадровый пересчёт, не перфоманс-проблема.
- **Риск:** наблюдатель «ловит» не только нужные меню. Митигируется фильтром по
  `.v-overlay.v-menu` и идемпотентностью стабилизации.
- **Откат:** фикс изолирован во фронтенде. Откат = вернуть прежний `menuRepositionFix.ts`,
  снять вызов в `app.ts` и вернуть ручные пометки (или просто откатить коммит/выкат).
  Деплой — overlay rsync, БД не трогается.

## 8. Вне scope (возможные follow-up)

- Дружелюбный текст ошибки ИНН на сервере («Юрлицо — 10 цифр, ИП — 12 цифр»)
  и клиентские подсказки в шаге реквизитов визарда (сейчас там, в отличие от
  `RequisitesTab`, нет проверки на лету). Отдельная UX-задача.
