diff --git a/common/changes/office-ui-fabric-react/ftz-rememberlastfocused_2018-05-16-22-37.json b/common/changes/office-ui-fabric-react/ftz-rememberlastfocused_2018-05-16-22-37.json new file mode 100644 index 0000000000000..0741f771aec9f --- /dev/null +++ b/common/changes/office-ui-fabric-react/ftz-rememberlastfocused_2018-05-16-22-37.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "office-ui-fabric-react", + "comment": "FocusTrapZone: Added new capability. When `FTZ.focus()` is called, it will pass focus to a descendant element. The new prop `focusPreviouslyFocusedInnerElement` controls the descendant-choosing behavior.", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "benw@microsoft.com" +} diff --git a/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.test.tsx b/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.test.tsx index 9e89154caca6c..e7965d95559a1 100644 --- a/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.test.tsx +++ b/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import * as ReactTestUtils from 'react-dom/test-utils'; -import { KeyCodes } from '../../Utilities'; +import { KeyCodes, createRef } from '../../Utilities'; import { FocusZone, FocusZoneDirection } from '../FocusZone'; import { FocusTrapZone } from './FocusTrapZone'; @@ -304,4 +304,94 @@ describe('FocusTrapZone', () => { expect(lastFocusedElement).toBe(buttonB); }); }); + + describe('Focusing the FTZ', () => { + function setupTest(focusPreviouslyFocusedInnerElement: boolean) { + const focusTrapZoneRef = createRef(); + const topLevelDiv = ReactTestUtils.renderIntoDocument( +
+ + + + + + + + +
+ ) 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.current!, 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*/); + + // Manually focusing FTZ when FTZ has never + // had focus within should go to 1st focusable inner element. + focusTrapZone.focus(); + 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); + + // Manually focusing FTZ should return to originally focused inner element. + focusTrapZone.focus(); + await animationFrame(); + 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*/); + + // Manually focusing FTZ when FTZ has never + // had focus within should go to 1st focusable inner element. + focusTrapZone.focus(); + 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); + + // Manually focusing FTZ should go to the first focusable element. + focusTrapZone.focus(); + await animationFrame(); + expect(lastFocusedElement).toBe(buttonF); + }); + }); }); diff --git a/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.tsx b/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.tsx index 9733049668ee7..a72fb033addd4 100644 --- a/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.tsx +++ b/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.tsx @@ -18,7 +18,8 @@ export class FocusTrapZone extends BaseComponent implem private static _clickStack: FocusTrapZone[] = []; private _root = createRef(); - private _previouslyFocusedElement: HTMLElement; + private _previouslyFocusedElementOutsideTrapZone: HTMLElement; + private _previouslyFocusedElementInTrapZone?: HTMLElement; private _isInFocusStack = false; private _isInClickStack = false; @@ -42,10 +43,10 @@ export class FocusTrapZone extends BaseComponent implem disableFirstFocus = false } = this.props; - this._previouslyFocusedElement = elementToFocusOnDismiss + this._previouslyFocusedElementOutsideTrapZone = elementToFocusOnDismiss ? elementToFocusOnDismiss : (document.activeElement as HTMLElement); - if (!elementContains(this._root.current, this._previouslyFocusedElement) && !disableFirstFocus) { + if (!elementContains(this._root.current, this._previouslyFocusedElementOutsideTrapZone) && !disableFirstFocus) { this.focus(); } @@ -60,8 +61,8 @@ export class FocusTrapZone extends BaseComponent implem public componentWillReceiveProps(nextProps: IFocusTrapZoneProps): void { const { elementToFocusOnDismiss } = nextProps; - if (elementToFocusOnDismiss && this._previouslyFocusedElement !== elementToFocusOnDismiss) { - this._previouslyFocusedElement = elementToFocusOnDismiss; + if (elementToFocusOnDismiss && this._previouslyFocusedElementOutsideTrapZone !== elementToFocusOnDismiss) { + this._previouslyFocusedElementOutsideTrapZone = elementToFocusOnDismiss; } } @@ -84,11 +85,11 @@ export class FocusTrapZone extends BaseComponent implem const activeElement = document.activeElement as HTMLElement; if ( !ignoreExternalFocusing && - this._previouslyFocusedElement && - typeof this._previouslyFocusedElement.focus === 'function' && + this._previouslyFocusedElementOutsideTrapZone && + typeof this._previouslyFocusedElementOutsideTrapZone.focus === 'function' && (elementContains(this._root.value, activeElement) || activeElement === document.body) ) { - focusAsync(this._previouslyFocusedElement); + focusAsync(this._previouslyFocusedElementOutsideTrapZone); } } @@ -103,17 +104,26 @@ export class FocusTrapZone extends BaseComponent implem ref={this._root} aria-labelledby={ariaLabelledBy} onKeyDown={this._onKeyboardHandler} + onFocusCapture={this._onFocusCapture} > {this.props.children} ); } - /** - * Need to expose this method in case of popups since focus needs to be set when popup is opened - */ public focus() { - const { firstFocusableSelector } = this.props; + const { focusPreviouslyFocusedInnerElement, firstFocusableSelector } = this.props; + + if ( + focusPreviouslyFocusedInnerElement && + this._previouslyFocusedElementInTrapZone && + elementContains(this._root.value, this._previouslyFocusedElementInTrapZone) + ) { + // focus on the last item that had focus in the zone before we left the zone + focusAsync(this._previouslyFocusedElementInTrapZone); + return; + } + const focusSelector = typeof firstFocusableSelector === 'string' ? firstFocusableSelector @@ -140,7 +150,18 @@ export class FocusTrapZone extends BaseComponent implem } } - private _onKeyboardHandler = (ev: React.KeyboardEvent): void => { + private _onFocusCapture = (ev: React.FocusEvent) => { + if (this.props.onFocusCapture) { + this.props.onFocusCapture(ev); + } + if (ev.target !== ev.currentTarget) { + // 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; + } + }; + + private _onKeyboardHandler = (ev: React.KeyboardEvent): void => { if (this.props.onKeyDown) { this.props.onKeyDown(ev); } diff --git a/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.types.ts b/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.types.ts index 247a5e19f7e7e..d0ab9e81c434e 100644 --- a/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.types.ts +++ b/packages/office-ui-fabric-react/src/components/FocusTrapZone/FocusTrapZone.types.ts @@ -2,7 +2,8 @@ import * as React from 'react'; export interface IFocusTrapZone { /** - * Sets focus on the first focusable, or configured, child in focus trap zone + * Sets focus to a descendant in the Trap Zone. + * See firstFocusableSelector and focusPreviouslyFocusedInnerElement for details. */ focus: () => void; } @@ -44,7 +45,7 @@ export interface IFocusTrapZoneProps extends React.HTMLAttributes string); @@ -55,7 +56,11 @@ export interface IFocusTrapZoneProps extends React.HTMLAttributes) => void; + focusPreviouslyFocusedInnerElement?: boolean; }