UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

306 lines (305 loc) 16.2 kB
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; } }