Newer
Older
gnexus-ui-kit / docs / tasks / gn-dropdown-mobile-sheet.md
@Eugene Sukhodolskiy Eugene Sukhodolskiy 9 hours ago 16 KB New tasks

Задача: мобильная шторка у 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) это значит:

  • у триггера у правого края экрана меню шириной 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 внутри мобильного блока.

Референс

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

.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. Поэтому важны две вещи: поведение по умолчанию (без обязательного пропа) и совпадение значений из «Референса».