import { FocusZoneDirection } from '@fluentui/accessibility';
import { FocusTrapZone, FocusZone, FocusTrapZoneProps } from '@fluentui/react-bindings';
import * as React from 'react';
import * as ReactTestUtils from 'react-dom/test-utils';
import { keyboardKey } from '@fluentui/keyboard-key';
// rAF does not exist in node - let's mock it
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
const r = window.setTimeout(callback, 0);
jest.runAllTimers();
return r;
};
jest.useFakeTimers();
class FocusTrapZoneTestComponent extends React.Component<{}, { isShowingFirst: boolean; isShowingSecond: boolean }> {
constructor(props: {}) {
super(props);
this.state = { isShowingFirst: true, isShowingSecond: false };
}
render() {
return (
{this.state.isShowingFirst && (
First
)}
{this.state.isShowingSecond && (
First
)}
);
}
_toggleFirst = () => this.setState({ isShowingFirst: !this.state.isShowingFirst });
_toggleSecond = () => this.setState({ isShowingSecond: !this.state.isShowingSecond });
}
describe('FocusTrapZone', () => {
// document.activeElement can be used to detect activeElement after component mount, but it does not
// update based on focus events due to limitations of ReactDOM. Use lastFocusedElement to detect focus
// change events.
let lastFocusedElement: HTMLElement | undefined;
const ftzClassname = 'ftzTestClassname';
const _onFocus = (ev: any): void => (lastFocusedElement = ev.target);
const setupElement = (
element: HTMLElement,
{
clientRect,
isVisible = true,
}: {
clientRect: {
top: number;
left: number;
bottom: number;
right: number;
};
isVisible?: boolean;
},
): void => {
// @ts-ignore
element.getBoundingClientRect = () => ({
top: clientRect.top,
left: clientRect.left,
bottom: clientRect.bottom,
right: clientRect.right,
width: clientRect.right - clientRect.left,
height: clientRect.bottom - clientRect.top,
});
element.setAttribute('data-is-visible', String(isVisible));
element.focus = () => ReactTestUtils.Simulate.focus(element);
};
/**
* Helper to get FocusTrapZone bumpers. Requires classname attribute of
* 'ftzClassname' on FTZ.
*/
function getFtzBumpers(
element: HTMLElement,
): {
firstBumper: Element;
lastBumper: Element;
} {
const ftz = element.querySelector(`.${ftzClassname}`) as HTMLElement;
const ftzNodes = ftz.children;
const firstBumper = ftzNodes[0];
const lastBumper = ftzNodes[ftzNodes.length - 1];
return { firstBumper, lastBumper };
}
beforeEach(() => {
lastFocusedElement = undefined;
});
describe('Tab and shift-tab wrap at extreme ends of the FTZ', () => {
it('can tab across FocusZones with different button structures', async () => {
expect.assertions(3);
const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
,
) as HTMLElement;
const buttonA = topLevelDiv.querySelector('.a') as HTMLElement;
const buttonB = topLevelDiv.querySelector('.b') as HTMLElement;
const buttonC = topLevelDiv.querySelector('.c') as HTMLElement;
const buttonD = topLevelDiv.querySelector('.d') as HTMLElement;
const buttonE = topLevelDiv.querySelector('.e') as HTMLElement;
const buttonF = topLevelDiv.querySelector('.f') as HTMLElement;
// Assign bounding locations to buttons.
setupElement(buttonA, { clientRect: { top: 0, bottom: 30, left: 0, right: 30 } });
setupElement(buttonB, { clientRect: { top: 0, bottom: 30, left: 30, right: 60 } });
setupElement(buttonC, { clientRect: { top: 0, bottom: 30, left: 60, right: 90 } });
setupElement(buttonD, { clientRect: { top: 30, bottom: 60, left: 0, right: 30 } });
setupElement(buttonE, { clientRect: { top: 30, bottom: 60, left: 30, right: 60 } });
setupElement(buttonF, { clientRect: { top: 30, bottom: 60, left: 60, right: 90 } });
const { firstBumper, lastBumper } = getFtzBumpers(topLevelDiv);
ReactTestUtils.Simulate.focus(buttonA);
expect(lastFocusedElement).toBe(buttonA);
// Simulate shift+tab event which would focus first bumper
ReactTestUtils.Simulate.focus(firstBumper);
expect(lastFocusedElement).toBe(buttonD);
// Simulate tab event which would focus last bumper
ReactTestUtils.Simulate.focus(lastBumper);
expect(lastFocusedElement).toBe(buttonA);
});
it('can tab across a FocusZone with different button structures', async () => {
expect.assertions(3);
const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
,
) as HTMLElement;
const buttonX = topLevelDiv.querySelector('.x') as HTMLElement;
const buttonA = topLevelDiv.querySelector('.a') as HTMLElement;
const buttonB = topLevelDiv.querySelector('.b') as HTMLElement;
const buttonC = topLevelDiv.querySelector('.c') as HTMLElement;
const buttonD = topLevelDiv.querySelector('.d') as HTMLElement;
const { firstBumper, lastBumper } = getFtzBumpers(topLevelDiv);
// Assign bounding locations to buttons.
setupElement(buttonX, { clientRect: { top: 0, bottom: 30, left: 0, right: 30 } });
setupElement(buttonA, { clientRect: { top: 0, bottom: 30, left: 0, right: 30 } });
setupElement(buttonB, { clientRect: { top: 0, bottom: 30, left: 30, right: 60 } });
setupElement(buttonC, { clientRect: { top: 0, bottom: 30, left: 60, right: 90 } });
setupElement(buttonD, { clientRect: { top: 30, bottom: 60, left: 0, right: 30 } });
ReactTestUtils.Simulate.focus(buttonX);
expect(lastFocusedElement).toBe(buttonX);
// Simulate shift+tab event which would focus first bumper
ReactTestUtils.Simulate.focus(firstBumper);
expect(lastFocusedElement).toBe(buttonA);
// Simulate tab event which would focus last bumper
ReactTestUtils.Simulate.focus(lastBumper);
expect(lastFocusedElement).toBe(buttonX);
});
it('can trap focus when FTZ bookmark elements are FocusZones, and those elements have inner elements focused that are not the first inner element', async () => {
expect.assertions(4);
const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
,
) as HTMLElement;
const buttonZ1 = topLevelDiv.querySelector('.z1') as HTMLElement;
const buttonA = topLevelDiv.querySelector('.a') as HTMLElement;
const buttonB = topLevelDiv.querySelector('.b') as HTMLElement;
const buttonC = topLevelDiv.querySelector('.c') as HTMLElement;
const buttonD = topLevelDiv.querySelector('.d') as HTMLElement;
const buttonE = topLevelDiv.querySelector('.e') as HTMLElement;
const buttonF = topLevelDiv.querySelector('.f') as HTMLElement;
const buttonG = topLevelDiv.querySelector('.g') as HTMLElement;
const buttonZ2 = topLevelDiv.querySelector('.z2') as HTMLElement;
const { firstBumper, lastBumper } = getFtzBumpers(topLevelDiv);
// Assign bounding locations to buttons.
setupElement(buttonZ1, { clientRect: { top: 0, bottom: 10, left: 0, right: 10 } });
setupElement(buttonA, { clientRect: { top: 10, bottom: 30, left: 0, right: 10 } });
setupElement(buttonB, { clientRect: { top: 10, bottom: 30, left: 10, right: 20 } });
setupElement(buttonC, { clientRect: { top: 10, bottom: 30, left: 20, right: 30 } });
setupElement(buttonD, { clientRect: { top: 30, bottom: 40, left: 0, right: 10 } });
setupElement(buttonE, { clientRect: { top: 40, bottom: 60, left: 0, right: 10 } });
setupElement(buttonF, { clientRect: { top: 40, bottom: 60, left: 10, right: 20 } });
setupElement(buttonG, { clientRect: { top: 40, bottom: 60, left: 20, right: 30 } });
setupElement(buttonZ2, { clientRect: { top: 60, bottom: 70, left: 0, right: 10 } });
// Focus the middle button in the first FZ.
ReactTestUtils.Simulate.focus(buttonA);
ReactTestUtils.Simulate.keyDown(buttonA, { which: keyboardKey.ArrowRight });
expect(lastFocusedElement).toBe(buttonB);
// Focus the middle button in the second FZ.
ReactTestUtils.Simulate.focus(buttonE);
ReactTestUtils.Simulate.keyDown(buttonE, { which: keyboardKey.ArrowRight });
expect(lastFocusedElement).toBe(buttonF);
// Simulate tab event which would focus last bumper
ReactTestUtils.Simulate.focus(lastBumper);
expect(lastFocusedElement).toBe(buttonB);
// Simulate shift+tab event which would focus first bumper
ReactTestUtils.Simulate.focus(firstBumper);
expect(lastFocusedElement).toBe(buttonF);
});
});
describe('Tab and shift-tab do nothing (keep focus where it is) when the FTZ contains 0 tabbable items', () => {
function setupTest(props: FocusTrapZoneProps) {
const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
,
) as HTMLElement;
const buttonZ1 = topLevelDiv.querySelector('.z1') as HTMLElement;
const buttonA = topLevelDiv.querySelector('.a') as HTMLElement;
const buttonB = topLevelDiv.querySelector('.b') as HTMLElement;
const buttonC = topLevelDiv.querySelector('.c') as HTMLElement;
const buttonZ2 = topLevelDiv.querySelector('.z2') as HTMLElement;
const { firstBumper, lastBumper } = getFtzBumpers(topLevelDiv);
// Have to set bumpers as "visible" for focus utilities to find them.
// This is needed for 0 tabbable element tests to make sure that next tabbable element
// from one bumper is the other bumper.
firstBumper.setAttribute('data-is-visible', String(true));
lastBumper.setAttribute('data-is-visible', String(true));
// Assign bounding locations to buttons.
setupElement(buttonZ1, { clientRect: { top: 0, bottom: 10, left: 0, right: 10 } });
setupElement(buttonA, { clientRect: { top: 10, bottom: 20, left: 0, right: 10 } });
setupElement(buttonB, { clientRect: { top: 20, bottom: 30, left: 0, right: 10 } });
setupElement(buttonC, { clientRect: { top: 30, bottom: 40, left: 0, right: 10 } });
setupElement(buttonZ2, { clientRect: { top: 40, bottom: 50, left: 0, right: 10 } });
return { buttonZ1, buttonA, buttonB, buttonC, buttonZ2, firstBumper, lastBumper };
}
it('focuses first focusable element when focusing first bumper', async () => {
expect.assertions(2);
const { buttonB, buttonA, firstBumper } = setupTest({});
ReactTestUtils.Simulate.focus(buttonB);
expect(lastFocusedElement).toBe(buttonB);
// Simulate shift+tab event which would focus first bumper
ReactTestUtils.Simulate.focus(firstBumper);
expect(lastFocusedElement).toBe(buttonA);
});
it('focuses first focusable element when focusing last bumper', async () => {
expect.assertions(2);
const { buttonA, buttonB, lastBumper } = setupTest({});
ReactTestUtils.Simulate.focus(buttonB);
expect(lastFocusedElement).toBe(buttonB);
// Simulate tab event which would focus last bumper
ReactTestUtils.Simulate.focus(lastBumper);
expect(lastFocusedElement).toBe(buttonA);
});
});
describe('Focus behavior based on default and explicit prop values', () => {
function setupTest(props: FocusTrapZoneProps) {
// data-is-visible is embedded in buttons here for testing focus behavior on initial render.
// Components have to be marked visible before setupElement has a chance to apply the data-is-visible attribute.
const topLevelDiv = (ReactTestUtils.renderIntoDocument(
,
) as unknown) as HTMLElement;
const buttonZ1 = topLevelDiv.querySelector('.z1') as HTMLElement;
const buttonA = topLevelDiv.querySelector('.a') as HTMLElement;
const buttonB = topLevelDiv.querySelector('.b') as HTMLElement;
const buttonC = topLevelDiv.querySelector('.c') as HTMLElement;
const buttonZ2 = topLevelDiv.querySelector('.z2') as HTMLElement;
const { firstBumper, lastBumper } = getFtzBumpers(topLevelDiv);
// Assign bounding locations to buttons.
setupElement(buttonZ1, { clientRect: { top: 0, bottom: 10, left: 0, right: 10 } });
setupElement(buttonA, { clientRect: { top: 10, bottom: 20, left: 0, right: 10 } });
setupElement(buttonB, { clientRect: { top: 20, bottom: 30, left: 0, right: 10 } });
setupElement(buttonC, { clientRect: { top: 30, bottom: 40, left: 0, right: 10 } });
setupElement(buttonZ2, { clientRect: { top: 40, bottom: 50, left: 0, right: 10 } });
return { buttonZ1, buttonA, buttonB, buttonC, buttonZ2, firstBumper, lastBumper };
}
it('Focuses first element when FTZ does not have focus and first bumper receives focus', async () => {
expect.assertions(2);
const { buttonA, buttonZ1, firstBumper } = setupTest({ isClickableOutsideFocusTrap: true });
ReactTestUtils.Simulate.focus(buttonZ1);
expect(lastFocusedElement).toBe(buttonZ1);
ReactTestUtils.Simulate.focus(firstBumper);
expect(lastFocusedElement).toBe(buttonA);
});
it('Focuses last element when FTZ does not have focus and last bumper receives focus', async () => {
expect.assertions(2);
const { buttonC, buttonZ2, lastBumper } = setupTest({ isClickableOutsideFocusTrap: true });
ReactTestUtils.Simulate.focus(buttonZ2);
expect(lastFocusedElement).toBe(buttonZ2);
ReactTestUtils.Simulate.focus(lastBumper);
expect(lastFocusedElement).toBe(buttonC);
});
it('Focuses first on mount', async () => {
expect.assertions(1);
const { buttonA } = setupTest({});
expect(document.activeElement).toBe(buttonA);
});
it('Does not focus first on mount with disableFirstFocus', async () => {
expect.assertions(1);
const activeElement = document.activeElement;
setupTest({ disableFirstFocus: true });
// document.activeElement can be used to detect activeElement after component mount, but it does not
// update based on focus events due to limitations of ReactDOM.
// Make sure activeElement didn't change.
expect(document.activeElement).toBe(activeElement);
});
it('Does not focus first on mount while disabled', async () => {
expect.assertions(1);
const activeElement = document.activeElement;
setupTest({ disabled: true });
// document.activeElement can be used to detect activeElement after component mount, but it does not
// update based on focus events due to limitations of ReactDOM.
// Make sure activeElement didn't change.
expect(document.activeElement).toBe(activeElement);
});
it('Focuses on firstFocusableSelector on mount', async () => {
expect.assertions(1);
const { buttonC } = setupTest({ firstFocusableSelector: '.c' });
expect(document.activeElement).toBe(buttonC);
});
it('Does not focus on firstFocusableSelector on mount while disabled', async () => {
expect.assertions(1);
const activeElement = document.activeElement;
setupTest({ firstFocusableSelector: '.c', disabled: true });
expect(document.activeElement).toBe(activeElement);
});
it('Falls back to first focusable element with invalid firstFocusableSelector', async () => {
const { buttonA } = setupTest({ firstFocusableSelector: '.invalidSelector' });
expect(document.activeElement).toBe(buttonA);
});
});
describe('Focusing the FTZ', () => {
function setupTest(focusPreviouslyFocusedInnerElement: boolean) {
let focusTrapZoneRef: FocusTrapZone | null = null;
const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
{
focusTrapZoneRef = ftz;
}}
>
,
) as HTMLElement;
const buttonF = topLevelDiv.querySelector('.f') as HTMLElement;
const buttonA = topLevelDiv.querySelector('.a') as HTMLElement;
const buttonB = topLevelDiv.querySelector('.b') as HTMLElement;
const buttonZ = topLevelDiv.querySelector('.z') as HTMLElement;
// Assign bounding locations to buttons.
setupElement(buttonF, { clientRect: { top: 0, bottom: 10, left: 0, right: 10 } });
setupElement(buttonA, { clientRect: { top: 10, bottom: 20, left: 0, right: 10 } });
setupElement(buttonB, { clientRect: { top: 20, bottom: 30, left: 0, right: 10 } });
setupElement(buttonZ, { clientRect: { top: 30, bottom: 40, left: 0, right: 10 } });
return { focusTrapZone: focusTrapZoneRef, buttonF, buttonA, buttonB, buttonZ };
}
it('goes to previously focused element when focusing the FTZ', async () => {
expect.assertions(4);
const { focusTrapZone, buttonF, buttonB, buttonZ } = setupTest(true /* focusPreviouslyFocusedInnerElement */);
// By calling `componentDidMount`, FTZ will behave as just initialized and focus needed element
// @ts-ignore
focusTrapZone.componentDidMount();
expect(lastFocusedElement).toBe(buttonF);
// Focus inside the trap zone, not the first element.
ReactTestUtils.Simulate.focus(buttonB);
expect(lastFocusedElement).toBe(buttonB);
// Focus outside the trap zone
ReactTestUtils.Simulate.focus(buttonZ);
expect(lastFocusedElement).toBe(buttonZ);
// By calling `componentDidMount`, FTZ will behave as just initialized and focus needed element
// FTZ should return to originally focused inner element.
// @ts-ignore
focusTrapZone.componentDidMount();
expect(lastFocusedElement).toBe(buttonB);
});
it('goes to first focusable element when focusing the FTZ', async () => {
expect.assertions(4);
const { focusTrapZone, buttonF, buttonB, buttonZ } = setupTest(false /* focusPreviouslyFocusedInnerElement */);
// By calling `componentDidMount`, FTZ will behave as just initialized and focus needed element
// Focus within should go to 1st focusable inner element.
// @ts-ignore
focusTrapZone.componentDidMount();
expect(lastFocusedElement).toBe(buttonF);
// Focus inside the trap zone, not the first element.
ReactTestUtils.Simulate.focus(buttonB);
expect(lastFocusedElement).toBe(buttonB);
// Focus outside the trap zone
ReactTestUtils.Simulate.focus(buttonZ);
expect(lastFocusedElement).toBe(buttonZ);
// By calling `componentDidMount`, FTZ will behave as just initialized and focus needed element
// Focus should go to the first focusable element
// @ts-ignore
focusTrapZone.componentDidMount();
expect(lastFocusedElement).toBe(buttonF);
});
});
describe('Nested FocusTrapZones Stack Behavior', () => {
const getFocusStack = (): FocusTrapZone[] => (FocusTrapZone as any)._focusStack;
beforeAll(() => (getFocusStack().length = 0));
it('FocusTrapZone maintains a proper stack of FocusTrapZones as more are mounted/unmounted.', async () => {
let focusTrapZoneFocusStack: FocusTrapZone[] = getFocusStack();
const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
,
) as HTMLElement;
const buttonA = topLevelDiv.querySelector('.a') as HTMLElement;
const buttonB = topLevelDiv.querySelector('.b') as HTMLElement;
expect(focusTrapZoneFocusStack.length).toBe(2);
const baseFocusTrapZone = focusTrapZoneFocusStack[0];
expect(baseFocusTrapZone.props.forceFocusInsideTrapOnOutsideFocus).toBe(true);
expect(baseFocusTrapZone.props.isClickableOutsideFocusTrap).toBe(false);
const firstFocusTrapZone = focusTrapZoneFocusStack[1];
expect(firstFocusTrapZone.props.forceFocusInsideTrapOnOutsideFocus).toBe(false);
expect(firstFocusTrapZone.props.isClickableOutsideFocusTrap).toBe(false);
// There should be now 3 focus trap zones (base/first/second)
ReactTestUtils.Simulate.click(buttonB);
expect(focusTrapZoneFocusStack.length).toBe(3);
expect(focusTrapZoneFocusStack[0]).toBe(baseFocusTrapZone);
expect(focusTrapZoneFocusStack[1]).toBe(firstFocusTrapZone);
const secondFocusTrapZone = focusTrapZoneFocusStack[2];
expect(secondFocusTrapZone.props.forceFocusInsideTrapOnOutsideFocus).toBe(false);
expect(secondFocusTrapZone.props.isClickableOutsideFocusTrap).toBe(true);
// we remove the middle one
// unmounting a focus trap zone should remove it from the focus stack.
// but we also check that it removes the right focustrapzone (the middle one)
ReactTestUtils.Simulate.click(buttonA);
focusTrapZoneFocusStack = getFocusStack();
expect(focusTrapZoneFocusStack.length).toBe(2);
expect(focusTrapZoneFocusStack[0]).toBe(baseFocusTrapZone);
expect(focusTrapZoneFocusStack[1]).toBe(secondFocusTrapZone);
// finally remove the last focus trap zone.
ReactTestUtils.Simulate.click(buttonB);
focusTrapZoneFocusStack = getFocusStack();
expect(focusTrapZoneFocusStack.length).toBe(1);
expect(focusTrapZoneFocusStack[0]).toBe(baseFocusTrapZone);
});
});
});