# Задача: мобильная шторка у `GnDropdown` (mobile sheet)

**Статус:** не начата.
**Тип:** поведение компонента + стили оверлеев (vanilla и Vue-адаптер).
**Повод:** потребитель кита (gntodo) ловит постоянные жалобы на позиционирование
выпадающих меню на телефоне и уже сделал обходной слой у себя. Задача — перенести
решение в кит, чтобы обходной слой у потребителя можно было удалить.

## Проблема

`GnDropdown` позиционирует меню **от левого края триггера**:

```scss
.dropdown { position: relative; display: inline-flex; }
.dropdown-menu { position: absolute; top: calc(100% + #{$space-2}); left: 0; min-width: 220px; }
```

Ручки позиционирования у компонента нет. На телефоне (ширина 360–430px) это значит:

- у триггера у правого края экрана меню шириной 220px уезжает за вьюпорт — часть
  пунктов недоступна;
- у триггера у нижнего края меню уходит под нижнюю границу экрана;
- потребителю приходится после каждого открытия править координаты инлайном
  (`getBoundingClientRect`, «открыть влево/вверх») — код хрупкий, зависит от
  момента рендера Vue и расходится с CSS кита (в gntodo это `dropdownClamp.ts`,
  и он же вынужден снимать свои инлайновые координаты при смене ширины окна).

Правильное решение для узкого экрана — не подгонять координаты выпадашки, а
**заменить её шторкой снизу** (bottom sheet): это штатный мобильный форм-фактор для
списка действий, он не зависит от положения триггера вообще.

## Целевое поведение

Выше 767px — **всё как сейчас**, пиксель в пиксель (проверять скриншотом: правило
«одно изменение — одна проверка»).

До 767px включительно (то есть `@include media_down('md')`, см. `_mixins.scss`)
открытое меню (`< .dropdown.is-open`) рендерится как шторка:

1. **Затемнение.** Слой `#16161e` (`$color-black`) с `opacity: .72` на весь вьюпорт,
   под шторкой и над контентом страницы. Тап по нему **закрывает меню** и не
   нажимает то, что под ним (слой реальный, `pointer-events: auto`, а не
   `pointer-events: none`).
2. **Панель.** Во всю ширину экрана, прижата к низу: `position: fixed; left: 0;
   right: 0; bottom: 0`. Скругление верхних углов `12px`, нижняя рамка снята (край
   экрана), левая акцентная полоса кита (6px) сводится к базовой 2px — на панели во
   всю ширину она читается обрубком.
3. **«Ручка»-грабер** сверху панели: 36×4px, радиус-пилюля, цвет
   `$border-color-muted`, `margin: 2px auto 6px`. Без неё панель читается просто
   выпадашкой, зачем-то прилипшей к низу.
4. **Появление** — выезд снизу (не китовый `overlay_reveal`, который едет сверху
   вниз на 8px): `translateY(100%) → 0` плюс `opacity: 0 → 1`, `0.22s ease`,
   `animation-fill-mode: both`.
5. **Пункты под палец:** `min-height: 44px` (в ките 34px `$control-height-sm`),
   `padding: 12px 15px`, `font-size: $font-size-md` (14px).
6. **Длинный список** прокручивается: `max-height: 70vh`, а следом
   `max-height: 70dvh` (в iOS `vh` выше видимой области; старые браузеры оставят
   `vh`), `overflow-y: auto`, `overscroll-behavior: contain`.
7. **Нижний отступ** — `calc(12px + env(safe-area-inset-bottom, 0px))`: последний
   пункт не должен упираться в жест-индикатор «домой».
8. **Закрытие** — как сейчас: тап по затемнению, Escape, выбор пункта, повторный
   тап по триггеру, клик мимо. `@select`/`items`/слоты не меняются.

Точные значения — ниже в «Референс»: они уже проверены на живом приложении, и
реализация в ките должна дать тот же результат, иначе потребителю нечего удалять.

## Как это сделать в ките

### Вариант по умолчанию, с возможностью отказа

Поведение включается само (по медиазапросу) — потребителю нечего настраивать,
именно поэтому он сможет удалить свой обходной слой. Для тех, кому шторка не нужна,
добавьте отказ **без изменения API по умолчанию**:

- Vue: проп `mobileSheet: { type: Boolean, default: true }`; при `false` на корень
  не вешается класс-модификатор `dropdown-no-sheet`;
- vanilla: класс `dropdown-no-sheet` на `.dropdown` руками.

В мобильном блоке CSS все селекторы — через `:not(.dropdown-no-sheet)`.

### Затемняющий слой

Слой **не может быть потомком ничего, что считается «внутри меню»**, иначе тап по
нему закрытия не даст — это проверено с двух сторон:

- Vue (`GnDropdown.js`): `onOutsideClick` закрывает только если
  `!root.value.contains(event.target)`;
- vanilla (`src/js/components/overlays.js`, `initDismiss`): закрывает только если
  `!event.target.closest(".dropdown, .popover, .tooltip")`.

Кладите слой **внутрь корня `.dropdown`, перед меню** (`.dropdown` —
`display: inline-flex; position: relative` без `z-index`, поэтому `position: fixed`
потомок ничего не ломает), но закройте его явным обработчиком:

- Vue: `h("div", { class: "dropdown-backdrop", onClick: close })` — рендерится
  всегда (на десктопе скрыт CSS, значит кликнуть по нему нельзя и поведение не
  меняется);
- vanilla: `Overlays.init()` гарантирует по одному `.dropdown-backdrop` в каждом
  `.dropdown` (создать, если нет), а в `initDismiss` добавьте `.dropdown-backdrop`
  в исключение — то есть клик по нему **не** считается «внутри меню».

### CSS

Место — `src/scss/components/_navigation-overlays.scss`, блок `@include
media_down('md')`. Новое значение радиуса (12px) стоит завести токеном в
`_design-tokens.scss` (`$border-radius-lg: 12px`) — токены здесь единственный
источник правды.

**Подводный камень, который сломает шторку: `.page-header` кита держит
`transform` навсегда.** `animation: panel_boot $motion-slow $motion-ease both`
(`_page-header.scss:16`) — заливка `both` оставляет конечный кадр
`transform: translateY(0)`, а любой не-`none` transform делает элемент
**содержащим блоком для `position: fixed`**. Меню внутри `GnPageHeader` будет
раскладываться по коробке шапки, а не по экрану (в gntodo это ловили живьём:
шторка «⋯» из шапки страницы уезжала внутрь шапки). Варианты, оба пригодны:

- снять у `.page-header` заливку `both` — визуально ничего не меняется (статичный
  стиль совпадает с конечным кадром), а вечный transform уходит у всех
  потребителей; **предпочтительно**;
- либо `animation: none` для `.page-header` внутри мобильного блока.

### Референс

Проверенные значения (можно переносить один в один):

```scss
.dropdown-backdrop { display: none; }

@include media_down("md") {
  .dropdown.is-open {
    &:not(.dropdown-no-sheet) {
      .dropdown-backdrop {
        display: block;
        position: fixed;
        inset: 0;
        z-index: 1090; // выше лестницы drawer/modal (1000/1010/1020):
                       // меню может открываться изнутри них
        background: $color-black;
        opacity: 0.72;
      }

      .dropdown-menu {
        position: fixed;
        top: auto;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 1100;
        min-width: 0;
        max-width: none;
        max-height: 70vh;
        max-height: 70dvh;
        overflow-y: auto;
        overscroll-behavior: contain;
        border-bottom: 0;
        border-left-width: $border-width-base;
        border-radius: $border-radius-lg $border-radius-lg 0 0;
        padding: $space-1 $space-1 calc(#{$space-3} + env(safe-area-inset-bottom, 0px));
        animation: dropdown_sheet $motion-slow $motion-ease both; // 0.28s у кита; в gntodo 0.22s

        &::before {
          content: "";
          display: block;
          width: 36px;
          height: 4px;
          margin: 2px auto 6px;
          border-radius: $border-radius-pill;
          background: $border-color-muted;
        }

        .dropdown-item {
          min-height: 44px;
          padding: $space-3 $space-4;
          font-size: $font-size-md;
        }
      }
    }
  }
}

@keyframes dropdown_sheet {
  from { opacity: 0; transform: translateY(100%); }
  to { opacity: 1; transform: translateY(0); }
}
```

### Границы задачи

- **Не трогаем** десктопную раскладку, разметку меню, `items`/слоты, `@select`,
  Escape, ARIA-контракт `menu`/`menuitem`.
- **Не телепортируем** меню. Но зафиксируйте в docs известное ограничение: если
  меню открывается изнутри `.drawer-panel` или `.modal-dialog` (у обоих
  `transform` → содержащий блок), `position: fixed` отсчитывается от панели и
  шторка окажется внутри неё. Поддержать это можно только телепортацией меню и
  слоя в `body` на мобильных — тогда обработчику закрытия придётся учитывать узел
  меню **вне** корня (иначе тап по пункту будет считаться «вне меню» и закроет
  меню раньше выбора).
- **Прокрутку страницы под шторкой не блокируем** — в ките её не блокируют ни
  `GnDrawer`, ни `GnModal`; если решите блокировать, то сразу для всех оверлеев, а
  не только для дропдауна.
- `role="dialog" aria-modal="true"` для шторки — по желанию: это потребует
  `matchMedia` в компоненте и такой же логики в vanilla-демо. Если делаете — в
  обеих сборках одинаково.
- `GnPopover`, панель `GnCombobox` и `GnSelect` страдают тем же на узком экране.
  В этой задаче их не трогаем, но паттерн стоит держать в голове на будущее.

## Что нужно сдать (приёмка по правилам репозитория)

1. Стили — `src/scss/components/_navigation-overlays.scss` (+ токен
   `$border-radius-lg` в `_design-tokens.scss`), Vue — `src/vue/components/GnDropdown.js`,
   vanilla — `src/js/components/overlays.js`.
2. Демо: разметка пункта «Dropdown» должна совпасть в `demo/index.html` (vanilla
   `demo/partials/navigation-overlays.html`) и `demo/vue.html` — если слой создаёт
   JS, а не разметка, синхронность держится сама; если добавляете слой в разметку,
   добавьте в оба демо и в сниппеты `code-examples`. После — `node compare-heights.js`
   (0 расхождений, сервер демо на `localhost:3000`).
3. Скриншоты до/после: десктоп (не изменился) и мобильная ширина 390px — открытая
   шторка из карточки и из `GnPageHeader`.
4. Документация: `docs/catalog.json` (строка `GnDropdown` — проп `mobileSheet`) и
   `npm run gen:catalog` после правки; `docs/vue/component-api.md`;
   `docs/components/navigation.md` (раздел «Dropdown, Tooltip, Popover» — про
   мобильное поведение и известное ограничение с drawer/modal);
   `docs/javascript.md` — про слой в vanilla-разметке/JS.
5. `npm run release:check` — полный гейт (юнит-тесты контрактов, сборка адаптера и
   примеров, смоук пакета, синхронность демо).
6. Юнит-тест на контракт (в `tests/unit/`), если в репозитории есть аналоги для
   оверлеев: открытие — `is-open`, тап по затемнению — меню закрыто и класс снят,
   `mobileSheet: false` — слоя в раскладке нет.

## Как проверить глазами (сценарий)

Ширина 390×844, страница с дропдауном:

- открыть меню → панель `x = 0, width = 390, bottom = 844`, `position: fixed`,
  `z-index` выше топбара; затемнение видно на всей странице, включая топбар;
- тап в любую точку затемнения → меню закрыто, ничего под слоем не нажато
  (в частности не сработала ссылка карточки);
- открыть меню из шапки страницы (`GnPageHeader`) → панель у нижнего края
  **экрана**, а не шапки; список из 10+ пунктов прокручивается, страница по
  горизонтали не едет;
- выбор пункта → переход/действие выполняется, затемнение исчезает;
- Escape, повторный тап по триггеру — закрывают;
- ширина 1024px и 1440px → меню как раньше (выпадашка у триггера, затемнения нет,
  `z-index: 40`, `border-left-width: 6px`).

## Почему именно так (контекст от потребителя)

В gntodo то же самое уже сделано обходным путём, и он проверен на живом приложении:
затемняющий слой лежит **вне** `#app` и показывается по `body:has(.dropdown.is-open)`
(в чужой компонент разметку не влезть), а мобильная ветка выравнивания координат
выключена. Как только кит отдаст шторку сам, потребитель удаляет: CSS-блок
переопределения, слой в `index.html` и мобильную ветку в `dropdownClamp.ts`.
Поэтому важны две вещи: поведение **по умолчанию** (без обязательного пропа) и
совпадение значений из «Референса».
