GnDropdown (mobile sheet)Статус: не начата. Тип: поведение компонента + стили оверлеев (vanilla и Vue-адаптер). Повод: потребитель кита (gntodo) ловит постоянные жалобы на позиционирование выпадающих меню на телефоне и уже сделал обходной слой у себя. Задача — перенести решение в кит, чтобы обходной слой у потребителя можно было удалить.
GnDropdown позиционирует меню от левого края триггера:
.dropdown { position: relative; display: inline-flex; }
.dropdown-menu { position: absolute; top: calc(100% + #{$space-2}); left: 0; min-width: 220px; }
Ручки позиционирования у компонента нет. На телефоне (ширина 360–430px) это значит:
getBoundingClientRect, «открыть влево/вверх») — код хрупкий, зависит от момента рендера Vue и расходится с CSS кита (в gntodo это dropdownClamp.ts, и он же вынужден снимать свои инлайновые координаты при смене ширины окна).Правильное решение для узкого экрана — не подгонять координаты выпадашки, а заменить её шторкой снизу (bottom sheet): это штатный мобильный форм-фактор для списка действий, он не зависит от положения триггера вообще.
Выше 767px — всё как сейчас, пиксель в пиксель (проверять скриншотом: правило «одно изменение — одна проверка»).
До 767px включительно (то есть @include media_down('md'), см. _mixins.scss) открытое меню (< .dropdown.is-open) рендерится как шторка:
#16161e ($color-black) с opacity: .72 на весь вьюпорт, под шторкой и над контентом страницы. Тап по нему закрывает меню и не нажимает то, что под ним (слой реальный, pointer-events: auto, а не pointer-events: none).position: fixed; left: 0; right: 0; bottom: 0. Скругление верхних углов 12px, нижняя рамка снята (край экрана), левая акцентная полоса кита (6px) сводится к базовой 2px — на панели во всю ширину она читается обрубком.$border-color-muted, margin: 2px auto 6px. Без неё панель читается просто выпадашкой, зачем-то прилипшей к низу.overlay_reveal, который едет сверху вниз на 8px): translateY(100%) → 0 плюс opacity: 0 → 1, 0.22s ease, animation-fill-mode: both.min-height: 44px (в ките 34px $control-height-sm), padding: 12px 15px, font-size: $font-size-md (14px).max-height: 70vh, а следом max-height: 70dvh (в iOS vh выше видимой области; старые браузеры оставят vh), overflow-y: auto, overscroll-behavior: contain.calc(12px + env(safe-area-inset-bottom, 0px)): последний пункт не должен упираться в жест-индикатор «домой».@select/items/слоты не меняются.Точные значения — ниже в «Референс»: они уже проверены на живом приложении, и реализация в ките должна дать тот же результат, иначе потребителю нечего удалять.
Поведение включается само (по медиазапросу) — потребителю нечего настраивать, именно поэтому он сможет удалить свой обходной слой. Для тех, кому шторка не нужна, добавьте отказ без изменения API по умолчанию:
mobileSheet: { type: Boolean, default: true }; при false на корень не вешается класс-модификатор dropdown-no-sheet;dropdown-no-sheet на .dropdown руками.В мобильном блоке CSS все селекторы — через :not(.dropdown-no-sheet).
Слой не может быть потомком ничего, что считается «внутри меню», иначе тап по нему закрытия не даст — это проверено с двух сторон:
GnDropdown.js): onOutsideClick закрывает только если !root.value.contains(event.target);src/js/components/overlays.js, initDismiss): закрывает только если !event.target.closest(".dropdown, .popover, .tooltip").Кладите слой внутрь корня .dropdown, перед меню (.dropdown — display: inline-flex; position: relative без z-index, поэтому position: fixed потомок ничего не ломает), но закройте его явным обработчиком:
h("div", { class: "dropdown-backdrop", onClick: close }) — рендерится всегда (на десктопе скрыт CSS, значит кликнуть по нему нельзя и поведение не меняется);Overlays.init() гарантирует по одному .dropdown-backdrop в каждом .dropdown (создать, если нет), а в initDismiss добавьте .dropdown-backdrop в исключение — то есть клик по нему не считается «внутри меню».Место — 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 внутри мобильного блока.Проверенные значения (можно переносить один в один):
.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..drawer-panel или .modal-dialog (у обоих transform → содержащий блок), position: fixed отсчитывается от панели и шторка окажется внутри неё. Поддержать это можно только телепортацией меню и слоя в body на мобильных — тогда обработчику закрытия придётся учитывать узел меню вне корня (иначе тап по пункту будет считаться «вне меню» и закроет меню раньше выбора).GnDrawer, ни GnModal; если решите блокировать, то сразу для всех оверлеев, а не только для дропдауна.role="dialog" aria-modal="true" для шторки — по желанию: это потребует matchMedia в компоненте и такой же логики в vanilla-демо. Если делаете — в обеих сборках одинаково.GnPopover, панель GnCombobox и GnSelect страдают тем же на узком экране. В этой задаче их не трогаем, но паттерн стоит держать в голове на будущее.src/scss/components/_navigation-overlays.scss (+ токен $border-radius-lg в _design-tokens.scss), Vue — src/vue/components/GnDropdown.js, vanilla — src/js/components/overlays.js.demo/index.html (vanilla demo/partials/navigation-overlays.html) и demo/vue.html — если слой создаёт JS, а не разметка, синхронность держится сама; если добавляете слой в разметку, добавьте в оба демо и в сниппеты code-examples. После — node compare-heights.js (0 расхождений, сервер демо на localhost:3000).GnPageHeader.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.npm run release:check — полный гейт (юнит-тесты контрактов, сборка адаптера и примеров, смоук пакета, синхронность демо).tests/unit/), если в репозитории есть аналоги для оверлеев: открытие — is-open, тап по затемнению — меню закрыто и класс снят, mobileSheet: false — слоя в раскладке нет.Ширина 390×844, страница с дропдауном:
x = 0, width = 390, bottom = 844, position: fixed, z-index выше топбара; затемнение видно на всей странице, включая топбар;GnPageHeader) → панель у нижнего края экрана, а не шапки; список из 10+ пунктов прокручивается, страница по горизонтали не едет;z-index: 40, border-left-width: 6px).В gntodo то же самое уже сделано обходным путём, и он проверен на живом приложении: затемняющий слой лежит вне #app и показывается по body:has(.dropdown.is-open) (в чужой компонент разметку не влезть), а мобильная ветка выравнивания координат выключена. Как только кит отдаст шторку сам, потребитель удаляет: CSS-блок переопределения, слой в index.html и мобильную ветку в dropdownClamp.ts. Поэтому важны две вещи: поведение по умолчанию (без обязательного пропа) и совпадение значений из «Референса».