From 36f51543313ea8f162f815fb045b12898442ebcf Mon Sep 17 00:00:00 2001 From: Sofiya Huts Date: Mon, 12 Aug 2019 13:56:14 +0200 Subject: [PATCH 1/3] =?UTF-8?q?FocusTrapZone:=20Fixed=20FocusTrapZone=20to?= =?UTF-8?q?=20use=20the=20relatedTarget=C2=A0#7906?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx b/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx index 7d7af1bcc7..f1d83e38c1 100644 --- a/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx +++ b/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx @@ -251,7 +251,8 @@ export default class FocusTrapZone extends React.Component { - const focusedElement = document.activeElement as HTMLElement + const focusedElement = + (ev.relatedTarget as HTMLElement) || (document.activeElement as HTMLElement) focusedElement && this._forceFocusInTrap(ev, focusedElement) } From c92ffa3d70e527e52438d1ce021c9e09d7fe7377 Mon Sep 17 00:00:00 2001 From: Sofiya Huts Date: Tue, 13 Aug 2019 16:33:34 +0200 Subject: [PATCH 2/3] Upgrade FocusTrapZone --- .../accessibility/FocusZone/FocusTrapZone.tsx | 285 +++++++++++++----- .../FocusZone/FocusTrapZone.types.tsx | 6 + .../accessibility/FocusZone/focusUtilities.ts | 8 +- .../test/specs/lib/FocusTrapZone-test.tsx | 250 +++++++++++---- 4 files changed, 414 insertions(+), 135 deletions(-) diff --git a/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx b/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx index f1d83e38c1..f467c6d0fb 100644 --- a/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx +++ b/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.tsx @@ -2,7 +2,6 @@ import * as customPropTypes from '@stardust-ui/react-proptypes' import { EventListener } from '@stardust-ui/react-component-event-listener' import * as React from 'react' import * as ReactDOM from 'react-dom' -import * as keyboardKey from 'keyboard-key' import * as PropTypes from 'prop-types' import * as _ from 'lodash' @@ -24,9 +23,16 @@ import getElementType from '../../getElementType' * Pressing tab will circle focus within the inner focusable elements of the FocusTrapZone. */ export default class FocusTrapZone extends React.Component { static _focusStack: FocusTrapZone[] = [] + _root: { current: HTMLElement | null } = { current: null } + _previouslyFocusedElementOutsideTrapZone: HTMLElement _previouslyFocusedElementInTrapZone?: HTMLElement + + _firstBumper = React.createRef() + _lastBumper = React.createRef() + _hasFocus: boolean = false + windowRef = React.createRef() createRef = elem => { @@ -34,6 +40,7 @@ export default class FocusTrapZone extends React.Component !this.props.isClickableOutsideFocusTrap || !this.props.focusTriggerOnOutsideClick @@ -55,34 +62,87 @@ export default class FocusTrapZone extends React.Component + return ( <> +
{this.props.children} +
{forceFocusInsideTrapOnOutsideFocus && ( @@ -116,22 +180,119 @@ export default class FocusTrapZone extends React.Component) => { + if (this.props.onFocus) { + this.props.onFocus(ev) + } + + this._hasFocus = true + } + + _onRootBlur = (ev: React.FocusEvent) => { + if (this.props.onBlur) { + this.props.onBlur(ev) + } + + let relatedTarget = ev.relatedTarget + if (ev.relatedTarget === null) { + // In IE11, due to lack of support, event.relatedTarget is always + // null making every onBlur call to be "outside" of the ComboBox + // even when it's not. Using document.activeElement is another way + // for us to be able to get what the relatedTarget without relying + // on the event + relatedTarget = document.activeElement as Element + } + + if (!this._root.current.contains(relatedTarget as HTMLElement)) { + this._hasFocus = false + } + } + + _onFirstBumperFocus = () => { + this._onBumperFocus(true) + } + + _onLastBumperFocus = () => { + this._onBumperFocus(false) + } + + _isBumper(element: HTMLElement): boolean { + return element === this._firstBumper.current || element === this._lastBumper.current + } + + _onBumperFocus = (isFirstBumper: boolean) => { + if (!this._root.current) { + return + } + + const currentBumper = (isFirstBumper === this._hasFocus + ? this._lastBumper.current + : this._firstBumper.current) as HTMLElement + + const nextFocusable = + isFirstBumper === this._hasFocus + ? getLastTabbable(this._root.current, currentBumper, true, false) + : getFirstTabbable(this._root.current, currentBumper, true, false) + + if (nextFocusable) { + if (this._isBumper(nextFocusable)) { + // This can happen when FTZ contains no tabbable elements. Focus will take care of finding a focusable element in FTZ. + this._findElementAndFocusAsync() + } else { + nextFocusable.focus() + } + } + } + + _focusAsync(element: HTMLElement): void { + if (!this._isBumper(element)) { + focusAsync(element) + } + } + + _enableFocusTrapZone = () => { + const { disabled = false } = this.props + if (disabled) { + return + } + + FocusTrapZone._focusStack.push(this) + + this._bringFocusIntoZone() + this._hideContentFromAccessibilityTree() + } + + _bringFocusIntoZone = () => { + const { disableFirstFocus = false } = this.props + + this._previouslyFocusedElementOutsideTrapZone = this._getPreviouslyFocusedElementOutsideTrapZone() + + if ( + !this._root.current.contains(this._previouslyFocusedElementOutsideTrapZone) && + !disableFirstFocus + ) { + this._findElementAndFocusAsync() + } + } + + _releaseFocusTrapZone = () => { const { ignoreExternalFocusing } = this.props FocusTrapZone._focusStack = FocusTrapZone._focusStack.filter((value: FocusTrapZone) => { return this !== value }) + // try to focus element which triggered FocusTrapZone - prviously focused element outside trap zone const activeElement = document.activeElement as HTMLElement if ( !ignoreExternalFocusing && this._previouslyFocusedElementOutsideTrapZone && (this._root.current.contains(activeElement) || activeElement === document.body) ) { - focusAsync(this._previouslyFocusedElementOutsideTrapZone) + this._focusAsync(this._previouslyFocusedElementOutsideTrapZone) } + // if last active focus trap zone is going to be released - show previously hidden content in accessibility tree const lastActiveFocusTrap = FocusTrapZone._focusStack.length && FocusTrapZone._focusStack[FocusTrapZone._focusStack.length - 1] @@ -147,21 +308,11 @@ export default class FocusTrapZone extends React.Component { - const { disableFirstFocus = false } = this.props - - this._previouslyFocusedElementOutsideTrapZone = this._getPreviouslyFocusedElementOutsideTrapZone() - - if ( - !this._root.current.contains(this._previouslyFocusedElementOutsideTrapZone) && - !disableFirstFocus - ) { - this._findElementAndFocusAsync() + _findElementAndFocusAsync = () => { + if (!this._root.current) { + return } - } - _findElementAndFocusAsync = () => { - if (!this._root.current) return const { focusPreviouslyFocusedInnerElement, firstFocusableSelector } = this.props if ( @@ -170,7 +321,7 @@ export default class FocusTrapZone extends React.Component) => { this.props.onFocusCapture && this.props.onFocusCapture(ev) - if (ev.target !== ev.currentTarget) { + if (ev.target !== ev.currentTarget && !this._isBumper(ev.target)) { // every time focus changes within the trap zone, remember the focused element so that // it can be restored if focus leaves the pane and returns via keystroke (i.e. via a call to this.focus(true)) this._previouslyFocusedElementInTrapZone = ev.target as HTMLElement } } - _onKeyboardHandler = (ev: React.KeyboardEvent): void => { - this.props.onKeyDown && this.props.onKeyDown(ev) - - // do not propogate keyboard events outside focus trap zone - ev.stopPropagation() - - if ( - ev.isDefaultPrevented() || - keyboardKey.getCode(ev) !== keyboardKey.Tab || - !this._root.current - ) { - return - } - - const _firstTabbableChild = getFirstTabbable( - this._root.current, - this._root.current.firstChild as HTMLElement, - true, - ) - const _lastTabbableChild = getLastTabbable( - this._root.current, - this._root.current.lastChild as HTMLElement, - true, - ) - - if (ev.shiftKey && _firstTabbableChild === ev.target) { - focusAsync(_lastTabbableChild) - ev.preventDefault() - } else if (!ev.shiftKey && _lastTabbableChild === ev.target) { - focusAsync(_firstTabbableChild) - ev.preventDefault() - } - } - _forceFocusInTrap = (ev: Event, triggeredElement: HTMLElement) => { if ( FocusTrapZone._focusStack.length && @@ -251,8 +375,7 @@ export default class FocusTrapZone extends React.Component { - const focusedElement = - (ev.relatedTarget as HTMLElement) || (document.activeElement as HTMLElement) + const focusedElement = document.activeElement as HTMLElement focusedElement && this._forceFocusInTrap(ev, focusedElement) } @@ -275,6 +398,16 @@ export default class FocusTrapZone extends React.Component): void => { + if (this.props.onKeyDown) { + this.props.onKeyDown(ev) + } + + // do not propogate keyboard events outside focus trap zone + // https://github.com/stardust-ui/react/pull/1180 + ev.stopPropagation() + } + _getPreviouslyFocusedElementOutsideTrapZone = () => { const { elementToFocusOnDismiss } = this.props let previouslyFocusedElement = this._previouslyFocusedElementOutsideTrapZone diff --git a/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.types.tsx b/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.types.tsx index 475e6c8b42..762bebc542 100644 --- a/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.types.tsx +++ b/packages/react/src/lib/accessibility/FocusZone/FocusTrapZone.types.tsx @@ -9,6 +9,11 @@ export interface FocusTrapZoneProps extends React.HTMLAttributes */ className?: string + /** + * Disables the FocusTrapZone's focus trapping behavior when set. + */ + disabled?: boolean + /** * Sets the HTMLElement to focus on when exiting the FocusTrapZone. By default, the target which triggered the FocusTrapZone will get focus. */ @@ -36,6 +41,7 @@ export interface FocusTrapZoneProps extends React.HTMLAttributes /** * Indicates whether focus trap zone should force focus inside the trap zone when focus event occurs outside the zone. + * @defaultvalue true */ forceFocusInsideTrapOnOutsideFocus?: boolean diff --git a/packages/react/src/lib/accessibility/FocusZone/focusUtilities.ts b/packages/react/src/lib/accessibility/FocusZone/focusUtilities.ts index f75ec99fe5..5db15e945d 100644 --- a/packages/react/src/lib/accessibility/FocusZone/focusUtilities.ts +++ b/packages/react/src/lib/accessibility/FocusZone/focusUtilities.ts @@ -58,11 +58,12 @@ export function getFirstTabbable( rootElement: HTMLElement, currentElement: HTMLElement, includeElementsInFocusZones?: boolean, + checkNode?: boolean, ): HTMLElement | null { return getNextElement( rootElement, currentElement, - true /* checkNode */, + checkNode, false /* suppressParentTraversal */, false /* suppressChildTraversal */, includeElementsInFocusZones, @@ -84,11 +85,12 @@ export function getLastTabbable( rootElement: HTMLElement, currentElement: HTMLElement, includeElementsInFocusZones?: boolean, + checkNode?: boolean, ): HTMLElement | null { return getPreviousElement( rootElement, currentElement, - true /* checkNode */, + checkNode, false /* suppressParentTraversal */, true /* traverseChildren */, includeElementsInFocusZones, @@ -184,7 +186,7 @@ export function getPreviousElement( } // Check the current node, if it's not the first traversal. - if (checkNode && isCurrentElementVisible && isElementTabbable(currentElement)) { + if (checkNode && isCurrentElementVisible && isElementTabbable(currentElement, tabbable)) { return currentElement } diff --git a/packages/react/test/specs/lib/FocusTrapZone-test.tsx b/packages/react/test/specs/lib/FocusTrapZone-test.tsx index 9aa581f36b..a4532a46b1 100644 --- a/packages/react/test/specs/lib/FocusTrapZone-test.tsx +++ b/packages/react/test/specs/lib/FocusTrapZone-test.tsx @@ -6,6 +6,7 @@ import { FocusTrapZone, FocusZone, FocusZoneDirection, + FocusTrapZoneProps, } from '../../../src/lib/accessibility/FocusZone' // rAF does not exist in node - let's mock it @@ -15,7 +16,6 @@ window.requestAnimationFrame = (callback: FrameRequestCallback) => { return r } -const animationFrame = () => new Promise(resolve => window.requestAnimationFrame(resolve)) jest.useFakeTimers() class FocusTrapZoneTestComponent extends React.Component< @@ -64,7 +64,11 @@ class FocusTrapZoneTestComponent extends React.Component< } 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) @@ -96,6 +100,24 @@ describe('FocusTrapZone', () => { 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 }) @@ -106,7 +128,7 @@ describe('FocusTrapZone', () => { const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
- +
@@ -146,19 +168,17 @@ describe('FocusTrapZone', () => { setupElement(buttonE, { clientRect: { top: 30, bottom: 60, left: 30, right: 60 } }) setupElement(buttonF, { clientRect: { top: 30, bottom: 60, left: 60, right: 90 } }) - // Focus the first button. + const { firstBumper, lastBumper } = getFtzBumpers(topLevelDiv) + ReactTestUtils.Simulate.focus(buttonA) - await animationFrame() expect(lastFocusedElement).toBe(buttonA) - // Pressing shift + tab should go to d. - ReactTestUtils.Simulate.keyDown(buttonA, { which: keyboardKey.Tab, shiftKey: true }) - await animationFrame() + // Simulate shift+tab event which would focus first bumper + ReactTestUtils.Simulate.focus(firstBumper) expect(lastFocusedElement).toBe(buttonD) - // Pressing tab should go to a. - ReactTestUtils.Simulate.keyDown(buttonD, { which: keyboardKey.Tab }) - await animationFrame() + // Simulate tab event which would focus last bumper + ReactTestUtils.Simulate.focus(lastBumper) expect(lastFocusedElement).toBe(buttonA) }) @@ -167,7 +187,7 @@ describe('FocusTrapZone', () => { const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
- +
@@ -193,6 +213,8 @@ describe('FocusTrapZone', () => { 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 } }) @@ -200,19 +222,15 @@ describe('FocusTrapZone', () => { setupElement(buttonC, { clientRect: { top: 0, bottom: 30, left: 60, right: 90 } }) setupElement(buttonD, { clientRect: { top: 30, bottom: 60, left: 0, right: 30 } }) - // Focus the first button. ReactTestUtils.Simulate.focus(buttonX) - await animationFrame() expect(lastFocusedElement).toBe(buttonX) - // Pressing shift + tab should go to a. - ReactTestUtils.Simulate.keyDown(buttonX, { which: keyboardKey.Tab, shiftKey: true }) - await animationFrame() + // Simulate shift+tab event which would focus first bumper + ReactTestUtils.Simulate.focus(firstBumper) expect(lastFocusedElement).toBe(buttonA) - // Pressing tab should go to x. - ReactTestUtils.Simulate.keyDown(buttonA, { which: keyboardKey.Tab }) - await animationFrame() + // Simulate tab event which would focus last bumper + ReactTestUtils.Simulate.focus(lastBumper) expect(lastFocusedElement).toBe(buttonX) }) @@ -222,7 +240,7 @@ describe('FocusTrapZone', () => { const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
- + @@ -249,6 +267,8 @@ describe('FocusTrapZone', () => { 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 } }) @@ -262,34 +282,34 @@ describe('FocusTrapZone', () => { // Focus the middle button in the first FZ. ReactTestUtils.Simulate.focus(buttonA) - await animationFrame() ReactTestUtils.Simulate.keyDown(buttonA, { which: keyboardKey.ArrowRight }) expect(lastFocusedElement).toBe(buttonB) // Focus the middle button in the second FZ. ReactTestUtils.Simulate.focus(buttonE) - await animationFrame() ReactTestUtils.Simulate.keyDown(buttonE, { which: keyboardKey.ArrowRight }) expect(lastFocusedElement).toBe(buttonF) - // Pressing tab should go to B the last focused element in FZ1. - ReactTestUtils.Simulate.keyDown(buttonF, { which: keyboardKey.Tab }) - await animationFrame() + // Simulate tab event which would focus last bumper + ReactTestUtils.Simulate.focus(lastBumper) expect(lastFocusedElement).toBe(buttonB) - // Pressing shift-tab should go to F the last focused element in FZ2. - ReactTestUtils.Simulate.keyDown(buttonB, { which: keyboardKey.Tab, shiftKey: true }) - await animationFrame() + // 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() { + function setupTest(props: FocusTrapZoneProps) { const topLevelDiv = ReactTestUtils.renderIntoDocument<{}>(
- + @@ -310,6 +330,14 @@ describe('FocusTrapZone', () => { 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 } }) @@ -317,39 +345,157 @@ describe('FocusTrapZone', () => { 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 } + return { buttonZ1, buttonA, buttonB, buttonC, buttonZ2, firstBumper, lastBumper } } - it('does not move when pressing tab', async () => { + it('focuses first focusable element when focusing first bumper', async () => { expect.assertions(2) + const { buttonB, buttonA, firstBumper } = setupTest({}) - const { buttonB } = setupTest() - - // Focus the middle button in the FTZ, even though it has tabIndex=-1 ReactTestUtils.Simulate.focus(buttonB) - await animationFrame() expect(lastFocusedElement).toBe(buttonB) - // Pressing tab should stay where you are. - ReactTestUtils.Simulate.keyDown(buttonB, { which: keyboardKey.Tab }) - await animationFrame() - expect(lastFocusedElement).toBe(buttonB) + // Simulate shift+tab event which would focus first bumper + ReactTestUtils.Simulate.focus(firstBumper) + expect(lastFocusedElement).toBe(buttonA) }) - it('does not move when pressing shift-tab', async () => { + it('focuses first focusable element when focusing last bumper', async () => { expect.assertions(2) - const { buttonB } = setupTest() + const { buttonA, buttonB, lastBumper } = setupTest({}) - // Focus the middle button in the FTZ, even though it has tabIndex=-1 ReactTestUtils.Simulate.focus(buttonB) - await animationFrame() expect(lastFocusedElement).toBe(buttonB) - // Pressing shift-tab should stay where you are. - ReactTestUtils.Simulate.keyDown(buttonB, { which: keyboardKey.Tab, shiftKey: true }) - await animationFrame() - 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) }) }) @@ -399,23 +545,19 @@ describe('FocusTrapZone', () => { // By calling `componentDidMount`, FTZ will behave as just initialized and focus needed element focusTrapZone.componentDidMount() - await animationFrame() expect(lastFocusedElement).toBe(buttonF) // Focus inside the trap zone, not the first element. ReactTestUtils.Simulate.focus(buttonB) - await animationFrame() expect(lastFocusedElement).toBe(buttonB) // Focus outside the trap zone ReactTestUtils.Simulate.focus(buttonZ) - await animationFrame() 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. focusTrapZone.componentDidMount() - await animationFrame() expect(lastFocusedElement).toBe(buttonB) }) @@ -429,23 +571,19 @@ describe('FocusTrapZone', () => { // By calling `componentDidMount`, FTZ will behave as just initialized and focus needed element // Focus within should go to 1st focusable inner element. focusTrapZone.componentDidMount() - await animationFrame() expect(lastFocusedElement).toBe(buttonF) // Focus inside the trap zone, not the first element. ReactTestUtils.Simulate.focus(buttonB) - await animationFrame() expect(lastFocusedElement).toBe(buttonB) // Focus outside the trap zone ReactTestUtils.Simulate.focus(buttonZ) - await animationFrame() 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 focusTrapZone.componentDidMount() - await animationFrame() expect(lastFocusedElement).toBe(buttonF) }) }) From 233fe790292ebfb48cc134f4c576a4beaa903df7 Mon Sep 17 00:00:00 2001 From: Sofiya Huts Date: Tue, 13 Aug 2019 18:26:21 +0200 Subject: [PATCH 3/3] update changelog --- CHANGELOG.md | 1 + .../react/src/lib/accessibility/FocusZone/CHANGELOG.md | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4d2eda4d..c316e64dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Upgrade `FocusZone` to the latest version from `fabric-ui` @sophieH29 ([#1772](https://github.com/stardust-ui/react/pull/1772)) - Add possibility for a Toolbar to rearrange its items according to space available @miroslavstastny ([#1657](https://github.com/stardust-ui/react/pull/1657)) - Edit `buttonBehavior` Adding aria-disabled when button has loading state @kolaps33 ([#1789](https://github.com/stardust-ui/react/pull/1789)) +- Upgrade `FocusTrapZone` to the latest version from `fabric-ui` @sophieH29 ([#1790](https://github.com/stardust-ui/react/pull/1790)) ### Documentation - Restore docs for `Ref` component @layershifter ([#1777](https://github.com/stardust-ui/react/pull/1777)) diff --git a/packages/react/src/lib/accessibility/FocusZone/CHANGELOG.md b/packages/react/src/lib/accessibility/FocusZone/CHANGELOG.md index 9eb9daa633..8d4e45df4d 100644 --- a/packages/react/src/lib/accessibility/FocusZone/CHANGELOG.md +++ b/packages/react/src/lib/accessibility/FocusZone/CHANGELOG.md @@ -81,3 +81,13 @@ This is a list of changes made to the Stardust copy of FocusTrapZone in comparis - Got rid of `componentWillMount` as it deprecated in higher versions of React. - Added `aria-hidden` to the body children outside of the Popup to prevent screen reader from reading background information. - Renamed `focus` method to `_findElementAndFocusAsync`, made it private and removed `IFocusTrapZone` interface as it's no longer needed. + + +### Upgrade `FocusTrapZone` to the latest version from `fabric-ui` @sophieH29 ([#1790](https://github.com/stardust-ui/react/pull/1790)) +- When `IsHiddenOnDismiss` is true focus does not automatically enter `Panel` ([OfficeDev/office-ui-fabric-react#7362](https://github.com/OfficeDev/office-ui-fabric-react/pull/7362)) +- Refactor trapping behavior ([OfficeDev/office-ui-fabric-react#8216](https://github.com/OfficeDev/office-ui-fabric-react/pull/8216)) +- Fix zero tabbable element scenarios ([OfficeDev/office-ui-fabric-react#8274](https://github.com/OfficeDev/office-ui-fabric-react/pull/8274)) +- Fix focus and blur callbacks ([OfficeDev/office-ui-fabric-react#8404](https://github.com/OfficeDev/office-ui-fabric-react/pull/8404)) +- Add new disabled prop ([OfficeDev/office-ui-fabric-react#8809](https://github.com/OfficeDev/office-ui-fabric-react/pull/8809)) +- Update focus handling in DatePicker and FocusTrapZone ([OfficeDev/office-ui-fabric-react#8875](https://github.com/OfficeDev/office-ui-fabric-react/pull/8875)) +- Remove aria-hidden from FocusTrapZone's bumpers ([OfficeDev/office-ui-fabric-react#9019](https://github.com/OfficeDev/office-ui-fabric-react/pull/9019))