Оверлеї та фокус
Спільний стек шарів, лічильник блокування прокрутки і пастка фокуса, що розуміє телепорти.
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.