Мобільні пристрої
Цілі дотику, 16px у полях, видима область із клавіатурою, безпечні зони, жести, системне «назад» і нативне відчуття — що робить бібліотека і що має зробити проєкт.
Компоненти розраховані на телефон так само, як на десктоп: мобільні розміри
пишуться першими, а md: повертає десктопну щільність. Нижче — контракт,
на який можна покладатися, і одна річ, яку мусить зробити сам проєкт.
Що має зробити проєкт
viewport-fit=cover у meta viewport. Без нього iOS тримає сторінку
поза вирізом камери й заокругленими кутами, і env(safe-area-inset-*)
завжди дорівнює нулю — тобто всі безпечні зони бібліотеки (футер модалки,
нижній sheet, тостер, UiActionBar, бічні панелі) просто не діють.
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
Разом із цим сторінка заходить під виріз, тож власний хром проєкту має
тримати бічні зони сам. Найпростіше — на body:
body {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
Фіксовані оверлеї бібліотеки рахують зони самостійно і від body не
залежать.
Нативне відчуття на дотику
Кілька рядків базового CSS проєкту прибирають ознаки «сайту» на телефоні: сірий спалах під пальцем, зум сторінки подвійним тапом і виділення тексту інтерфейсу довгим натисканням (воно конфліктує з контекстним меню).
html {
-webkit-text-size-adjust: 100%; /* iOS у ландшафті не роздуває текст */
-webkit-tap-highlight-color: transparent;
touch-action: manipulation; /* без double-tap-zoom; щипок лишається */
}
@media (hover: none) and (pointer: coarse) {
body { -webkit-user-select: none; user-select: none; }
/* iOS не дає друкувати в contenteditable під user-select: none */
input, textarea, [contenteditable='true'], .ui-selectable {
-webkit-user-select: text; user-select: text;
}
}
touch-action: manipulation на <html> діє на всю сторінку: частина
touch-action, що відповідає за зум, областями прокрутки не скидається.
Відгук на дотик тоді дають власні active:-стани — а iOS Safari вмикає
:active лише за наявності обробника touchstart, тож один раз на
застосунок:
import { enableTouchActiveStates } from '~/utils/touch'
enableTouchActiveStates()
Масштаб на дотику
Корінь 15px — щільність для миші. На дотиковому пристрої застосунок
природніше виглядає з коренем 16px: усі rem (кегль, відступи, висоти
шапок і кнопок) ростуть на ~7%, ближче до нативних 16–17pt. Межі md:/xl:
від цього не зсуваються — rem у медіазапиті рахується від шрифту браузера.
@media (pointer: coarse) {
html { font-size: 16px; }
}
Перевірте тоді власні числа в px, що залежать від кегля (оцінки висот рядків у віртуальних списках): їх треба множити на корінь документа.
Колір рядка стану
Статичні <meta name="theme-color" media="…"> знають лише тему ОС, а тему
перемикають і вручну. startThemeColor тримає рядок стану телефона й панель
браузера в кольорі шапки поточної теми застосунку (--bg-card або свій
токен); поверхня на весь екран — відеодзвінок, перегляд — кладе свій колір
поверх і знімає, коли закривається.
import { pushThemeColorSurface, startThemeColor } from '~/utils/themeColor'
startThemeColor() // один раз на застосунок
const restore = pushThemeColorSurface('--stage') // дзвінок на весь екран
Системне «назад»
Кнопка чи жест «назад» Android і «назад» браузера на телефоні мають
спершу закривати верхню шторку, меню чи вікно — так само, як Escape, — і
лише потім іти з екрана. useSystemBack робить це для всіх оверлеїв
бібліотеки (і для шарів застосунку з layers): поки шар відкритий, поверх
запису історії лежить запис-охоронець з тією самою адресою.
// main.ts — ПЕРШИМ рядком, до імпорту роутера
import '~/utils/popstateFirst'
// …
useSystemBack({ router, layers: [{ open: () => callFull.value, close: () => minimize() }] })
Слухач popstate має стати раніше за роутер: тоді крок назад, що зняв
охоронця, роутер не бачить узагалі. У Nuxt роутер створює плагін
фреймворку раніше за будь-який імпорт, тож той самий слухач ставить
інлайн-скрипт POPSTATE_FIRST_SCRIPT в app.head.script. Програмний крок
назад (router.back() з кнопки «Назад») — після await releaseSystemBack():
інакше він зняв би охоронця щойно закритого вікна, а не екран. З мишею
«назад» браузера лишається навігацією (touchOnly).
Ціль дотику — 44px зони, а не заливки
Точність дотику вимірюється цільовою зоною (W3C 2.5.8), а не видимою висотою. Тому ієрархія розмірів на телефоні лишається, а дотик тримають три прийоми:
- Фізична висота 45px там, де елемент і є рядком: поля вводу (
h-12), пункти випадайок і меню, картки вибору вUiRadioGroup, рядки налаштувань колонок таблиці. - Невидима зона 45×45 через
pointer-coarse:after:навколо дрібних елементів: кнопки, хрестики очищення, квадратикUiCheckbox, кнопки тулбара редактора, посиланняUiBreadcrumb, заголовки колонок, що сортуються. На пристрої з мишею зони немає. - Гнучка сітка, де елементи стоять впритул і зона накрила б сусіда:
клітинка дня в
UiCalendarросте до 45px, але не ширша за сьому частину екрана — на 320px вона стискається, а сітка лишається в екрані.
Для власних пунктів меню в слоті content є спільний клас — висота 45px на
телефоні, щільно на десктопі. Колір тексту додається окремо:
<script setup lang="ts">
import { menuItemClass } from '~/utils/uiFieldStyles'
</script>
<template>
<button role="menuitem" :class="[menuItemClass, 'text-danger']">Видалити</button>
</template>
Текст полів — 16px
iOS Safari при фокусі на поле з font-size менше 16px автоматично зумує
сторінку. Корінь документа — 15px, тож text-base (1rem) дає 15px і
теж зумує (з коренем 16px на дотику — див. вище — вже ні, але піксельне
значення лишається надійнішим). Тому текст полів на мобільному — text-[16px] у пікселях
(fieldTextSizes в uiFieldStyles.ts). Те саме стосується пошуку
всередині випадайок, прихованого поля UiInputOtp і самого редактора
UiRichTextEditor — contenteditable зумує так само, як <input>.
Клавіатура і видима область
Екранна клавіатура на iOS і на Android (з Chrome 108) не зменшує
innerHeight: випадайка під полем, яка за цим числом «влазить», фізично
лежить під клавіатурою. Усі плаваючі панелі бібліотеки рахують позицію від
visualViewport і переміщуються, коли клавіатура виїжджає чи ховається:
панель перевертається догори або отримує max-height і прокрутку.
Для власного оверлея — ті самі функції з utils/overlayPosition.ts:
const viewport = getVisibleViewport() // видима область, а не innerHeight
const point = placeAnchoredPanel(anchor.getBoundingClientRect(), panel, viewport)
const stop = listenViewportChanges(updatePosition) // scroll, resize, клавіатура
Прокручувані панелі мають overscroll-contain: докрутивши список до кінця,
палець не тягне за собою сторінку під оверлеєм.
Жести
| Компонент | На дотику |
|---|---|
UiTooltip | Довге натискання (0,5 с) показує підказку, відпускання ховає її через 1,5 с. Клік після довгого натискання гаситься — людина хотіла прочитати, а не натиснути. |
UiHoverCard | Не відкривається. Наведення немає, а картка, що відкривалася від тапу по посиланню, блимала перед переходом. |
UiContextMenu | Довге натискання — на Android через contextmenu, на iOS власним таймером. З haptic — короткий відгук вібрацією (Android). |
UiCarousel | Свайп із фліком і блокуванням осі; стрілок на дотику немає. |
UiDrawer | Ручка свайпу закриває панель. |
UiModal | З swipe-to-close шторка (до sm) має ручку й закривається свайпом униз за ручку чи шапку. |
UiSwipeActions | Свайп рядка ліворуч відкриває дії під ним; дотик повз, прокрутка чи свайп праворуч закривають. |
UiSwipeStack | Свайп праворуч з будь-якого місця верхнього екрана тягне його за пальцем і повертає назад. |
UiToaster | Картку можна змахнути вбік. |
| Системне «назад» | З useSystemBack закриває верхній оверлей, а не екран під ним. |
Власні жести
Ядро горизонтальних свайпів — utils/swipe.ts, спільне для UiSwipeActions
і UiSwipeStack: блокування осі (вертикаль лишається прокрутці), поріг і
кидок, опір після межі, гасіння кліку, що прийшов слідом за свайпом.
import { bindSwipe, rubberBand } from '~/utils/swipe'
const stop = bindSwipe(listEl, {
direction: 'left',
onMove: (dx) => (row.style.transform = `translateX(${rubberBand(dx, 84)}px)`),
onEnd: ({ dx, cancelled }) => { if (!cancelled && dx < -56) reply() },
})
Область жесту й кожна область прокрутки в ній мають touch-action: pan-y pinch-zoom (класи touch-pan-y touch-pinch-zoom): інакше браузер забирає
горизонтальний рух собі й шле pointercancel. Жест не починається з полів,
повзунків, полотна й будь-чого з touch-action: none (навіть інлайн-
стилем); решту виключає атрибут data-no-swipe. Миша свайпом не
користується.
Розкладка на вузьких екранах
UiModalнижчеsm— нижній sheet, а не вікно посередині.UiDateRangePickerна телефоні показує один місяць, а пресети — смугою пігулок над сіткою, що прокручується вбік.UiStepperу горизонтальному ряду нижчеsmпоказує підпис лише активного кроку; решта — кружечки й лінії, підписи лишаються для скрінрідера.UiTableнижчеmd— картки. Шапки там немає, тож сортування живе в окремому рядку над картками: нативний<select>і кнопка напрямку.UiButtonне переносить підпис: висота кнопки фіксована, і другий рядок виходив би за заливку. Ряд кнопок, що не влазить, має переноситися сам (flex-wrap).UiCarouselіUiVirtualListзаймають усю ширину батька.
Як перевіряти
Режим пристрою в DevTools з увімкненим дотиком: без нього pointer: coarse
не спрацьовує, і невидимих зон просто не видно. Свайпи в автотестах —
справжніми дотиками (у Chromium — CDP Input.dispatchTouchEvent): на відміну
від синтетичних PointerEvent вони поважають touch-action, тож видно, коли
браузер забирає жест собі. Перевіряти на 320px і
375px — у 320px першими виходять за край ряди кнопок і сітки. На справжньому
iPhone окремо варто перевірити фокус у полі (зум) і відкриту клавіатуру
під випадайкою.