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

Оверлеї та фокус

Спільний стек шарів, лічильник блокування прокрутки і пастка фокуса, що розуміє телепорти.

UiModal, UiDrawer і UiConfirmDialog не мають власної логіки оверлея. Уся вона в чотирьох композаблах, які вони ділять. Через це вкладені оверлеї працюють правильно — а це саме те, що ламалося у вихідних проєктах.

Плаваючі панелі — UiPopover, UiMenu, UiContextMenu, UiTooltip, UiHoverCard — стоять у тому самому стеку через useFloatingLayer. Він не дає їм z-index, зате вирішує за них Escape і клік «повз».

Проблема, яку це розв'язує

Раніше кожен компонент рахував z-index сам: модалка мала власний стек від 1001, drawer — захардкоджений z-[1000], діалог підтвердження — 99999. Наслідки:

  • drawer, відкритий із модалки, опинявся під нею;
  • два drawer'и один над одним розрулювалися лише порядком у DOM;
  • Escape закривав усі відкриті оверлеї одразу;
  • закриття внутрішнього оверлея розблоковувало прокрутку фону, поки зовнішній ще відкритий.

Шкала z-index

1000  перший оверлей
1010  другий (крок 10)
1020  третій
…
1100  випадайки UiSelect
9999  тости

Крок 10 лишає місце для внутрішніх шарів панелі — sticky-шапки, футера.

1100 для випадайок обране так, щоб перші ~10 оверлеїв лишалися нижче: селект усередині модалки має бути видимим над нею.

Тости на 9999 і з data-overlay-ignore — вони мусять лишатися клікабельними навіть під відкритою модалкою.

Плаваючі панелі теж записані в стек, але крок модальних не зсувають: відкрита підказка не змінює z-index модалки, що відкривається після неї. Їхній z-index рахує getOverlayChildZIndex — від 1100 або на одиницю вище за найближчий оверлей.

Шар знімається одразу, z-index — після анімації

Це найнеочевидніша частина. При закритті шар виходить зі стеку негайно, а його z-index лишається зафіксованим до кінця анімації виходу.

Якби шар чекав завершення анімації, будь-який незавершений leave (перервана анімація, фонова вкладка) лишав би мертвий запис у стеку. Тоді isTopmost брехав би, і Escape перестав би закривати видимий оверлей.

Заморожування z-index потрібне з іншого боку: без нього панель, що закривається, пірнула б під сусідній оверлей посеред анімації.

Escape і клік «повз» — один на всіх

Раніше кожна плаваюча панель слухала document сама, і вигравав той, хто зареєструвався першим або слухав у фазі захоплення:

  • Escape у відкритому селекті всередині поповера закривав увесь поповер;
  • Escape у діалозі, відкритому з пункту меню, закривав меню під ним;
  • Escape для підказки в модалці закривав і модалку разом із формою;
  • меню в модалці не закривалося від кліку деінде в модалці, бо ігнорувало будь-який [role="dialog"];
  • вибір у селекті всередині поповера закривав поповер раніше, ніж змінювалося значення.

Тепер слухачів два на весь застосунок, і ставляться вони під час завантаження модуля — раніше за будь-який слухач компонента.

Escape дістається рівно одному шару — останньому в стеку. Слухач — у фазі спливання, тож UiSelect, UiCombobox і решта, що зупиняють Escape на своєму елементі, обробляють його першими.

Клік «повз» не рахує нічого, що з'явилося в <body> після відкриття шару: випадайку селекта всередині поповера, вкладене меню, діалог поверх. Список ролей-винятків для цього не годиться — модалка теж dialog.

Підказка й hover-картка — пасивні шари: Escape вони отримують, але isTopmost у шару під собою не забирають. Стрілки в галереї мають гортати, поки над кнопкою висить підказка.

Модальний шар передає свій Escape так само: useOverlayLayer(id, { onEscape }). Компонент, що має власний слухач на document, продовжує працювати через isTopmost — спільний слухач спрацьовує раніше і для підказки чи меню над ним зупиняє подію сам.

Скільки шарів відкрито

useOverlayStack().blockingCount — кількість відкритих шарів, які людина закриває сама: модальних і плаваючих, без пасивних підказок. Лічильник реактивний і рахує вже відкриті шари — Escape після нього закриває верхній із них.

На ньому стоїть useSystemBack: системне «назад» на телефоні закриває верхню шторку чи меню (тим самим шляхом, що й Escape), а не йде з екрана під ними. Поки відкрито N шарів, поверх запису історії лежить N записів-охоронців з тією самою адресою; закрили шар хрестиком — охоронець прибирається кроком назад, якого роутер не бачить. Налаштування — у «Мобільних пристроях».

Прокрутка блокується з лічильником

position: fixed, а не overflow: hidden — на iOS Safari overflow не зупиняє «гумовий» скрол фону.

Лічильник тримачів означає, що фон розблоковується лише коли закрився останній оверлей.

Позиція прокрутки зберігається і відновлюється, а scroll-behavior примусово ставиться в auto — інакше повернення на місце анімувалося б.

Пастка фокуса розуміє телепорти

Головний нюанс. UiSelect рендерить випадайку прямо в <body> — тобто формально поза панеллю модалки.

Тому «фон» визначається не як «усе поза контейнером», а як «те, що ця пастка сама позначила inert у момент активації». Елементи, які з'явилися пізніше — та сама випадайка — лишаються клікабельними й фокусованими.

Вкладеність працює з лічильником на кожен елемент фону: коли поверх drawer'а відкривається модалка, її пастка позначає inert і корінь drawer'а теж, а атрибути знімаються лише тоді, коли елемент відпустила остання пастка, що його тримала.

Раніше кожна пастка відновлювала власний знімок атрибутів. Закриття не в порядку LIFO — обидва оверлеї одним кліком «Зберегти й закрити», навігація, drawer із модалкою в слоті — лишало корінь застосунку з inert і aria-hidden назавжди: сторінка переставала реагувати на все. З тієї ж причини пастка, що закривається, поки над нею ще відкрита інша, фокус не повертає, а передає свою ціль верхній — щоб фокус зрештою дістався кнопки, з якої відкрили весь ланцюжок.

Різні дефолти closeOnBackdrop

UiModal — true, UiDrawer — false. Це не непослідовність: модалка зазвичай інформаційна, і випадкове закриття нічого не коштує. Drawer майже завжди містить форму, де це втрата введеного.

Обидва вимагають, щоб натискання і почалося, і закінчилося на фоні. Без цього виділення тексту зсередини панелі з відпусканням мишки над фоном закриває вікно разом із незбереженою формою.

Оверлей у кожному рядку списку

Меню, підказка чи селект часто стоять у кожному рядку довгого списку. Тоді важить не відкрита панель, а сотня закритих:

  • Teleport вмикається з першим відкриттям (useLazyTeleport), а не в onMounted. Перемикання одразу після монтування рендерило кожен екземпляр удруге — разом зі слотом-тригером, тобто з усім рядком. На сервері Teleport однаково вимкнений, тож гідратація збігається.
  • Слухачі вікна й документа — лише поки панель відкрита. Закрите меню чи поповер раніше тримали чотири слухачі прокрутки й resize (разом із visualViewport), підказка — прокрутку сторінки, селект — pointerdown на документі у фазі захоплення: кожен дотик і кожна подія прокрутки проходили крізь усі екземпляри на сторінці.
  • Контекстне меню списку — одне на список. UiContextMenu з targetSelector віддає targetAttrs контейнеру й відкривається для рядка під подією — див. сторінку ContextMenu.