Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: Add disabled prop.",
"type": "minor"
}
],
"packageName": "office-ui-fabric-react",
"email": "jagore@microsoft.com"
}
Original file line number Diff line number Diff line change
Expand Up @@ -4225,6 +4225,7 @@ export interface IFocusTrapZone {
export interface IFocusTrapZoneProps extends React.HTMLAttributes<HTMLDivElement> {
ariaLabelledBy?: string;
componentRef?: IRefObject<IFocusTrapZone>;
disabled?: boolean;
disableFirstFocus?: boolean;
elementToFocusOnDismiss?: HTMLElement;
firstFocusableSelector?: string | (() => string);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { FocusTrapZone } from '../../FocusTrapZone';
export const FocusTrapCallout: React.StatelessComponent<IFocusTrapCalloutProps> = (props: IFocusTrapCalloutProps): JSX.Element => {
return (
<Callout {...props}>
<FocusTrapZone {...props.focusTrapProps}>{props.children}</FocusTrapZone>
<FocusTrapZone disabled={props.hidden} {...props.focusTrapProps}>
{props.children}
</FocusTrapZone>
</Callout>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ import { FocusTrapZoneNestedExample } from './examples/FocusTrapZone.Nested.Exam
const FocusTrapZoneNestedExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.Nested.Example.tsx') as string;
const FocusTrapZoneNestedExampleCodepen = require('!@uifabric/codepen-loader!office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.Nested.Example.tsx') as string;

import { FocusTrapZoneNoTabbableExample } from './examples/FocusTrapZone.NoTabbable.Example';
const FocusTrapZoneNoTabbableExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.NoTabbable.Example.tsx') as string;
const FocusTrapZoneNoTabbableExampleCodepen = require('!@uifabric/codepen-loader!office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.NoTabbable.Example.tsx') as string;

import { FocusTrapZoneFocusZoneExample } from './examples/FocusTrapZone.FocusZone.Example';
const FocusTrapZoneFocusZoneExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.FocusZone.Example.tsx') as string;
const FocusTrapZoneFocusZoneExampleCodepen = require('!@uifabric/codepen-loader!office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.FocusZone.Example.tsx') as string;
Expand Down Expand Up @@ -66,12 +62,6 @@ export const FocusTrapZonePageProps: IDocPageProps = {
codepenJS: FocusTrapZoneFocusZoneExampleCodepen,
view: <FocusTrapZoneFocusZoneExample />
},
{
title: 'FocusTrapZone with no tabbable elements',
code: FocusTrapZoneNoTabbableExampleCode,
codepenJS: FocusTrapZoneNoTabbableExampleCodepen,
view: <FocusTrapZoneNoTabbableExample />
},
{
title: 'A Dialog nested in a Panel',
code: FocusTrapZoneDialogInPanelExampleCode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,19 @@ describe('FocusTrapZone', () => {
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);

Expand All @@ -587,6 +600,16 @@ describe('FocusTrapZone', () => {
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' });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,30 +47,36 @@ export class FocusTrapZone extends React.Component<IFocusTrapZoneProps, {}> impl
public componentDidUpdate(prevProps: IFocusTrapZoneProps) {
const prevForceFocusInsideTrap = prevProps.forceFocusInsideTrap !== undefined ? prevProps.forceFocusInsideTrap : true;
const newForceFocusInsideTrap = this.props.forceFocusInsideTrap !== undefined ? this.props.forceFocusInsideTrap : true;
const prevDisabled = prevProps.disabled !== undefined ? prevProps.disabled : false;
const newDisabled = this.props.disabled !== undefined ? this.props.disabled : false;

if (!prevForceFocusInsideTrap && newForceFocusInsideTrap) {
// Transition from forceFocusInsideTrap disabled to enabled. Emulate what happens when a FocusTrapZone gets mounted
if ((!prevForceFocusInsideTrap && newForceFocusInsideTrap) || (prevDisabled && !newDisabled)) {
// Transition from forceFocusInsideTrap / FTZ disabled to enabled.
// Emulate what happens when a FocusTrapZone gets mounted.
this._bringFocusIntoZone();
} else if (prevForceFocusInsideTrap && !newForceFocusInsideTrap) {
// Transition from forceFocusInsideTrap enabled to disabled. Emulate what happens when a FocusTrapZone gets unmounted
} else if ((prevForceFocusInsideTrap && !newForceFocusInsideTrap) || (!prevDisabled && newDisabled)) {
// Transition from forceFocusInsideTrap / FTZ enabled to disabled.
// Emulate what happens when a FocusTrapZone gets unmounted.
this._returnFocusToInitiator();
}
}

public componentWillUnmount(): void {
this._returnFocusToInitiator();
if (!this.props.disabled) {
this._returnFocusToInitiator();
}
}

public render(): JSX.Element {
const { className, ariaLabelledBy } = this.props;
const { className, disabled = false, ariaLabelledBy } = this.props;
const divProps = getNativeProps(this.props, divProperties);

const bumperProps = {
style: {
pointerEvents: 'none',
position: 'fixed' // 'fixed' prevents browsers from scrolling to bumpers when viewport does not contain them
},
tabIndex: 0,
tabIndex: disabled ? -1 : 0, // make bumpers tabbable only when enabled
'aria-hidden': true,
'data-is-visible': true
} as React.HTMLAttributes<HTMLDivElement>;
Expand Down Expand Up @@ -168,7 +174,12 @@ export class FocusTrapZone extends React.Component<IFocusTrapZoneProps, {}> impl
};

private _onBumperFocus = (isFirstBumper: boolean) => {
if (this.props.disabled) {
return;
}

const currentBumper = (isFirstBumper === this._hasFocus ? this._lastBumper.current : this._firstBumper.current) as HTMLElement;

if (this._root.current) {
const nextFocusable =
isFirstBumper === this._hasFocus
Expand All @@ -187,7 +198,11 @@ export class FocusTrapZone extends React.Component<IFocusTrapZoneProps, {}> impl
};

private _bringFocusIntoZone(): void {
const { elementToFocusOnDismiss, disableFirstFocus = false } = this.props;
const { elementToFocusOnDismiss, disabled = false, disableFirstFocus = false } = this.props;

if (disabled) {
return;
}

FocusTrapZone._focusStack.push(this);

Expand Down Expand Up @@ -252,6 +267,10 @@ export class FocusTrapZone extends React.Component<IFocusTrapZoneProps, {}> impl
}

private _forceFocusInTrap = (ev: FocusEvent): void => {
if (this.props.disabled) {
return;
}

if (FocusTrapZone._focusStack.length && this === FocusTrapZone._focusStack[FocusTrapZone._focusStack.length - 1]) {
const focusedElement = document.activeElement as HTMLElement;

Expand All @@ -265,6 +284,10 @@ export class FocusTrapZone extends React.Component<IFocusTrapZoneProps, {}> impl
};

private _forceClickInTrap = (ev: MouseEvent): void => {
if (this.props.disabled) {
return;
}

if (FocusTrapZone._focusStack.length && this === FocusTrapZone._focusStack[FocusTrapZone._focusStack.length - 1]) {
const clickedElement = ev.target as HTMLElement;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,15 @@ export interface IFocusTrapZoneProps extends React.HTMLAttributes<HTMLDivElement
*/
componentRef?: IRefObject<IFocusTrapZone>;

/**
* Disables the FocusTrapZone's focus trapping behavior when set.
* @defaultvalue false
*/
disabled?: boolean;

/**
* Sets the HTMLElement to focus on when exiting the FocusTrapZone.
* @defaultvalue The element.target that triggered the FTZ.
* @defaultvalue element.target The element.target that triggered the FTZ.
*/
elementToFocusOnDismiss?: HTMLElement;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,51 +16,33 @@ export class FocusTrapZoneBoxClickExample extends React.Component<{}, IFocusTrap
private _toggle = React.createRef<IToggle>();

public render() {
return (
<div>
{this.state.useTrapZone ? (
<FocusTrapZone isClickableOutsideFocusTrap={true} forceFocusInsideTrap={false}>
{this._internalContents()}
</FocusTrapZone>
) : (
this._internalContents()
)}
</div>
);
}

private _internalContents() {
const { useTrapZone } = this.state;

return (
<Stack
horizontalAlign="start"
tokens={{ childrenGap: 15 }}
styles={{
root: { border: `2px dashed ${useTrapZone ? '#ababab' : 'transparent'}`, padding: 10 }
}}
>
<Toggle
label="Use trap zone"
componentRef={this._toggle}
checked={useTrapZone}
onChange={this._onFocusTrapZoneToggleChanged}
onText="On (toggle to exit)"
offText="Off"
/>
<TextField label="Input inside trap zone" styles={{ root: { width: 300 } }} />
<Link href="https://bing.com">Hyperlink inside trap zone</Link>
</Stack>
<FocusTrapZone disabled={!useTrapZone} isClickableOutsideFocusTrap={true} forceFocusInsideTrap={false}>
Copy link
Member

Choose a reason for hiding this comment

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

Couldn't you just do isClickableOutsideFocus instead of isClickableOutsideFocusTrap={true}?

<Stack
horizontalAlign="start"
tokens={{ childrenGap: 15 }}
styles={{
root: { border: `2px dashed ${useTrapZone ? '#ababab' : 'transparent'}`, padding: 10 }
}}
>
<Toggle
label="Use trap zone"
componentRef={this._toggle}
checked={useTrapZone}
onChange={this._onFocusTrapZoneToggleChanged}
onText="On (toggle to exit)"
offText="Off"
/>
<TextField label="Input inside trap zone" styles={{ root: { width: 300 } }} />
<Link href="https://bing.com">Hyperlink inside trap zone</Link>
</Stack>
</FocusTrapZone>
);
}

private _onFocusTrapZoneToggleChanged = (ev: React.MouseEvent<HTMLElement>, checked?: boolean): void => {
this.setState({ useTrapZone: !!checked }, () => {
// Restore focus to toggle after disabling the trap zone
// (the trap zone itself will handle initial focus when it's enabled)
if (!checked) {
this._toggle.current!.focus();
}
});
this.setState({ useTrapZone: !!checked });
};
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import * as React from 'react';

import { DefaultButton } from 'office-ui-fabric-react/lib/Button';
import { FocusTrapZone } from 'office-ui-fabric-react/lib/FocusTrapZone';
import { Link } from 'office-ui-fabric-react/lib/Link';
import { Stack } from 'office-ui-fabric-react/lib/Stack';
import { Text } from 'office-ui-fabric-react/lib/Text';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import { Toggle, IToggle } from 'office-ui-fabric-react/lib/Toggle';
import { Stack } from 'office-ui-fabric-react/lib/Stack';

export interface IFocusTrapZoneBoxExampleState {
useTrapZone: boolean;
Expand All @@ -18,50 +20,49 @@ export class FocusTrapZoneBoxExample extends React.Component<{}, IFocusTrapZoneB
private _toggle = React.createRef<IToggle>();

public render() {
return (
<div>
{this.state.useTrapZone ? (
<FocusTrapZone>{this._internalContents()}</FocusTrapZone>
) : (
// prettier-ignore
this._internalContents()
)}
</div>
);
}

private _internalContents() {
const { useTrapZone } = this.state;

return (
<Stack
horizontalAlign="start"
tokens={{ childrenGap: 15 }}
styles={{
root: { border: `2px solid ${useTrapZone ? '#ababab' : 'transparent'}`, padding: 10 }
}}
>
<Toggle
label="Use trap zone"
componentRef={this._toggle}
checked={useTrapZone}
onChange={this._onFocusTrapZoneToggleChanged}
onText="On (toggle to exit)"
offText="Off"
/>
<TextField label="Input inside trap zone" styles={{ root: { width: 300 } }} />
<Link href="https://bing.com">Hyperlink inside trap zone</Link>
<Stack tokens={{ childrenGap: 8 }}>
<Stack.Item>
<Text>
If this button is used to enable FocusTrapZone, focus should return to this button after the FocusTrapZone is disabled.
</Text>
</Stack.Item>
<Stack.Item>
<DefaultButton onClick={this._onButtonClickHandler} text="Trap Focus" />
</Stack.Item>
<FocusTrapZone disabled={!useTrapZone}>
<Stack
horizontalAlign="start"
tokens={{ childrenGap: 15 }}
styles={{
root: { border: `2px solid ${useTrapZone ? '#ababab' : 'transparent'}`, padding: 10 }
}}
>
<Toggle
label="Use trap zone"
componentRef={this._toggle}
checked={useTrapZone}
onChange={this._onFocusTrapZoneToggleChanged}
onText="On (toggle to exit)"
offText="Off"
/>
<TextField label="Input inside trap zone" styles={{ root: { width: 300 } }} />
<Link href="https://bing.com">Hyperlink inside trap zone</Link>
</Stack>
</FocusTrapZone>
</Stack>
);
}

private _onFocusTrapZoneToggleChanged = (ev: React.MouseEvent<HTMLElement>, checked?: boolean): void => {
this.setState({ useTrapZone: !!checked }, () => {
// Restore focus to toggle after disabling the trap zone
// (the trap zone itself will handle initial focus when it's enabled)
if (!checked) {
this._toggle.current!.focus();
}
private _onButtonClickHandler = (): void => {
this.setState({
useTrapZone: true
});
};

private _onFocusTrapZoneToggleChanged = (ev: React.MouseEvent<HTMLElement>, checked?: boolean): void => {
this.setState({ useTrapZone: !!checked });
};
}
Loading