Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ModalPage, ModalCard): add prop disableFocusTrap to fix conflict with several FocusTraps #8248

Merged
41 changes: 39 additions & 2 deletions packages/vkui/src/components/ModalCard/ModalCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { act } from 'react';
import { act, useState } from 'react';
import * as React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { baselineComponent, waitCSSTransitionEnd } from '../../testing/utils';
import { ViewWidth } from '../../lib/adaptivity';
import { baselineComponent, userEvent, waitCSSTransitionEnd } from '../../testing/utils';
import { AdaptivityProvider } from '../AdaptivityProvider/AdaptivityProvider';
import { Button } from '../Button/Button';
import { ConfigProvider } from '../ConfigProvider/ConfigProvider';
import { ModalPageHeader } from '../ModalPageHeader/ModalPageHeader';
Expand Down Expand Up @@ -132,6 +134,41 @@ describe(ModalCard, () => {
expect(onClose).toHaveBeenCalledWith('click-close-button');
});

test('check disable focus trap', async () => {
const Fixture = () => {
const [open, setOpen] = useState(false);
return (
<>
<AdaptivityProvider viewWidth={ViewWidth.SMALL_TABLET} hasPointer>
<ModalCard
key="host"
id="host"
open={open}
modalDismissButtonTestId="dismiss-button"
data-testid="host"
focusTrapDisabled
/>
</AdaptivityProvider>
<Button data-testid="open-button" onClick={() => setOpen(true)} />
</>
);
};

const h = render(<Fixture />);

const openButton = h.getByTestId('open-button');
fireEvent.click(openButton);

await waitModalCardCSSTransitionEnd(h.getByTestId('host'));

const dismissButton = h.getByTestId('dismiss-button');
dismissButton.focus();
expect(dismissButton).toHaveFocus();

await userEvent.tab();
expect(openButton).toHaveFocus();
});

describe('check restoreFocus prop', () => {
const Fixture: React.FC<Pick<ModalCardProps, 'restoreFocus'>> = ({ restoreFocus = true }) => {
const [open, setOpen] = React.useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const ModalCardInternal = ({
onOpened,
onClose = noop,
onClosed,
focusTrapDisabled,
...restProps
}: ModalCardInternalProps): ReactNode => {
const platform = usePlatform();
Expand Down Expand Up @@ -139,7 +140,7 @@ export const ModalCardInternal = ({
useScrollLock(!hidden);
useFocusTrap(ref, {
autoFocus: !noFocusToDialog,
disabled: !opened || hidden,
disabled: !opened || hidden || focusTrapDisabled,
restoreFocus,
});

Expand Down
6 changes: 6 additions & 0 deletions packages/vkui/src/components/ModalCard/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ const Example = () => {
При `open={true}` навигация через `Tab` и `Shift` + `Tab` будет зациклена на содержимом `ModalCard` (допустимо, т.к. к устройству
может быть подключена клавиатура).

> ⚠️ При открытии модального окна поверх другого модального окна может возникнуть конфликт в управлении фокусом.
> По умолчанию каждое модальное окно пытается удерживать фокус внутри себя, что может привести к некорректной работе при наличии нескольких открытых модальных окон.
>
> Чтобы избежать этой проблемы, для первого (нижнего) модального окна необходимо отключить захват фокуса с помощью свойства focusTrapDisabled.
> Это позволит корректно управлять фокусом во втором (верхнем) модальном окне.

По умолчанию, пользователь может закрыть **bottom card** с помощью:

- нажатия на `ModalOverlay`, что вызовет событие `onClose` с аргументом `click-overlay`;
Expand Down
6 changes: 6 additions & 0 deletions packages/vkui/src/components/ModalCard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,10 @@ export interface ModalCardProps
* Будет вызвано при окончательном закрытии модалки.
*/
onClosed?: VoidFunction;
/**
* Позволяет отключить захват фокуса.
*
* Нужно использовать, когда поверх одной модалки открывается другая, чтобы два `FocusTrap` не конфликтовали
*/
focusTrapDisabled?: UseFocusTrapProps['disabled'];
}
40 changes: 38 additions & 2 deletions packages/vkui/src/components/ModalPage/ModalPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { act } from 'react';
import { act, useState } from 'react';
import * as React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { ViewWidth } from '../../lib/adaptivity';
import { baselineComponent, waitCSSTransitionEnd } from '../../testing/utils';
import { baselineComponent, userEvent, waitCSSTransitionEnd } from '../../testing/utils';
import { AdaptivityProvider } from '../AdaptivityProvider/AdaptivityProvider';
import { Button } from '../Button/Button';
import { ConfigProvider } from '../ConfigProvider/ConfigProvider';
Expand Down Expand Up @@ -139,6 +139,42 @@ describe(ModalPage, () => {
expect(onClose).toHaveBeenCalledWith('click-close-button', expect.any(Object));
});

test('check disable focus trap', async () => {
const Fixture = () => {
const [open, setOpen] = useState(false);
return (
<>
<AdaptivityProvider viewWidth={ViewWidth.SMALL_TABLET} hasPointer>
<ModalPage
key="host"
id="host"
open={open}
modalDismissButtonTestId="dismiss-button"
focusTrapDisabled
noFocusToDialog
data-testid="host"
/>
</AdaptivityProvider>
<Button data-testid="open-button" onClick={() => setOpen(true)} />
</>
);
};

const h = render(<Fixture />);

const openButton = h.getByTestId('open-button');
fireEvent.click(openButton);

await waitModalPageCSSTransitionEnd(h.getByTestId('host'));

const dismissButton = h.getByTestId('dismiss-button');
dismissButton.focus();
expect(dismissButton).toHaveFocus();

await userEvent.tab();
expect(openButton).toHaveFocus();
});

describe('check restoreFocus prop', () => {
const Fixture: React.FC<Pick<ModalCardProps, 'restoreFocus'>> = ({ restoreFocus = true }) => {
const [open, setOpen] = React.useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const ModalPageInternal = ({
onOpened,
onClose = noop,
onClosed,
focusTrapDisabled,
...restProps
}: ModalPageInternalProps) => {
const { hasCustomPanelHeaderAfter } = useConfigProvider();
Expand Down Expand Up @@ -180,7 +181,7 @@ export const ModalPageInternal = ({
restoreFocus={restoreFocus}
role="dialog"
aria-modal="true"
disabled={!opened || hidden}
disabled={!opened || hidden || focusTrapDisabled}
className={classNames(
className,
styles.host,
Expand Down
6 changes: 6 additions & 0 deletions packages/vkui/src/components/ModalPage/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ const Example = () => {
- параметр `size` будет ограничивать максимальную ширину окна;
- навигация через `Tab` и `Shift` + `Tab` будет зациклена на содержимом `ModalPage`.

> ⚠️ При открытии модального окна поверх другого модального окна может возникнуть конфликт в управлении фокусом.
> По умолчанию каждое модальное окно пытается удерживать фокус внутри себя, что может привести к некорректной работе при наличии нескольких открытых модальных окон.
>
> Чтобы избежать этой проблемы, для первого (нижнего) модального окна необходимо отключить захват фокуса с помощью свойства focusTrapDisabled.
> Это позволит корректно управлять фокусом во втором (верхнем) модальном окне.

По умолчанию, пользователь может закрыть **диалоговое окно** с помощью:

- нажатия на кнопку закрытия, что вызовет событие `onClose` с аргументом `click-close-button`;
Expand Down
6 changes: 6 additions & 0 deletions packages/vkui/src/components/ModalPage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,10 @@ export interface ModalPageProps
*
*/
outsideButtons?: React.ReactNode;
/**
* Позволяет отключить захват фокуса.
*
* Нужно использовать, когда поверх одной модалки открывается другая, чтобы два `FocusTrap` не конфликтовали
*/
focusTrapDisabled?: UseFocusTrapProps['disabled'];
}
2 changes: 1 addition & 1 deletion packages/vkui/src/hooks/useFocusTrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export const useFocusTrap = (

recalculateFocusableNodesRef(parentNode);

if (!autoFocus || arraysEquals(oldFocusableNodes, focusableNodesRef.current)) {
if (disabled || !autoFocus || arraysEquals(oldFocusableNodes, focusableNodesRef.current)) {
return;
}

Expand Down