diff --git a/common/changes/@uifabric/utilities/focuszone_2019-01-28-05-01.json b/common/changes/@uifabric/utilities/focuszone_2019-01-28-05-01.json new file mode 100644 index 0000000000000..e9becd73d13d6 --- /dev/null +++ b/common/changes/@uifabric/utilities/focuszone_2019-01-28-05-01.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Adding helper functions to get and restore focus elements based on index paths.", + "packageName": "@uifabric/utilities", + "type": "minor" + } + ], + "packageName": "@uifabric/utilities", + "email": "dzearing@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/office-ui-fabric-react/focuszone_2019-01-28-05-01.json b/common/changes/office-ui-fabric-react/focuszone_2019-01-28-05-01.json new file mode 100644 index 0000000000000..24b5437f80ba1 --- /dev/null +++ b/common/changes/office-ui-fabric-react/focuszone_2019-01-28-05-01.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "office-ui-fabric-react", + "comment": "FocusZone: focus can now be recovered if focus was resting within the zone but the element was removed.", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "dzearing@microsoft.com" +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.ts b/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.ts index bb8b1125d1458..5848f47c67660 100644 --- a/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.ts +++ b/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.ts @@ -1247,6 +1247,8 @@ class FocusZone extends BaseComponent, implements IFocusZon // (undocumented) componentDidMount(): void; // (undocumented) + componentDidUpdate(): void; + // (undocumented) componentWillUnmount(): void; // (undocumented) static defaultProps: IFocusZoneProps; @@ -1352,6 +1354,9 @@ export function getDistanceBetweenPoints(point1: IPoint, point2: IPoint): number // @public export function getDocument(rootElement?: HTMLElement | null): Document | undefined; +// @public +export function getElementIndexPath(fromElement: HTMLElement, toElement: HTMLElement): number[]; + // @public export function getFadedOverflowStyle(theme: ITheme, color?: keyof ISemanticColors | keyof IPalette, direction?: 'horizontal' | 'vertical', width?: string | number, height?: string | number): IRawStyle; @@ -1361,6 +1366,9 @@ export function getFirstFocusable(rootElement: HTMLElement, currentElement: HTML // @public export function getFirstTabbable(rootElement: HTMLElement, currentElement: HTMLElement, includeElementsInFocusZones?: boolean): HTMLElement | null; +// @public +export function getFocusableByIndexPath(parent: HTMLElement, path: number[]): HTMLElement | undefined; + // @public export function getFocusStyle(theme: ITheme, inset?: number, position?: 'relative' | 'absolute', highContrastStyle?: IRawStyle | undefined, borderColor?: string, outlineColor?: string, isFocusedOnly?: boolean): IRawStyle; diff --git a/packages/office-ui-fabric-react/package.json b/packages/office-ui-fabric-react/package.json index b01db357f5141..01dbee56e93c9 100644 --- a/packages/office-ui-fabric-react/package.json +++ b/packages/office-ui-fabric-react/package.json @@ -45,6 +45,7 @@ "@uifabric/jest-serializer-merge-styles": ">=6.0.7 <7.0.0", "@uifabric/prettier-rules": ">=1.0.0 <2.0.0", "@uifabric/tslint-rules": ">=1.0.0 <2.0.0", + "@uifabric/test-utilities": ">=6.0.0 <7.0.0", "@uifabric/webpack-utils": ">=0.7.4 <1.0.0", "enzyme": "^3.4.1", "enzyme-adapter-react-16": "^1.2.0", @@ -76,4 +77,4 @@ "react": ">=16.3.2-0 <17.0.0", "react-dom": ">=16.3.2-0 <17.0.0" } -} +} \ No newline at end of file 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 aa87ad09d2c81..19f7015b5aeb5 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 @@ -92,7 +92,6 @@ describe('FocusTrapZone', () => { 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); diff --git a/packages/office-ui-fabric-react/src/components/FocusZone/FocusZone.test.tsx b/packages/office-ui-fabric-react/src/components/FocusZone/FocusZone.test.tsx index 0be56b4f3775f..8a38c7ca74365 100644 --- a/packages/office-ui-fabric-react/src/components/FocusZone/FocusZone.test.tsx +++ b/packages/office-ui-fabric-react/src/components/FocusZone/FocusZone.test.tsx @@ -1,14 +1,23 @@ import * as React from 'react'; - import * as ReactDOM from 'react-dom'; import * as ReactTestUtils from 'react-dom/test-utils'; import { setRTL, KeyCodes } from '../../Utilities'; - import { FocusZone } from './FocusZone'; import { FocusZoneDirection, FocusZoneTabbableElements } from './FocusZone.types'; +// tslint:disable:typedef + describe('FocusZone', () => { let lastFocusedElement: HTMLElement | undefined; + let host: HTMLElement; + + afterEach(() => { + if (host) { + ReactDOM.unmountComponentAtNode(host); + (host as any) = undefined; + } + }); + function _onFocus(ev: any): void { lastFocusedElement = ev.target; } @@ -143,6 +152,159 @@ describe('FocusZone', () => { expect(lastFocusedElement).toBe(buttonC); }); + it('can restore focus to the following item when item removed', () => { + host = document.createElement('div'); + + // Render component. + ReactDOM.render( + + + + + , + host + ); + + const buttonB = host.querySelector('#b') as HTMLElement; + + buttonB.focus(); + + // Render component without button A. + ReactDOM.render( + + + + , + host + ); + + expect(document.activeElement).toBe(host.querySelector('#c')); + }); + + it('can restore focus to the previous item when end item removed', () => { + host = document.createElement('div'); + + // Render component. + ReactDOM.render( + + + + + , + host + ); + + const buttonC = host.querySelector('#c') as HTMLElement; + + buttonC.focus(); + + // Render component without button A. + ReactDOM.render( + + + + , + host + ); + + expect(document.activeElement).toBe(host.querySelector('#b')); + }); + + describe('parking and unparking', () => { + let buttonA: HTMLElement; + + beforeEach(() => { + host = document.createElement('div'); + + // Render component. + ReactDOM.render( +
+ + +
, + host + ); + buttonA = host.querySelector('#a') as HTMLElement; + buttonA.focus(); + + // Render component without button A. + ReactDOM.render( +
+
, + host + ); + }); + + it('can move focus to container when last item removed', () => { + expect(document.activeElement).toBe(host.querySelector('#fz')); + }); + + it('can move focus from container to first item when added', () => { + ReactDOM.render( +
+ + +
, + host + ); + expect(document.activeElement).toBe(host.querySelector('#a')); + }); + + it('removes focusability when moving from focused container', () => { + expect(host.querySelector('#fz')!.getAttribute('tabindex')).toEqual('-1'); + (host.querySelector('#z') as HTMLElement).focus(); + expect(host.querySelector('#fz')!.getAttribute('tabindex')).toBeNull(); + }); + + it('does not move focus when items added without container focus', () => { + expect(host.querySelector('#fz')!.getAttribute('tabindex')).toEqual('-1'); + (host.querySelector('#z') as HTMLElement).focus(); + + ReactDOM.render( +
+ + +
, + host + ); + expect(document.activeElement).toBe(host.querySelector('#z')); + }); + }); + it('can ignore arrowing if default is prevented', () => { const component = ReactTestUtils.renderIntoDocument(
diff --git a/packages/office-ui-fabric-react/src/components/FocusZone/FocusZone.tsx b/packages/office-ui-fabric-react/src/components/FocusZone/FocusZone.tsx index 389a78b26e0f6..98dbfaea4ee33 100644 --- a/packages/office-ui-fabric-react/src/components/FocusZone/FocusZone.tsx +++ b/packages/office-ui-fabric-react/src/components/FocusZone/FocusZone.tsx @@ -8,6 +8,8 @@ import { htmlElementProperties, elementContains, getDocument, + getElementIndexPath, + getFocusableByIndexPath, getId, getNextElement, getNativeProps, @@ -49,12 +51,26 @@ export class FocusZone extends BaseComponent implements IFo private _root = React.createRef(); private _id: string; + /** The most recently focused child element. */ private _activeElement: HTMLElement | null; + + /** + * The index path to the last focused child element. + */ + private _lastIndexPath: number[] | undefined; + + /** + * Flag to define when we've intentionally parked focus on the root element to temporarily + * hold focus until items appear within the zone. + */ + private _isParked: boolean; + /** The child element with tabindex=0. */ private _defaultFocusElement: HTMLElement | null; private _focusAlignment: IPoint; private _isInnerZone: boolean; + private _parkedTabIndex: string | null | undefined; /** Used to allow us to move to next focusable element even when we're focusing on a input element when pressing tab */ private _processingTabKey: boolean; @@ -78,11 +94,14 @@ export class FocusZone extends BaseComponent implements IFo } public componentDidMount(): void { + const { current: root } = this._root; + _allInstances[this._id] = this; - if (this._root.current) { - const windowElement = this._root.current.ownerDocument.defaultView; - let parentElement = getParent(this._root.current, ALLOW_VIRTUAL_ELEMENTS); + if (root) { + const windowElement = root.ownerDocument!.defaultView; + + let parentElement = getParent(root, ALLOW_VIRTUAL_ELEMENTS); while (parentElement && parentElement !== document.body && parentElement.nodeType === 1) { if (isElementFocusZone(parentElement)) { @@ -94,6 +113,7 @@ export class FocusZone extends BaseComponent implements IFo if (!this._isInnerZone) { this._events.on(windowElement, 'keydown', this._onKeyDownCapture, true); + this._events.on(root, 'blur', this._onBlur, true); } // Assign initial tab indexes so that we can set initial focus as appropriate. @@ -106,6 +126,26 @@ export class FocusZone extends BaseComponent implements IFo } } + public componentDidUpdate(): void { + const { current: root } = this._root; + const doc = getDocument(root); + + if (doc && this._lastIndexPath && (doc.activeElement === doc.body || doc.activeElement === root)) { + // The element has been removed after the render, attempt to restore focus. + const elementToFocus = getFocusableByIndexPath(root as HTMLElement, this._lastIndexPath); + + if (elementToFocus) { + this._setActiveElement(elementToFocus, true); + elementToFocus.focus(); + this._setParkedFocus(false); + } else { + // We had a focus path to restore, but now that path is unresolvable. Park focus + // on the container until we can try again. + this._setParkedFocus(true); + } + } + } + public componentWillUnmount() { delete _allInstances[this._id]; } @@ -116,6 +156,13 @@ export class FocusZone extends BaseComponent implements IFo const Tag = this.props.elementType || 'div'; + // Note, right before rendering/reconciling proceeds, we need to record if focus + // was in the zone before the update. This helper will track this and, if focus + // was actually in the zone, what the index path to the element is at this time. + // Then, later in componentDidUpdate, we can evaluate if we need to restore it in + // the case the element was removed. + this._evaluateFocusBeforeRender(); + return ( implements IFo return false; } + private _evaluateFocusBeforeRender(): void { + const { current: root } = this._root; + const doc = getDocument(root); + + if (doc) { + const focusedElement = doc.activeElement as HTMLElement; + + // Only update the index path if we are not parked on the root. + if (focusedElement !== root) { + const shouldRestoreFocus = elementContains(root, focusedElement); + + this._lastIndexPath = shouldRestoreFocus ? getElementIndexPath(root as HTMLElement, doc.activeElement as HTMLElement) : undefined; + } + } + } + private _onFocus = (ev: React.FocusEvent): void => { const { onActiveElementChanged, doNotAllowFocusEventToPropagate, onFocusNotification } = this.props; + const isImmediateDescendant = this._isImmediateDescendantOfZone(ev.target as HTMLElement); + let newActiveElement: HTMLElement | undefined; if (onFocusNotification) { onFocusNotification(); } - if (this._isImmediateDescendantOfZone(ev.target as HTMLElement)) { - this._activeElement = ev.target as HTMLElement; - this._setFocusAlignment(this._activeElement); + if (isImmediateDescendant) { + newActiveElement = ev.target as HTMLElement; } else { let parentElement = ev.target as HTMLElement; while (parentElement && parentElement !== this._root.current) { if (isElementTabbable(parentElement) && this._isImmediateDescendantOfZone(parentElement)) { - this._activeElement = parentElement; + newActiveElement = parentElement; break; } parentElement = getParent(parentElement, ALLOW_VIRTUAL_ELEMENTS) as HTMLElement; } } - if (onActiveElementChanged) { - onActiveElementChanged(this._activeElement as HTMLElement, ev); + if (newActiveElement && newActiveElement !== this._activeElement) { + this._activeElement = newActiveElement; + + if (isImmediateDescendant) { + this._setFocusAlignment(this._activeElement); + } + + if (onActiveElementChanged) { + onActiveElementChanged(this._activeElement as HTMLElement, ev); + } } if (doNotAllowFocusEventToPropagate) { @@ -232,6 +304,41 @@ export class FocusZone extends BaseComponent implements IFo } }; + /** + * When focus is in the zone at render time but then all focusable elements are removed, + * we "park" focus temporarily on the root. Once we update with focusable children, we restore + * focus to the closest path from previous. If the user tabs away from the parked container, + * we restore focusability to the pre-parked state. + */ + private _setParkedFocus(isParked: boolean): void { + const { current: root } = this._root; + + if (root && this._isParked !== isParked) { + this._isParked = isParked; + + if (isParked) { + if (!this.props.allowFocusRoot) { + this._parkedTabIndex = root.getAttribute('tabindex'); + root.setAttribute('tabindex', '-1'); + } + root.focus(); + } else { + if (!this.props.allowFocusRoot) { + if (this._parkedTabIndex) { + root.setAttribute('tabindex', this._parkedTabIndex); + this._parkedTabIndex = undefined; + } else { + root.removeAttribute('tabindex'); + } + } + } + } + } + + private _onBlur() { + this._setParkedFocus(false); + } + /** * Handle global tab presses so that we can patch tabindexes on the fly. */ diff --git a/packages/office-ui-fabric-react/src/components/FocusZone/examples/FocusZone.Photos.Example.tsx b/packages/office-ui-fabric-react/src/components/FocusZone/examples/FocusZone.Photos.Example.tsx index a736b6c883f41..f90a993ad268a 100644 --- a/packages/office-ui-fabric-react/src/components/FocusZone/examples/FocusZone.Photos.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/FocusZone/examples/FocusZone.Photos.Example.tsx @@ -1,38 +1,82 @@ import * as React from 'react'; - -import { createArray } from 'office-ui-fabric-react/lib/Utilities'; import { FocusZone } from 'office-ui-fabric-react/lib/FocusZone'; import { Image } from 'office-ui-fabric-react/lib/Image'; import './FocusZone.Photos.Example.scss'; -const PHOTOS = createArray(25, () => { - const randomWidth = 50 + Math.floor(Math.random() * 150); - - return { - url: `http://placehold.it/${randomWidth}x100`, - width: randomWidth, - height: 100 - }; -}); - -const log = (): void => { - console.log('clicked'); -}; - -export const FocusZonePhotosExample = () => ( - - {PHOTOS.map((photo, index) => ( -
  • - -
  • - ))} -
    -); +const MAX_COUNT = 20; +let _counter = 0; + +export interface IPhoto { + id: number; + url: string; + width: number; + height: number; + onClick: (ev: React.MouseEvent) => void; +} + +export class FocusZonePhotosExample extends React.Component<{}, { items: IPhoto[] }> { + constructor(props: {}) { + super(props); + this.state = { + items: this._getInitialItems() + }; + } + + public render() { + const { items } = this.state; + + return ( + +

    Note: clicking on the items below will remove them, illustrating how FocusZone will retain focus even when items are removed.

    + {items.map((item: IPhoto, index) => ( +
  • + +
  • + ))} +
    + ); + } + + private _getInitialItems(): IPhoto[] { + const items: IPhoto[] = []; + + for (let i = 0; i < MAX_COUNT; i++) { + items.push(this._createItem()); + } + + return items; + } + + private _createItem(): IPhoto { + const randomWidth = 50 + Math.floor(Math.random() * 150); + const id = _counter++; + + return { + id, + url: `http://placehold.it/${randomWidth}x100`, + width: randomWidth, + height: 100, + onClick: (ev: React.MouseEvent) => { + const items: IPhoto[] = this.state.items.filter((item: IPhoto) => item.id !== id); + + this.setState({ items }); + + // If we have run out of items, repopulate in a couple seconds. + if (items.length === 0) { + setTimeout(() => this.setState({ items: this._getInitialItems() }), 2000); + } + + ev.preventDefault(); + ev.stopPropagation(); + } + }; + } +} diff --git a/packages/office-ui-fabric-react/src/components/__snapshots__/FocusZone.Photos.Example.tsx.shot b/packages/office-ui-fabric-react/src/components/__snapshots__/FocusZone.Photos.Example.tsx.shot index 2e5f36e497e0b..0af554c159757 100644 --- a/packages/office-ui-fabric-react/src/components/__snapshots__/FocusZone.Photos.Example.tsx.shot +++ b/packages/office-ui-fabric-react/src/components/__snapshots__/FocusZone.Photos.Example.tsx.shot @@ -9,10 +9,13 @@ exports[`Component Examples renders FocusZone.Photos.Example.tsx correctly 1`] = onMouseDownCapture={[Function]} role="presentation" > +

    + Note: clicking on the items below will remove them, illustrating how FocusZone will retain focus even when items are removed. +

  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • + +
    + + + + + +
    + +
  • +`; diff --git a/packages/utilities/etc/utilities.api.ts b/packages/utilities/etc/utilities.api.ts index f70b6abe97de5..1e5fb4fd9de35 100644 --- a/packages/utilities/etc/utilities.api.ts +++ b/packages/utilities/etc/utilities.api.ts @@ -226,12 +226,18 @@ export function getDistanceBetweenPoints(point1: IPoint, point2: IPoint): number // @public export function getDocument(rootElement?: HTMLElement | null): Document | undefined; +// @public +export function getElementIndexPath(fromElement: HTMLElement, toElement: HTMLElement): number[]; + // @public export function getFirstFocusable(rootElement: HTMLElement, currentElement: HTMLElement, includeElementsInFocusZones?: boolean): HTMLElement | null; // @public export function getFirstTabbable(rootElement: HTMLElement, currentElement: HTMLElement, includeElementsInFocusZones?: boolean): HTMLElement | null; +// @public +export function getFocusableByIndexPath(parent: HTMLElement, path: number[]): HTMLElement | undefined; + // @public export function getId(prefix?: string): string; diff --git a/packages/utilities/src/focus.test.tsx b/packages/utilities/src/focus.test.tsx index c2c67baf9148a..86fc5c3e8fb8f 100644 --- a/packages/utilities/src/focus.test.tsx +++ b/packages/utilities/src/focus.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; /* tslint:enable:no-unused-variable */ import * as ReactDOM from 'react-dom'; import * as ReactTestUtils from 'react-dom/test-utils'; -import { isElementVisible, isElementTabbable, focusAsync } from './focus'; +import { isElementVisible, isElementTabbable, focusAsync, getElementIndexPath, getFocusableByIndexPath } from './focus'; let _hiddenElement: HTMLElement | undefined; let _visibleElement: HTMLElement | undefined; @@ -168,3 +168,88 @@ describe('focusAsync', () => { expect(calledFocus).toEqual(true); }); }); + +describe('getFocusableByIndexPath', () => { + it('can recover a path', () => { + const parent = document.createElement('div'); + + parent.innerHTML = ` +
    +
    +
    +
    +
    +
    +
    + `; + + const child = parent.querySelector('#child') as HTMLElement; + + expect(getFocusableByIndexPath(parent, [0, 2, 1])).toEqual(child); + }); + + it('ignores hidden elements', () => { + const parent = document.createElement('div'); + + parent.innerHTML = ` +
    +
    +
    +
    +
    +
    +
    + `; + + const child = parent.querySelector('#child') as HTMLElement; + + expect(getFocusableByIndexPath(parent, [0, 2, 1])).toEqual(null); + }); + + it('can fallback to a previous element', () => { + const parent = document.createElement('div'); + + parent.innerHTML = ` +
    + +
    + `; + + const child = parent.querySelector('#child') as HTMLElement; + + expect(getFocusableByIndexPath(parent, [0, 0, 0, 0, 0, 0])).toEqual(child); + }); +}); + +describe('getElementIndexPath', () => { + it('can get a path', () => { + const parent = document.createElement('div'); + + parent.innerHTML = ` +
    +
    +
    +
    +
    +
    +
    +
    + `; + + const child = parent.querySelector('#child') as HTMLElement; + + expect(getElementIndexPath(parent, child)).toEqual([0, 2, 1]); + }); + + it('can handle the same element', () => { + const parent = document.createElement('div'); + + expect(getElementIndexPath(parent, parent)).toEqual([]); + }); +}); diff --git a/packages/utilities/src/focus.ts b/packages/utilities/src/focus.ts index e675a040ac913..5790e3af360d0 100644 --- a/packages/utilities/src/focus.ts +++ b/packages/utilities/src/focus.ts @@ -1,6 +1,6 @@ /* tslint:disable:no-string-literal */ -import { elementContainsAttribute, elementContains, getDocument, getWindow } from './dom'; +import { elementContains, elementContainsAttribute, getDocument, getParent, getWindow } from './dom'; const IS_FOCUSABLE_ATTRIBUTE = 'data-is-focusable'; const IS_VISIBLE_ATTRIBUTE = 'data-is-visible'; @@ -464,3 +464,50 @@ export function focusAsync(element: HTMLElement | { focus: () => void } | undefi } } } + +/** + * Finds the closest focusable element via an index path from a parent. See + * `getElementIndexPath` for getting an index path from an element to a child. + */ +export function getFocusableByIndexPath(parent: HTMLElement, path: number[]): HTMLElement | undefined { + let element = parent; + + for (const index of path) { + const nextChild = element.children[Math.min(index, element.children.length - 1)] as HTMLElement; + + if (!nextChild) { + break; + } + element = nextChild; + } + + element = + isElementTabbable(element) && isElementVisible(element) + ? element + : getNextElement(parent, element, true) || getPreviousElement(parent, element)!; + + return element as HTMLElement; +} + +/** + * Finds the element index path from a parent element to a child element. + * + * If you had this node structure: "A has children [B, C] and C has child D", + * the index path from A to D would be [1, 0], or `parent.chidren[1].children[0]`. + */ +export function getElementIndexPath(fromElement: HTMLElement, toElement: HTMLElement): number[] { + const path: number[] = []; + + while (toElement && fromElement && toElement !== fromElement) { + const parent = getParent(toElement, true); + + if (parent === null) { + return []; + } + + path.unshift(Array.prototype.indexOf.call(parent.children, toElement)); + toElement = parent; + } + + return path; +}