diff --git a/README.md b/README.md index e670e83..23913de 100644 --- a/README.md +++ b/README.md @@ -439,4 +439,7 @@ Технические задачи: -- На текущий момент технический backlog пуст; дальше расширяем компонентный набор. +- **Мобильная шторка у `GnDropdown`** — до 767px меню открывается листом снизу + поверх затемнения вместо выпадашки у триггера: на телефоне выпадашка у правого + края экрана уезжает за вьюпорт, и потребители правят координаты инлайном. + Спецификация, значения и приёмка: [`docs/tasks/gn-dropdown-mobile-sheet.md`](docs/tasks/gn-dropdown-mobile-sheet.md). diff --git a/docs/tasks/gn-dropdown-mobile-sheet.md b/docs/tasks/gn-dropdown-mobile-sheet.md new file mode 100644 index 0000000..1f9a01f --- /dev/null +++ b/docs/tasks/gn-dropdown-mobile-sheet.md @@ -0,0 +1,254 @@ +# Задача: мобильная шторка у `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`. +Поэтому важны две вещи: поведение **по умолчанию** (без обязательного пропа) и +совпадение значений из «Референса».