Skip to content

Commit

Permalink
refactor: ModalRoot/ModalPage/ModalCard (#6759)
Browse files Browse the repository at this point in the history
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
inomdzhon authored Dec 2, 2024
1 parent 29278ca commit 1234239
Show file tree
Hide file tree
Showing 79 changed files with 4,571 additions and 3,286 deletions.
12 changes: 3 additions & 9 deletions packages/vkui/src/components/Group/Group.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
import { classNames, noop } from '@vkontakte/vkjs';
import { ModalContext } from '../../context/ModalContext';
import type { SizeTypeValues } from '../../lib/adaptivity';
import { baselineComponent } from '../../testing/utils';
import { AdaptivityContext } from '../AdaptivityProvider/AdaptivityContext';
Expand All @@ -8,7 +9,6 @@ import {
type AppRootContextInterface,
DEFAULT_APP_ROOT_CONTEXT_VALUE,
} from '../AppRoot/AppRootContext';
import { ModalRootContext } from '../ModalRoot/ModalRootContext';
import { Group, type GroupProps } from './Group';
import styles from './Group.module.css';

Expand Down Expand Up @@ -75,17 +75,11 @@ describe('Group', () => {
}}
>
<AdaptivityContext.Provider value={{ sizeX }}>
<ModalRootContext.Provider
value={{
isInsideModal,
updateModalHeight: noop,
registerModal: noop,
}}
>
<ModalContext.Provider value={isInsideModal ? 'test' : null}>
<Group mode={mode} data-testid="group">
<div />
</Group>
</ModalRootContext.Provider>
</ModalContext.Provider>
</AdaptivityContext.Provider>
</AppRootContext.Provider>,
);
Expand Down
4 changes: 2 additions & 2 deletions packages/vkui/src/components/Group/GroupContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import * as React from 'react';
import { classNames } from '@vkontakte/vkjs';
import { useModalContext } from '../../context/ModalContext';
import { useAdaptivity } from '../../hooks/useAdaptivity';
import type { SizeTypeValues } from '../../lib/adaptivity';
import { warnOnce } from '../../lib/warnOnce';
import type { HasComponent, HTMLAttributesWithRootRef } from '../../types';
import { AppRootContext } from '../AppRoot/AppRootContext';
import { ModalRootContext } from '../ModalRoot/ModalRootContext';
import { RootComponent } from '../RootComponent/RootComponent';
import styles from './Group.module.css';

Expand Down Expand Up @@ -90,7 +90,7 @@ export const GroupContainer: React.FC<GroupContainerProps> = ({
tabIndex: tabIndexProp,
...restProps
}: GroupContainerProps) => {
const { isInsideModal } = React.useContext(ModalRootContext);
const isInsideModal = useModalContext().id !== null;
const { sizeX = 'none' } = useAdaptivity();

const mode = useGroupMode(modeProps, sizeX, isInsideModal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import {
import { Button } from '../Button/Button';
import { ButtonGroup } from '../ButtonGroup/ButtonGroup';
import { Image } from '../Image/Image';
import { ModalRoot } from '../ModalRoot/ModalRootAdaptive';
import { Spacing } from '../Spacing/Spacing';
import { Textarea } from '../Textarea/Textarea';
import { UsersStack } from '../UsersStack/UsersStack';
import { ModalCard, type ModalCardProps } from './ModalCard';
import { ModalCard } from './ModalCard';
import type { ModalCardProps } from './types';

const AppWrapper = ({ children, ...restProps }: AppDefaultWrapperProps) => (
<AppDefaultWrapper scroll="contain" disablePortal {...restProps}>
Expand Down Expand Up @@ -127,15 +127,14 @@ export const ModalCardPlayground = (props: ComponentPlaygroundProps) => {
AppWrapper={AppWrapper}
>
{(props: ModalCardProps) => (
<div style={{ height: 500, transform: 'translateZ(0)' }}>
<ModalRoot
activeModal={props.nav}
<div style={{ height: 500, overflow: 'hidden', transform: 'translateZ(0)' }}>
<ModalCard
open
// Note: с включенным фокусом ломаются скриншоты на движке Webkit из-за фокуса сразу
// на несколько окон
noFocusToDialog
>
<ModalCard {...props} />
</ModalRoot>
{...props}
/>
</div>
)}
</ComponentPlayground>
Expand Down
104 changes: 60 additions & 44 deletions packages/vkui/src/components/ModalCard/ModalCard.module.css
Original file line number Diff line number Diff line change
@@ -1,65 +1,81 @@
.host {
box-sizing: border-box;
position: absolute;
inset-block-start: 0;
padding: 8px;
inset-inline-start: 0;
padding: var(--vkui--spacing_size_m);
margin-inline: auto;
inline-size: 100%;
block-size: 100%;
display: flex;
align-items: flex-end;
box-sizing: border-box;
}

.host:focus {
outline: none;
}

.in {
inline-size: 100%;
margin-inline: auto;
transform: translateY(calc(100% + 16px));
transition: transform 340ms var(--vkui--animation_easing_platform);
.hostMaxWidthS {
max-inline-size: calc(400px + var(--vkui--spacing_size_2xl));
}

/**
* iOS
*/
.hostMaxWidthM {
max-inline-size: calc(414px + var(--vkui--spacing_size_2xl));
}

.ios .in {
max-inline-size: 414px;
.hostMaxWidthL {
max-inline-size: calc(440px + var(--vkui--spacing_size_2xl));
}

/**
* Android + vkcom
*/
/* Mobile */
@media (--viewWidth-smallTabletMinus) {
.host {
--vkui_internal_ModalCard--translateY: 100%;
--vkui_internal_ModalCard--safeAreaInsetBottom: var(--vkui_internal--safe_area_inset_bottom);

.android .in {
max-inline-size: 440px;
}
position: absolute;
inset-inline: 0;
inset-block-end: 0;
margin-block-end: var(--vkui_internal_ModalCard--safeAreaInsetBottom);
transform: translate3d(0, calc(100% - var(--vkui_internal_ModalCard--translateY)), 0);
transition: transform 0.4s var(--vkui_internal--spring-easing);
}

.vkcom .in {
max-inline-size: 400px;
}
.hostStateEnter {
transform: translate3d(0, 100%, 0);
transition: none;
}

/**
* Desktop
*/
.hostStateEntering {
transition: transform 0.5s var(--vkui_internal--slide-easing) 0.2s;
}

.desktop {
align-items: center;
}
.hostStateExiting {
transform: translate3d(0, 100%, 0);
transition: transform 0.2s ease;
}

.desktop .in {
transform: unset;
opacity: 0;
transition: opacity 340ms var(--vkui--animation_easing_platform);
.hostStateExited {
transform: translate3d(0, 100%, 0);
transition: none;
}
}
/* Desktop */
@media (--viewWidth-smallTabletPlus) {
.host {
margin-block: auto;
opacity: 1;
transition: opacity 340ms var(--vkui--animation_easing_platform);
}

.hostStateEnter {
opacity: 0;
transition-property: none;
}

.hostStateEntering {
opacity: 1;
}

.hostStateExiting {
opacity: 0;
}

/**
* CMP:
* ModalRoot
*/
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list */
:global(.vkuiInternalModalRoot--touched) .in {
transition: none;
.hostStateExited {
opacity: 0;
}
}
Loading

0 comments on commit 1234239

Please sign in to comment.