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); }); }); });