Перейти до вмісту
tatetUI

Мобільні пристрої

Цілі дотику, 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 окремо варто перевірити фокус у полі (зум) і відкриту клавіатуру під випадайкою.