Skip to content

Commit

Permalink
fix(FocusTrap): add mutation observer (#7041) (#7069)
Browse files Browse the repository at this point in the history
h2. Описание
Сейчас в FocusTrap если children не меняется, то может и не происходить перерасчёт focusableNodesRef.current.
Например, такое происходит в модалке, если мы динамически добавляем/удаляем инпуты.

h2. Изменения
Добавил MutationObserver для отслеживания добавления/удаления элементов внутри компонента обернутого FocusTrap. При изменении содержимого происходит перерасчет focusableNodesRef. Таким образом поддерживается актуальное состояние списка нод.

Вручную потыкал компоненты где используется FocusTrap - все работает

---

Т.к. в master тесты писались уже с удалённой строчкой const { keyboardInput } = useContext(AppRootContext); (см. #6955?files=packages/vkui/src/components/FocusTrap/FocusTrap.tsx), а в 6.1-stable она ещё есть, обернул FocusTrap в AppRootContext.Provider.

Co-authored-by: EldarMuhamethanov <[email protected]>
  • Loading branch information
inomdzhon and EldarMuhamethanov authored Jun 25, 2024
1 parent 34cd6af commit 35ee7c8
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 25 deletions.
87 changes: 82 additions & 5 deletions packages/vkui/src/components/FocusTrap/FocusTrap.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as React from 'react';
import { act } from 'react';
import { act, Fragment, useRef, useState } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { ViewWidth } from '../../lib/adaptivity';
import {
Expand All @@ -12,6 +11,8 @@ import { ActionSheet, ActionSheetProps } from '../ActionSheet/ActionSheet';
import { ActionSheetItem } from '../ActionSheetItem/ActionSheetItem';
import { AdaptivityProvider } from '../AdaptivityProvider/AdaptivityProvider';
import { AppRoot } from '../AppRoot/AppRoot';
import { AppRootContext, DEFAULT_APP_ROOT_CONTEXT_VALUE } from '../AppRoot/AppRootContext';
import { Button } from '../Button/Button';
import { CellButton } from '../CellButton/CellButton';
import { Panel } from '../Panel/Panel';
import { SplitCol } from '../SplitCol/SplitCol';
Expand All @@ -30,8 +31,8 @@ const ActionSheetTest = ({
onClose: onCloseProp,
...props
}: Partial<ActionSheetProps> & Partial<FocusTrapProps>) => {
const toggleRef = React.useRef(null);
const [actionSheet, toggleActionSheet] = React.useState<any>(null);
const toggleRef = useRef(null);
const [actionSheet, toggleActionSheet] = useState<any>(null);

const handleClose = () => {
if (onCloseProp) {
Expand Down Expand Up @@ -93,7 +94,7 @@ describe(FocusTrap, () => {
it('renders with no focusable elements', async () => {
render(
<ActionSheetTest>
<React.Fragment>NOPE</React.Fragment>
<Fragment>NOPE</Fragment>
</ActionSheetTest>,
);
await mountActionSheetViaClick();
Expand Down Expand Up @@ -170,5 +171,81 @@ describe(FocusTrap, () => {
await userEvent.tab();
expect(screen.getByTestId('first')).toHaveFocus();
});

it('manages navigation inside trap on TAB with remove last child when navigate', async () => {
const Template = (props: { childIds: string[] }) => {
return (
<AppRootContext.Provider
value={{ ...DEFAULT_APP_ROOT_CONTEXT_VALUE, keyboardInput: true }}
>
<FocusTrap>
<div>
{props.childIds.map((childId) => (
<Button key={childId} data-testid={childId}>
Кнопка {childId}
</Button>
))}
</div>
</FocusTrap>
</AppRootContext.Provider>
);
};

const result = render(<Template childIds={['first', 'middle', 'last']} />);

// forward to middle
await userEvent.tab();
expect(result.getByTestId('middle')).toHaveFocus();

// remove last
await act(async () => {
result.rerender(<Template childIds={['first', 'middle']} />);
});

// check focus in middle yet
expect(result.getByTestId('middle')).toHaveFocus();

// forward to first
await userEvent.tab();
expect(result.getByTestId('first')).toHaveFocus();
});

it('manages navigation inside trap on TAB with remove middle child when focus on middle', async () => {
const Template = (props: { childIds: string[] }) => {
return (
<AppRootContext.Provider
value={{ ...DEFAULT_APP_ROOT_CONTEXT_VALUE, keyboardInput: true }}
>
<FocusTrap>
<div>
{props.childIds.map((childId) => (
<Button key={childId} data-testid={childId}>
Кнопка {childId}
</Button>
))}
</div>
</FocusTrap>
</AppRootContext.Provider>
);
};

const result = render(<Template childIds={['first', 'middle', 'last']} />);

// forward to middle
await userEvent.tab();
expect(result.getByTestId('middle')).toHaveFocus();

// remove middle
await act(async () => {
result.rerender(<Template childIds={['first', 'last']} />);
});

// reset focus to first
expect(result.getByTestId('first')).toHaveFocus();

// forward to last
await userEvent.tab();
expect(result.getByTestId('last')).toHaveFocus();
});
});
});
74 changes: 54 additions & 20 deletions packages/vkui/src/components/FocusTrap/FocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import * as React from 'react';
import { AllHTMLAttributes, useContext, useRef } from 'react';
import { useExternRef } from '../../hooks/useExternRef';
import { FOCUSABLE_ELEMENTS_LIST, Keys, pressedKey } from '../../lib/accessibility';
import {
contains,
getActiveElementByAnotherElement,
getWindow,
isHTMLElement,
useDOM,
} from '../../lib/dom';
import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect';
import { HasComponent, HasRootRef } from '../../types';
import { AppRootContext } from '../AppRoot/AppRootContext';

const FOCUSABLE_ELEMENTS: string = FOCUSABLE_ELEMENTS_LIST.join();
export interface FocusTrapProps<T extends HTMLElement = HTMLElement>
extends React.AllHTMLAttributes<T>,
extends AllHTMLAttributes<T>,
HasRootRef<T>,
HasComponent {
autoFocus?: boolean;
Expand All @@ -36,33 +37,66 @@ export const FocusTrap = <T extends HTMLElement = HTMLElement>({
...restProps
}: FocusTrapProps<T>) => {
const ref = useExternRef<T>(getRootRef);
const { document } = useDOM();

const { keyboardInput } = React.useContext(AppRootContext);
const focusableNodesRef = React.useRef<HTMLElement[]>([]);
const { keyboardInput } = useContext(AppRootContext);
const focusableNodesRef = useRef<HTMLElement[]>([]);

const focusNodeByIndex = (nodeIndex: number) => {
const element = focusableNodesRef.current[nodeIndex];

if (element) {
element.focus();
}
};

const recalculateFocusableNodesRef = (parentNode: HTMLElement) => {
// eslint-disable-next-line no-restricted-properties
const newFocusableElements = parentNode.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);

const nodes: HTMLElement[] = [];
newFocusableElements.forEach((focusableEl) => {
const { display, visibility } = getComputedStyle(focusableEl);
if (display !== 'none' && visibility !== 'hidden') {
nodes.push(focusableEl);
}
});

if (nodes.length === 0) {
// Чтобы фокус был хотя бы на родителе
nodes.push(parentNode);
}
focusableNodesRef.current = nodes;
};

const onMutateParentHandler = (parentNode: HTMLElement) => {
recalculateFocusableNodesRef(parentNode);

if (document) {
const activeElement = document.activeElement as HTMLElement;
const currentElementIndex = Math.max(
document.activeElement ? focusableNodesRef.current.indexOf(activeElement) : -1,
0,
);
focusNodeByIndex(currentElementIndex);
}
};

useIsomorphicLayoutEffect(
function collectFocusableNodesRef() {
if (!ref.current) {
return;
}

const nodes: HTMLElement[] = [];
// eslint-disable-next-line no-restricted-properties
ref.current.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS).forEach((focusableEl) => {
const { display, visibility } = getComputedStyle(focusableEl);
if (display !== 'none' && visibility !== 'hidden') {
nodes.push(focusableEl);
}
const parentNode = ref.current;
const observer = new MutationObserver(() => onMutateParentHandler(parentNode));
observer.observe(ref.current, {
subtree: true,
childList: true,
});

if (nodes.length === 0) {
// Чтобы фокус был хотя бы на родителе
nodes.push(ref.current);
}

focusableNodesRef.current = nodes;
recalculateFocusableNodesRef(parentNode);
return () => observer.disconnect();
},
[children],
[ref],
);

useIsomorphicLayoutEffect(
Expand Down

0 comments on commit 35ee7c8

Please sign in to comment.