Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: ModalRoot/ModalPage/ModalCard (#6759)
h2. Описание <details><summary>Переписаны компоненты ModalPage/ModalCard</summary> <p>⚠️ Теперь компоненты могут использоваться без `ModalRoot`. `ModalPage` / `ModalCard`: - добавлено свойство `open`; - добавлено свойство `keepMounted`; - типа `onClose` изменён с `VoidFunction` на `(reason: ModalPageCloseReason, event?: UIEvent<HTMLElement>) => void`; - добавлено свойство `noFocusToDialog`, приоритетней чем `noFocusToDialog` в `ModalRoot`; - добавлено свойство `modalOverlayTestId`, приоритетней чем `modalOverlayTestId` в `ModalRoot`. - создан компонент `ModalOutlet`; - создан компонент `ModalOverlay`; - основной код работы модалок вынесен в отдельные компоненты `ModalPageInternal` и `ModalCardInternal`, у них есть свойство `ModalOverlay`; - создан контекст `ModalContext`, который теперь используется в компонентах `ModalPageHeader`, `Group` и `PanelHeader` вместо `ModalRootContext`. `ModalPage`: - `settlingHeight` теперь имеет значение по умолчанию `50%` вместо `75%`, обратил внимание, что в обычно `BottomSheet`'ы открываются на половину экрана; - добавлено свойство `footer`, а также создан компонент `ModalPageFooter`; - созданы компонент `ModalPageContent` – вынес, чтобы можно было в будущем перейти на сбор `ModalPage` через композицию компонентов. `lib/sheet`: В папке хранится логика отвечающая за взаимодействие с модалкой на мобильных экранах. Отдаёт хук `useBottomSheet()`. </p> </details> <details><summary>Переписан компонент ModalRoot</summary> <p>⚠️ Теперь лишь отвечает за состояние `open` компонентов `ModalCard` и `ModalPage` в зависимости от их `id`/`nav` и параметра `activeModal`, а также рендера общей `ModalOverlay` для всех модалок. `ModalRoot`: - добавлены события `onOpen`, `onOpened`, `onClosed`, которые всплывают от `activeModal`; - в `PopoutRoot` удалён `PopoutRootModal` в пользу `ModalOutlet` у `ModalPage` и `ModalCard`. `ModalRootContext`: - **⚠️ BREAKING CHANGE** в свойство `onClose` нужно теперь обязательно передавать `id` модального окна; - добавлены события `onOpen`, `onOpened`, `onClosed`, чтобы их могли вызывать `ModalPage` и `ModalCard`; - свойство `registerModal` теперь `@deprecated` – не нужно отдельно регистрировать модальное окно. - свойство `updateModalHeight` теперь `@deprecated` – задача с обновлением высоты контента при `dynamicContentHeight` решается через CSS; `ModalRootOverlayContext` / `VisuallyHiddenModalOverlay`: - чтобы создать общий `ModalOverlay` для всех модалок в контексте `ModalRoot`, происходит подмена `ModalOverlay` в `ModalPage` и `ModalCard` на `VisuallyHiddenModalOverlay`, который отвечает за приём `onClick` и `modalOverlayTestId`, а сам `ModalOverlay` попадает в начало `ModalRoot` `useModalManager()`: - отвечает за `unmounted` состояние; - отвечает за регулирования приоритета параметров из `ModalRoot` и `ModalPage`/`ModalCard`; - отвечает за подмену `ModalOverlay` на `VisuallyHiddenModalOverlay`. `withModalRootContext`: - ввиду отказа от `updateModalHeight` HOC тоже `@deprecated`. </p> </details> h2. Нюансы <details><summary>Обратная совместимость</summary> <p> Постарался сделать так, чтобы миграция прошла бесследно. Сломаются вот такой кейс: ```tsx const MyModal = ({ id }) => { // пропустили settinglingHeight / dynamicContentHeight return <ModalPage>Lorem Ipsum</ModalPage> }; const App = () => ( <ModalRoot> <MyModal id="example-1" settinglingHeight={100} // устанавливалось здесь, т.к. раньше ModalRoot итерировал по потомкам и доставал этот параметр /> <MyModal id="example-2" dynamicContentHeight // устанавливалось здесь, т.к. раньше ModalRoot итерировал по потомкам и доставал этот параметр /> </ModalRoot> ); ``` который нужно будет править руками. </p> </details> <details><summary>BottomSheet, анимации и свайп</summary> <p> Полное появление и полное скрытие происходит через `transform`, но анимация взаимодействия через свайп реализована через `height`, т.к. это оказалось самым оптимальным способом для решения задач: - закреплённый `ModalPageFooter` внизу; - возможность скроллить при `settlingHeight` меньше `100`; - обновление высоты, если задан `dynamicContentHeight`. Нашёл решение допустимым, т.к. свайп используется либо для закрытия, либо для разворачивания/сворачивания модального окна на всю или на половину страницы. В первом случае сработает закрытие через `transform`, а во втором разворачивание/сворачивание произойдёт через `height`. </p> </details> <details><summary>dynamicContentHeight</summary> <p> > см. предыдущий пункт про **BottomSheet** для контекста Обновление высоты происходит без анимации, т.к. `height: auto` не анимируется. Опустил анимирование, т.к. усложняет компонент. В теории можно прибегнуть к `useResizeObserver()`. </p> </details> <details><summary>Адативность</summary> <p> При `platform="vkcom"` и при разрешении экрана `767px` компонент теперь превращается в **BottomSheet**, но при этом логику взаимодействия через тач не имеет, т.к. `isDesktop` при `platform="vkcom"` всегда `true` вне зависимости от размера экрана. </p> </details> h2. Решения <details><summary>Выделение текста</summary> <p> С помощью функции `hasSelectionWithRangeType` определяем, что пользователь выделил текст и перестаём реагировать на `touchstart` и `touchmove` пока выделение не будет удалено. </p> </details> <details><summary>Вертикальный и горизонтальный скроллы</summary> <p> - **вертикальный скролл:**: - **основной скролл (`ModalPageContent`)**: проверяем на `scrollTop !== 0` - **другие скроллы**: при `touchstart` достаём скроллируемый элемент через `event.target` и проверяем на положение `scrollTop !== 0` и направление пальца вверх - **горизонтальный скролл:** не блочится, т.к. реагируем на события только по оси Y. </p> </details> <details><summary>Плавающие элементы внутри модалки</summary> <p> Нужно рекомендовать использовать `forcePortal` – в коде проверяем, что идёт взаимодействие с элементом вне модалки. Или нужно рекомендовать добавлять в корневой элемент плавающего элемента атрибут `data-vkui-prevent-swipe` . </p> </details> <details><summary>Поля ввода</summary> <p> Наилучшего варианта не нашёл кроме как: 1. через `useVirtualKeyboardState()` узнавать, что пользователь работает с клавиатурой, и перебивать `safe-area-inset-bottom` на ~тот, что возвращает хук~ **0** (попытка вычислять разницу высоты через `VisualViewport`, чтобы реагировать на смену размера клавиатуры, например, из-за панели эмодзи, не удалась); 2. в том же хуке слушать событие скролла на `window` и сохранять его позицию на `window.scrollTo(0, visualViewport.offsetTop)`. Так как иные решения приводят к другим проблемам (подробнее можно прочесть в **JSDoc** хука `useVirtualKeyboardState()`), следующие баги нужно закрыть: - [#338](https://github.com/VKCOM/VKUI/issues/338) - [#599](https://github.com/VKCOM/VKUI/issues/599) </p> </details> h2. Референсы - [Youtube • Универсальные попапы или UIKit против / Антон Спивак](https://www.youtube.com/watch?v=jQC_jxtf500) - [Habr • Bottom sheet: Scrolling and interactions](https://habr.com/ru/companies/koshelek/articles/703260/) - [How to present a Bottom Sheet in iOS 15 with UISheetPresentationController](https://sarunw.com/posts/bottom-sheet-in-ios-15-with-uisheetpresentationcontroller/) - [GitHub • ionic-framework/core/src/components/modal](https://github.com/ionic-team/ionic-framework/tree/main/core/src/components/modal) - [GitHub • stipsan/react-spring-bottom-sheet](https://github.com/stipsan/react-spring-bottom-sheet) - [GitHub • gorhom/react-native-bottom-sheet](https://github.com/gorhom/react-native-bottom-sheet) - [GitHub • stanleyugwu/react-native-bottom-sheet](https://github.com/stanleyugwu/react-native-bottom-sheet) - [GitHub • Temzasse/react-modal-sheet](https://github.com/Temzasse/react-modal-sheet) - [GitHub • tech-systems/panes](https://github.com/tech-systems/panes) - [#5092](https://github.com/VKCOM/VKUI/issues/5092) – Пример рефактора модалок с помощью touch-событий h2. Release notes h2. BREAKING CHANGE - ModalRoot: удалена реализация контекста через `React.cloneElement`, теперь не нужно задавать `settlingHeight` и `dynamicContentHeight` на обёртку над `ModalPage` / `ModalCard` <details> <summary>Пример миграции 1</summary> ```diff const SomeWrapper = ({ id }) => ( <Modal id={id} + settlingHeight={100} /> ); <ModalRoot activeModal="m"> <SomeWrapper id="m" - settlingHeight={100} /> </ModalRoot> ``` </details> <details> <summary>Пример миграции 2</summary> ```diff - const SomeWrapper = ({ id }) => ( + const SomeWrapper = (props) => ( <Modal - id={id} + {...props} /> ); <ModalRoot activeModal="m"> <SomeWrapper id="m" settlingHeight={100} /> </ModalRoot> ``` </details> h2. Улучшения - ModalRoot: - `updateModalHeight()` помечен как `@depreacted`, т.к. в нём больше нет необходимости – `ModalPage`, при `dynamicContentHeight`, теперь автоматически подстраиваются под контент; - `registerModal()` помечен как `@depreacted`, т.к. изменилась логика работы компонента – теперь `ModalPage` и `ModalCard` ориентируется на контекст, создаваемый `ModalRoot`; - добавлено свойство `usePortal`. - `ModalPage`: - теперь можно использовать без `ModalRoot` (для рендера в портале можно обернуть в `AppRootPortal`); - изменилось значение по умолчанию у свойств `settlingHeight` – `75` → `50` ; - добавлено свойство `keepMounted`; - добавлено свойство `footer`; - добавлено свойство `disableContentPanningGestureProp`; - расширен тип `onClose` до `(reason: ModalPageCloseReason, event?: UIEvent<HTMLElement>) => void`. - `ModalCard`: - теперь можно использовать без `ModalRoot` (для рендера в портале можно обернуть в `AppRootPortal`); - добавлено свойство `keepMounted`; - расширен тип `onClose` до `(reason: ModalPageCloseReason, event?: UIEvent<HTMLElement>) => void`.
- Loading branch information