Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -304,4 +304,94 @@ describe('FocusTrapZone', () => {
expect(lastFocusedElement).toBe(buttonB);
});
});

describe('Focusing the FTZ', () => {
function setupTest(focusPreviouslyFocusedInnerElement: boolean) {
const focusTrapZoneRef = createRef<FocusTrapZone>();
const topLevelDiv = ReactTestUtils.renderIntoDocument(
<div onFocusCapture={_onFocus}>
<FocusTrapZone
forceFocusInsideTrap={false}
focusPreviouslyFocusedInnerElement={focusPreviouslyFocusedInnerElement}
data-is-focusable={true}
ref={focusTrapZoneRef}
>
<button className={'f'}>f</button>
<FocusZone>
<button className={'a'}>a</button>
<button className={'b'}>b</button>
</FocusZone>
</FocusTrapZone>
<button className={'z'}>z</button>
</div>
) 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export class FocusTrapZone extends BaseComponent<IFocusTrapZoneProps, {}> implem
private static _clickStack: FocusTrapZone[] = [];

private _root = createRef<HTMLDivElement>();
private _previouslyFocusedElement: HTMLElement;
private _previouslyFocusedElementOutsideTrapZone: HTMLElement;
private _previouslyFocusedElementInTrapZone?: HTMLElement;
private _isInFocusStack = false;
private _isInClickStack = false;

Expand All @@ -42,10 +43,10 @@ export class FocusTrapZone extends BaseComponent<IFocusTrapZoneProps, {}> 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();
}

Expand All @@ -60,8 +61,8 @@ export class FocusTrapZone extends BaseComponent<IFocusTrapZoneProps, {}> 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;
}
}

Expand All @@ -84,11 +85,11 @@ export class FocusTrapZone extends BaseComponent<IFocusTrapZoneProps, {}> 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);
}
}

Expand All @@ -103,17 +104,26 @@ export class FocusTrapZone extends BaseComponent<IFocusTrapZoneProps, {}> implem
ref={this._root}
aria-labelledby={ariaLabelledBy}
onKeyDown={this._onKeyboardHandler}
onFocusCapture={this._onFocusCapture}
>
{this.props.children}
</div>
);
}

/**
* 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
Expand All @@ -140,7 +150,18 @@ export class FocusTrapZone extends BaseComponent<IFocusTrapZoneProps, {}> implem
}
}

private _onKeyboardHandler = (ev: React.KeyboardEvent<HTMLElement>): void => {
private _onFocusCapture = (ev: React.FocusEvent<HTMLDivElement>) => {
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<HTMLDivElement>): void => {
if (this.props.onKeyDown) {
this.props.onKeyDown(ev);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -44,7 +45,7 @@ export interface IFocusTrapZoneProps extends React.HTMLAttributes<HTMLDivElement
forceFocusInsideTrap?: boolean;

/**
* Indicates the selector for first focusable item
* Indicates the selector for first focusable item. Only applies if focusPreviouslyFocusedInnerElement == false.
*/
firstFocusableSelector?: string | (() => string);

Expand All @@ -55,7 +56,11 @@ export interface IFocusTrapZoneProps extends React.HTMLAttributes<HTMLDivElement
disableFirstFocus?: boolean;

/**
* Optional, onKeyDown event handler
* Specifies the algorithm used to determine which descendant element to focus when focus() is called.
* If false, the first focusable descendant, filtered by the firstFocusableSelector property if present, is chosen.
* If true, the element that was focused when the Trap Zone last had a focused descendant is chosen.
* If it has never had a focused descendant before, behavior falls back to the first focused descendant.
* @default false
*/
onKeyDown?: (ev: React.KeyboardEvent<HTMLElement>) => void;
focusPreviouslyFocusedInnerElement?: boolean;
}