Skip to content

Commit

Permalink
feat(Modal): focus to modal container if controllable element doesn't…
Browse files Browse the repository at this point in the history
… focused

h2. Описание

При открытии, фокусируемся на контейнер `ModalPage`/`ModalCard` если
внутри модалки нет элемента, на котором уже есть фокус (проверяем через
`document.activeElement`).

h2. Изменения

- Для контейнера `ModalPage`/`ModalCard` добавлены `tabIndex={-1}`,
  чтобы была возможность фокусироваться на него, но при этом по Tab'у на
  него пользователь больше не попадал.
- Добавил параметр `noFocusToDialog`, чтобы была возможность отключить
  фичу (понадобилось в e2e тестах `ModalCard`, см. коммент в тестах).
- Добавил отключение `outline` на фокус в CSS для `ModalPage`
  и `ModalCard`.
- Для `ModalCard` добавил недостающие для a11y атрибуты.
  • Loading branch information
inomdzhon committed Oct 26, 2023
1 parent d1f16fc commit 8beff6f
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,12 @@ export const ModalCardPlayground = (props: ComponentPlaygroundProps) => {
<ComponentPlayground {...props} propSets={propSets} AppWrapper={AppWrapper}>
{(props: ModalCardProps) => (
<div style={{ height: 500, transform: 'translateZ(0)' }}>
<ModalRoot activeModal={props.nav}>
<ModalRoot
activeModal={props.nav}
// Note: с включенным фокусом ломаются скриншоты на движке Webkit из-за фокуса сразу
// на несколько окон
noFocusToDialog
>
<ModalCard {...props} />
</ModalRoot>
</div>
Expand Down
4 changes: 4 additions & 0 deletions packages/vkui/src/components/ModalCard/ModalCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
align-items: flex-end;
}

.ModalCard:focus {
outline: none;
}

.ModalCard__in {
width: 100%;
margin-left: auto;
Expand Down
10 changes: 10 additions & 0 deletions packages/vkui/src/components/ModalCard/ModalCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { classNames } from '@vkontakte/vkjs';
import { useAdaptivityWithJSMediaQueries } from '../../hooks/useAdaptivityWithJSMediaQueries';
import { useExternRef } from '../../hooks/useExternRef';
import { usePlatform } from '../../hooks/usePlatform';
import { getNavId, NavIdProps } from '../../lib/getNavId';
import { warnOnce } from '../../lib/warnOnce';
Expand Down Expand Up @@ -33,17 +34,26 @@ export const ModalCard = ({
nav,
id,
size,
getRootRef,
...restProps
}: ModalCardProps) => {
const { isDesktop } = useAdaptivityWithJSMediaQueries();
const platform = usePlatform();

const modalContext = React.useContext(ModalRootContext);
const { refs } = useModalRegistry(getNavId({ nav, id }, warn), ModalType.CARD);
const rootRef = useExternRef(getRootRef, refs.modalElement);

const contextValue = React.useMemo(() => ({ labelId: `${id}-label` }), [id]);

return (
<RootComponent
{...restProps}
getRootRef={rootRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby={contextValue.labelId}
id={id}
baseClassName={classNames(
styles['ModalCard'],
Expand Down
4 changes: 4 additions & 0 deletions packages/vkui/src/components/ModalPage/ModalPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
pointer-events: none;
}

.ModalPage:focus {
outline: none;
}

.ModalPage--desktop {
display: flex;
justify-content: center;
Expand Down
5 changes: 5 additions & 0 deletions packages/vkui/src/components/ModalPage/ModalPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { classNames } from '@vkontakte/vkjs';
import { useAdaptivityWithJSMediaQueries } from '../../hooks/useAdaptivityWithJSMediaQueries';
import { useExternRef } from '../../hooks/useExternRef';
import { useId } from '../../hooks/useId';
import { useOrientationChange } from '../../hooks/useOrientationChange';
import { usePlatform } from '../../hooks/usePlatform';
Expand Down Expand Up @@ -91,6 +92,7 @@ export const ModalPage = ({
hideCloseButton = false,
height,
modalContentTestId,
getRootRef,
...restProps
}: ModalPageProps) => {
const generatingId = useId();
Expand All @@ -113,13 +115,16 @@ export const ModalPage = ({

const modalContext = React.useContext(ModalRootContext);
const { refs } = useModalRegistry(getNavId({ nav, id }, warn), ModalType.PAGE);
const rootRef = useExternRef(getRootRef, refs.modalElement);

const contextValue = React.useMemo(() => ({ labelId: `${id}-label` }), [id]);

return (
<ModalPageContext.Provider value={contextValue}>
<RootComponent
{...restProps}
getRootRef={rootRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby={contextValue.labelId}
Expand Down
71 changes: 71 additions & 0 deletions packages/vkui/src/components/ModalRoot/ModalRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,75 @@ describe.each([
);
expect(screen.queryByTestId('modal-mask')).toBeTruthy();
});

describe('focus', () => {
let modalPageRef: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>();
let modalCardRef: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>();
let modalPageWithInputRef: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>();
let inputInnerModalPageRef: React.RefObject<HTMLInputElement> =
React.createRef<HTMLInputElement>();
let modals: React.ReactElement[] = [];

beforeEach(() => {
modalPageRef = React.createRef<HTMLDivElement>();
modalCardRef = React.createRef<HTMLDivElement>();
modalPageWithInputRef = React.createRef<HTMLDivElement>();
inputInnerModalPageRef = React.createRef<HTMLInputElement>();
modals = [
<ModalPage id="modal-page" key="1" getRootRef={modalPageRef} />,
<ModalCard id="modal-card" key="2" getRootRef={modalCardRef} />,
<ModalPage id="modal-page-with-input" key="3" getRootRef={modalPageWithInputRef}>
<input ref={inputInnerModalPageRef} autoFocus />
</ModalPage>,
];
});

it('should focus on modal container if controllable element does not exist or is not focused', () => {
const component = render(<ModalRoot activeModal="modal-page">{modals}</ModalRoot>, {
baseElement: document.documentElement,
});
runAllTimers();

expect(modalPageRef.current).toHaveFocus();

component.rerender(<ModalRoot activeModal="modal-card">{modals}</ModalRoot>);
runAllTimers();

expect(modalCardRef.current).toHaveFocus();
});

it('should focus on controllable element instead modal if it is focused', () => {
render(<ModalRoot activeModal="modal-page-with-input">{modals}</ModalRoot>, {
baseElement: document.documentElement,
});
runAllTimers();

expect(modalPageWithInputRef.current).not.toHaveFocus();
expect(inputInnerModalPageRef.current).toHaveFocus();
});

it('should focus on controllable element only (if it is focused)', () => {
const component = render(
<ModalRoot activeModal="modal-page" noFocusToDialog>
{modals}
</ModalRoot>,
{
baseElement: document.documentElement,
},
);
runAllTimers();

expect(modalPageRef.current).not.toHaveFocus();

component.rerender(
<ModalRoot activeModal="modal-page-with-input" noFocusToDialog>
{modals}
</ModalRoot>,
);
runAllTimers();

expect(modalPageWithInputRef.current).not.toHaveFocus();
expect(inputInnerModalPageRef.current).toHaveFocus();
});
});
});
29 changes: 18 additions & 11 deletions packages/vkui/src/components/ModalRoot/ModalRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,15 @@ class ModalRootTouchComponent extends React.Component<

// transition phase 3: animate entering modal
if (this.props.enteringModal && this.props.enteringModal !== prevProps.enteringModal) {
const { enteringModal } = this.props;
const enteringState = this.props.getModalState(enteringModal);
const enteringState = this.props.getModalState(this.props.enteringModal);
this.props.onEnter();
this.waitTransitionFinish(enteringState, () => {
if (enteringState?.innerElement) {
enteringState.innerElement.style.transitionDelay = '';
if (enteringState) {
if (enteringState.innerElement) {
enteringState.innerElement.style.transitionDelay = '';
}
this.onEntered(enteringState);
}
this.props.onEntered(enteringModal);
});

if (enteringState?.innerElement) {
Expand Down Expand Up @@ -219,6 +220,18 @@ class ModalRootTouchComponent extends React.Component<
}
};

onEntered({ id, modalElement }: ModalsStateEntry) {
if (
!this.props.noFocusToDialog &&
modalElement &&
!modalElement.contains(this.document.activeElement)
) {
modalElement.focus();
}

this.props.onEntered(id);
}

closeModal(id: string) {
// Сбрасываем состояния, которые могут помешать закрытию модального окна
this.setState({ touchDown: false });
Expand Down Expand Up @@ -599,12 +612,6 @@ class ModalRootTouchComponent extends React.Component<
return (
<FocusTrap
key={key}
getRootRef={(e) => {
const modalState = this.props.getModalState(modalId);
if (modalState) {
modalState.modalElement = e;
}
}}
onClose={this.props.onExit}
timeout={this.timeout}
className={classNames(
Expand Down
27 changes: 16 additions & 11 deletions packages/vkui/src/components/ModalRoot/ModalRootDesktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const warn = warnOnce('ModalRoot');
export const ModalRootDesktop = ({
activeModal: activeModalProp,
children,
noFocusToDialog = false,
onOpen,
onOpened,
onClose,
Expand All @@ -39,7 +40,7 @@ export const ModalRootDesktop = ({
getModalState,
enteringModal,
onEnter,
onEntered,
onEntered: onEnteredProp,
onExited,
history,
delayEnter,
Expand Down Expand Up @@ -92,6 +93,14 @@ export const ModalRootDesktop = ({
});
};

const onEntered = ({ id, modalElement }: ModalsStateEntry) => {
if (!noFocusToDialog && modalElement && !modalElement.contains(document!.activeElement)) {
modalElement.focus();
}

onEnteredProp(id);
};

const openModal = () => {
if (!enteringModal || !prevProps) {
return;
Expand All @@ -103,16 +112,10 @@ export const ModalRootDesktop = ({
// Анимация открытия модального окна
if (!prevProps.exitingModal) {
requestAnimationFrame(() => {
if (enteringModal === enteringModal) {
waitTransitionFinish(
enteringState?.innerElement,
() => onEntered(enteringModal),
timeout,
);
if (enteringModal === enteringModal && enteringState) {
waitTransitionFinish(enteringState.innerElement, () => onEntered(enteringState), timeout);
animateModalOpacity(enteringState, true);
if (enteringState) {
setMaskOpacity(enteringState, 1);
}
setMaskOpacity(enteringState, 1);
}
});

Expand All @@ -128,7 +131,9 @@ export const ModalRootDesktop = ({
}
});

onEntered(enteringModal);
if (enteringState) {
onEntered(enteringState);
}
};

const closeModal = (id: string) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/vkui/src/components/ModalRoot/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ export interface ModalRootProps {
* `data-testid` для маски
*/
modalOverlayTestId?: string;

/**
* Отключает фокус на контейнер диалогового окна при открытии.
*/
noFocusToDialog?: boolean;
}

export interface ModalRootWithDOMProps extends HasPlatform, ModalRootProps, DOMContextInterface {
Expand Down

0 comments on commit 8beff6f

Please sign in to comment.