azure-devops-ui
Version:
React components for building web UI in Azure DevOps
306 lines (305 loc) • 16.2 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import * as React from "react";
import { FocusGroup } from '../../FocusGroup';
import { ElementRelationship, getRelationship, KeyCode, shimRef } from '../../Util';
import { FocusZoneDirection, FocusZoneKeyStroke } from "./FocusZone.Props";
// The FocusZoneContext carries the identifier for the current FocusZone.
export const FocusZoneContext = React.createContext({ direction: undefined, focuszoneId: undefined });
// As an event propagates through the hierarchy of focus zones it may
// be marked as ignored. This allows a child focus zone to mark an event
// as "pass-through" for all of its parents.
let ignoreEvent = false;
// An internal identifier used to created unique focuszoneId's.
let focuszoneId = 1;
export class FocusZone extends React.Component {
constructor(props) {
super(props);
this.rootElements = [];
this.state = {
focuszoneId: "focuszone-" + focuszoneId++
};
}
render() {
// We need to shim the KeyDown event on each of the children. This allows us to capture
// the event and process it for focus changes.
let content = (React.createElement(FocusZoneContext.Consumer, null, (parentContext) => (React.createElement(FocusZoneContext.Provider, { value: { direction: this.props.direction, focuszoneId: this.state.focuszoneId } }, React.Children.map(this.props.children, (child, index) => {
if (child === null || typeof child === "string" || typeof child === "number") {
return child;
}
// All direct children MUST be DOM elements.
if (typeof child.type !== "string") {
throw Error("Children of a focus zone MUST be DOM elements");
}
// Save the supplied keydown event handler so we can forward the event to it.
const existingOnKeyDown = child.props.onKeyDown;
const existingOnFocus = child.props.onFocus;
// Save the component reference for this element, either the one from the original
// component or the one we added.
this.rootElements[index] = shimRef(child);
return React.cloneElement(child, Object.assign(Object.assign({ key: index }, child.props), { ref: this.rootElements[index], onFocus: (event) => {
var _a;
if (existingOnFocus) {
existingOnFocus(event);
}
const focusCurrent = document.activeElement;
for (let index = 0; index < this.rootElements.length; index++) {
const rootElement = (_a = this.rootElements[index]) === null || _a === void 0 ? void 0 : _a.current;
if (rootElement && (rootElement.contains(focusCurrent) || rootElement === focusCurrent)) {
this.lastFocusElement = event.target;
}
}
}, onKeyDown: (event) => {
let ignoreKeystroke = FocusZoneKeyStroke.IgnoreNone;
if (existingOnKeyDown) {
existingOnKeyDown(event);
}
// Determine whether or not this focuszone wants to preprocess this keystroke
// and mark the current propagation as ignored.
if (!ignoreEvent && this.props.preprocessKeyStroke) {
ignoreKeystroke = this.props.preprocessKeyStroke(event);
if (ignoreKeystroke === FocusZoneKeyStroke.IgnoreAll) {
ignoreEvent = true;
}
}
if (!ignoreEvent) {
if (!event.defaultPrevented && !this.props.disabled) {
const nodeName = event.target.nodeName;
let offset;
// Logic to handle input / text area tags
let inputPosition;
let inputLength;
if (nodeName === "INPUT" || nodeName === "TEXTAREA") {
const input = event.target;
try {
inputPosition = typeof input.selectionStart === "number" ? input.selectionStart : undefined;
}
catch (_a) {
// Microsoft Edge throws InvalidStateError when calling 'input.selectionStart' on non-supported input element types
// according to https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement
// Ignore this error
}
inputLength = input.value.length;
}
const allowLeftArrow = inputPosition === undefined || (inputPosition === 0 && this.props.allowArrowOutOfInputs);
const allowRightArrow = inputPosition === undefined ||
inputLength === undefined ||
(inputPosition === inputLength && this.props.allowArrowOutOfInputs);
switch (event.which) {
case KeyCode.upArrow:
if (nodeName !== "TEXTAREA") {
if (this.props.direction === FocusZoneDirection.Vertical) {
offset = -1;
}
}
break;
case KeyCode.downArrow:
if (nodeName !== "TEXTAREA") {
if (this.props.direction === FocusZoneDirection.Vertical) {
offset = 1;
}
}
break;
case KeyCode.rightArrow:
if (allowRightArrow) {
if (this.props.direction === FocusZoneDirection.Horizontal) {
offset = 1;
}
}
break;
case KeyCode.leftArrow:
if (allowLeftArrow) {
if (this.props.direction === FocusZoneDirection.Horizontal) {
offset = -1;
}
}
break;
case KeyCode.tab:
if (this.props.handleTabKey) {
offset = event.shiftKey ? -1 : 1;
}
break;
case KeyCode.enter:
if (this.props.activateOnEnter) {
event.target.click();
}
}
if (offset) {
if (this.focusNextElement(event, offset)) {
event.preventDefault();
}
}
}
}
if (ignoreKeystroke === FocusZoneKeyStroke.IgnoreParents) {
ignoreEvent = true;
}
// Perform any supplied event post processing.
if (!ignoreEvent && this.props.postprocessKeyStroke) {
if (this.props.postprocessKeyStroke(event) === FocusZoneKeyStroke.IgnoreParents) {
ignoreEvent = true;
}
}
// Once we reach the root focuszone we need to clear the ignoredEvent.
if (!parentContext.focuszoneId) {
ignoreEvent = false;
}
} }));
})))));
if (this.props.focusGroupProps) {
content = React.createElement(FocusGroup, Object.assign({}, this.props.focusGroupProps), content);
}
return content;
}
componentDidMount() {
let focusElement;
// If a defaultActiveElement is supplied we will focus it. It is not required to
// be member of the focus zone, it can be any element.
if (this.props.focusOnMount) {
const { defaultActiveElement } = this.props;
const focusElements = this.getFocusElements(typeof defaultActiveElement === "function" ? defaultActiveElement() : defaultActiveElement);
if (focusElements.length > 0) {
focusElement = focusElements[0];
}
}
if (focusElement) {
focusElement.focus({
preventScroll: this.props.preventScrollOnFocus
});
}
}
focusNextElement(event, offset) {
const focusElements = this.getFocusElements();
if (focusElements.length > 0) {
const focusCurrent = document.activeElement;
const rootElements = this.rootElements;
// Determine if an element in the focus zone has focus.
let focusIndex = focusElements.indexOf(focusCurrent);
// Focus may not be on an element in the zone so we need to
// figure out which one we are between in this case.
if (focusIndex === -1) {
let index = 0;
// Determine if the element is in a portal or directly within a focuszone root.
for (index = 0; index < rootElements.length; index++) {
const elementRef = rootElements[index];
if (elementRef.current) {
if (elementRef.current.contains(event.target)) {
break;
}
}
}
// If this is coming from a portal, we will use the element that last had focus.
if (index === this.rootElements.length && this.lastFocusElement) {
focusIndex = focusElements.indexOf(this.lastFocusElement);
}
else {
for (index = 0; index < focusElements.length; index++) {
const relationship = getRelationship(focusCurrent, focusElements[index]);
if (relationship === ElementRelationship.Before) {
focusIndex = index - (offset > 0 ? 1 : 0);
break;
}
else if (relationship === ElementRelationship.Child) {
focusIndex = index;
break;
}
else if (relationship === ElementRelationship.After && index === focusElements.length - 1) {
focusIndex = focusElements.length;
}
}
}
}
// Move to the next component in the set of focus zone components.
focusIndex += offset;
// If the FocusZone supports circular navigation and we are on the end
// we will move to the element on the opposite end.
if (this.props.circularNavigation) {
if (focusIndex < 0) {
focusIndex = focusElements.length - 1;
}
else if (focusIndex >= focusElements.length) {
focusIndex = 0;
}
}
// If we ended up on a focusable element update the focus.
if (focusIndex > -1 && focusIndex < focusElements.length) {
focusElements[focusIndex].focus();
if (this.props.selectInputTextOnFocus && focusElements[focusIndex] instanceof HTMLInputElement) {
focusElements[focusIndex].select();
}
return true;
}
}
return false;
}
getFocusElements(customSelector) {
const focusElements = [];
let selector = customSelector;
// If a custom selector was supplied we will use it.
if (!selector) {
// The default selector will just pick up items tagged with this focuszone id.
selector = "[data-focuszone~=" + this.state.focuszoneId + "]";
// If we are including the default elements from the DOM we will add the
// default selector to our list of selectors.
if (this.props.includeDefaults) {
selector += ",a[href],button,iframe,input,select,textarea,[tabIndex]";
}
}
// Filter the elements that matched our query to the elements that are elligible
// for receiving focus in this focuszone.
for (const rootElement of this.rootElements) {
if (rootElement.current) {
const focusChildren = rootElement.current.querySelectorAll(selector);
// Check if the root element matches our selector.
if (rootElement.current.matches(selector) && this.isFocusElement(rootElement.current, customSelector)) {
focusElements.push(rootElement.current);
}
// Check all the children of the root that are potential focus elements.
for (let rootIndex = 0; rootIndex < focusChildren.length; rootIndex++) {
const element = focusChildren[rootIndex];
if (this.isFocusElement(element, customSelector)) {
focusElements.push(element);
}
}
}
}
return focusElements;
}
/**
* isFocusElement is used to determine whether or not an element should participate
* in this focus zone.
*
* @param element HTML Element that you are testing as a valid focus element.
*
* @param customSelector A custom selector that is used to match elements with
* negative tabIndex. These wont match normally unless targetted by the custom
* selector.
*/
isFocusElement(element, customSelector) {
// Filter out elements that are disabled.
if (element.hasAttribute("disabled")) {
return false;
}
if (!customSelector) {
// Filter out elements that are not visible.
if (!this.props.skipHiddenCheck) {
const style = window.getComputedStyle(element);
if (style.visibility === "hidden" ||
style.display === "none" ||
!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)) {
return false;
}
}
// Filter out elements with negative tabIndex that aren't
// explicity marked for this focuszone.
const tabIndex = element.getAttribute("tabindex");
if (tabIndex && parseInt(tabIndex) < 0) {
const focuszoneId = element.getAttribute("data-focuszone");
if (!focuszoneId || focuszoneId.indexOf(this.state.focuszoneId) < 0) {
return false;
}
}
}
return true;
}
}