@sap-ux/ui-components
Version:
SAP UI Components Library
662 lines • 29.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UITreeDropdown = exports.EdgePosition = exports.UIDirectionalHint = void 0;
const react_1 = __importDefault(require("react"));
const uuid_1 = __importDefault(require("uuid"));
const react_2 = require("@fluentui/react");
Object.defineProperty(exports, "UIDirectionalHint", { enumerable: true, get: function () { return react_2.DirectionalHint; } });
const UIInput_1 = require("../UIInput");
const UIContextualMenu_1 = require("../UIContextualMenu");
const UIIconButton_1 = require("../UIButton/UIIconButton");
const Icons_1 = require("../Icons");
const ValidationMessage_1 = require("../../helper/ValidationMessage");
require("./UITreeDropdown.scss");
const SELECTOR_CLASSES = {
callout: 'ui-tree-callout',
scrollArea: 'ms-ContextualMenu-container',
splitButton: 'ms-ContextualMenu-splitMenu'
};
var EdgePosition;
(function (EdgePosition) {
EdgePosition["First"] = "First";
EdgePosition["Last"] = "Last";
})(EdgePosition || (exports.EdgePosition = EdgePosition = {}));
const KEYBOARD_KEYS = {
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
Enter: 'Enter',
Escape: 'Escape'
};
/**
* UITreeDropdown component.
*
* @exports
* @class UIVerticalDivider
* @extends {React.Component<UITreeDropdownProps, UITreeDropdownState>}
*/
class UITreeDropdown extends react_1.default.Component {
/**
* Initializes component properties.
*
* @param {UITreeDropdownProps} props
*/
constructor(props) {
super(props);
this.UITreeDropdownRef = react_1.default.createRef();
this.UITreeDropdownFocusZoneRef = react_1.default.createRef();
this.inputRef = react_1.default.createRef();
this.submenuRefs = {};
// Calculated offset for submenu positions
// It is added because root menu can have scrollbar - in result root menu's items width is smaller than root menu.
// In such case(when scrollbar) submenu is positioned near to expand/hovered item and position is not on the edge of root menu.
// Using offset/margin we can do corrections to position of submenu and place it one the edge of root menu.
this.submenuOffset = 0;
this.lastKeyDown = '';
this.componentDidMount = () => {
if (this.props.items.length) {
this.buildItems(this.props.items);
this.setState({ isDisabled: false });
}
};
this.componentDidUpdate = (prevProps) => {
if (this.props.items.length !== prevProps.items.length) {
this.setState({ isHidden: true });
this.setState({ isDisabled: this.props.items.length ? false : true });
this.buildItems(this.props.items);
}
if (this.props.value !== prevProps.value) {
this.setState({ value: this.props.value });
}
// Calculate size for submenu offset
this.calculateSubmenuOffset();
};
/**
* Map the payload.
*
* @param {ItemsProps[]} items
*/
this.buildItems = (items) => {
if (this.state.items.length !== items.length) {
items = items.map(this.buildSubItems);
const mapedItems = this.mapValuesToContextMenu(items);
this.setState({
originalItems: mapedItems,
items: mapedItems
});
}
};
/**
* Sub items values and style.
*
* @param {ItemsProps} item
* @returns {ItemsProps}
*/
this.buildSubItems = (item) => {
if (item.children?.length) {
item.children = item.children.map((el) => {
const regex = new RegExp(item.value, 'ig');
const value = el.value.search(regex) === -1 ? `${item.value}${this.state.valueSeparator}${el.value}` : el.value;
return {
...el,
split: false,
value
};
});
}
return item;
};
/**
* Map GD payload to ContextMenu payload.
*
* @param {ItemsProps[]} items
* @param {number} level
* @returns {IContextualMenuItem[]}
*/
this.mapValuesToContextMenu = (items, level = 0) => {
return items.map((item) => {
if (item.children?.length) {
item.split = true;
const refId = this.getRefId(item.value, level);
if (!this.submenuRefs[refId]) {
this.submenuRefs[refId] = react_1.default.createRef();
}
item.subMenuProps = {
componentRef: this.submenuRefs[refId],
items: this.mapValuesToContextMenu(item.children, level + 1),
focusZoneProps: {
handleTabKey: react_2.FocusZoneTabbableElements.none,
onFocus: () => {
const openerItem = this.defaultSubmenuFocus?.parent;
if (openerItem && openerItem.item.value === item.value && openerItem.level === level) {
this.focusItemWithValue(this.state.value, this.defaultSubmenuFocus?.parent?.item.subMenuProps?.items);
}
}
}
};
}
return {
...item,
key: item.value,
text: item.label,
className: 'ui-tree-dropdown-list-item',
onClick: () => this.handleSelection(item.value),
onRenderContent: this.handleRenderContent
};
});
};
/**
* Handle the selected value.
*
* @param {string} value
*/
this.handleSelection = (value) => {
this.hasSelected = true;
this.setState({ value: value, valueChanged: false }, () => this.props.onParameterValueChange(value));
};
/**
* Handle the keypress value.
*
* @param {React.KeyboardEvent<HTMLInputElement>} event
*/
this.handleKeyPress = (event) => {
switch (event.key) {
case 'Enter':
if (!this.state.isMenuOpen) {
this.toggleMenu(false, event);
}
else {
this.setState({ valueChanged: true });
this.handleSelection(this.state.value ? this.state.value : '');
}
break;
case 'ArrowDown':
if (!this.state.isMenuOpen) {
// Open dropdown contextMenu if closed
this.toggleMenu(false, event);
}
else {
this.focusDropdown(event, event.key);
}
break;
case 'Tab':
if (this.state.isMenuOpen) {
// Close Dropdown if open
this.toggleMenu(true);
}
this.handleSelection(this.state.value ? this.state.value : '');
break;
default: {
// do nothing
}
}
this.lastKeyDown = event.key;
};
/**
* Handle ContextMenu focus.
*
* @param {React.KeyboardEvent<HTMLInputElement>} event
* @param {string} key
*/
this.focusDropdown = (event, key) => {
if (this.UITreeDropdownFocusZoneRef) {
if (key === KEYBOARD_KEYS.Enter) {
this.focusItemWithValue(this.state.value, this.state.items);
}
else {
this.UITreeDropdownFocusZoneRef.current?.focus(true);
}
// disable scroll which sometimes triggers
event.preventDefault();
}
};
/**
* Custom handle the render from subMenu to control the highlight and the .is-selected.
*
* @param {IContextualMenuListProps} props
* @param {IContextualMenuItemRenderFunctions} defaultRenders
* @returns { React.ReactNode | null}
*/
this.handleRenderContent = (props, defaultRenders) => {
props.item.className = props.item.value === this.props.value ? 'is-selected' : '';
props.item.text = this.highlightQuery(props.item.label, this.state.query);
this.applySubmenuPosition(props.item);
return defaultRenders ? defaultRenders.renderItemName(props) : null;
};
/**
* Custom handle the render to control the highlight and the .is-selected.
*
* @param {IContextualMenuListProps} props
* @param {IRenderFunction<IContextualMenuListProps>} defaultRender
* @returns {JSX.Element | null}
*/
this.handleRenderMenuList = (props, defaultRender) => {
let mappedItems = [];
if (props?.items) {
mappedItems = props.items.map((item) => {
item.className = item.value === this.props.value ? 'is-selected' : '';
item.text = this.highlightQuery(item.label, this.state.query);
this.applySubmenuPosition(item);
item.subMenuProps?.items.map((subItem) => {
subItem.className = subItem.value === this.props.value ? 'is-selected' : '';
return subItem;
});
return { ...item };
});
}
return defaultRender ? defaultRender({ ...props, items: mappedItems }) : null;
};
/**
* Handle on/off ContextualMenu.
*
* @param {boolean} status
* @param {React.KeyboardEvent<HTMLInputElement>} event
*/
this.toggleMenu = (status, event) => {
if (this.props.readOnly) {
return;
}
this.setState({
isHidden: status
});
const key = event?.key;
//select first item after contextMenu is opened
if (event) {
setTimeout(() => {
event.persist();
this.focusDropdown(event, key);
}, 0);
}
};
/**
* Highlight the search string.
*
* @param {string} text
* @param {string} query
* @returns {JSX.Element}
*/
this.highlightQuery = (text, query) => {
return react_1.default.createElement(UIContextualMenu_1.UIHighlightMenuOption, { text: text, query: query });
};
/**
* Filter all options that match the query string.
*
* @param {string} input
* @param {IContextualMenuItem} item
* @returns {boolean}
*/
this.filterElement = (input, item) => {
const regex = new RegExp(input, 'ig');
if (item?.children?.length) {
return item.children.filter((item) => this.filterElement(input, item)).length > 0;
}
if (item?.value) {
return item.value.search(regex) !== -1;
}
return false;
};
/**
* Update the query string and the prop value.
*
* @param {React.FormEvent<HTMLInputElement | HTMLTextAreaElement>} event
*/
this.handleOnChangeValue = (event) => {
const query = event.target;
this.hasSelected = false;
this.setState((prevState) => ({
value: query.value,
items: prevState.originalItems.filter((item) => this.filterElement(query.value, item)),
query: query.value,
valueChanged: true
}));
if (!this.state.isMenuOpen) {
this.toggleMenu(false);
}
};
/**
* Method reset states.
*
* @param {Event | React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent} event
*/
this.handleDismiss = (event) => {
if (event && 'key' in event && event.key === KEYBOARD_KEYS.Escape) {
this.resetValue();
}
else if (!this.hasSelected) {
this.props.onParameterValueChange('');
}
this.setState((prevState) => ({
items: prevState.originalItems,
query: ''
}));
this.toggleMenu(true);
this.originalValue = undefined;
};
/**
* Method applies additional styling for submnu callout.
* It is used to apply scroll width offset - in result submenu should be displayed on the edge of root menu.
*
* @param {IContextualMenuItem} item Context menu item.
*/
this.applySubmenuPosition = (item) => {
if (item.subMenuProps?.items) {
if (!item.subMenuProps.calloutProps) {
item.subMenuProps.calloutProps = {};
}
item.subMenuProps.calloutProps.styles = {
root: {
marginLeft: this.submenuOffset
}
};
}
};
this.getCalloutDomRef = (submenu = false) => {
const menuLayerClass = `${SELECTOR_CLASSES.callout}${this.state.uiidInput}`;
const callout = document.querySelector(`.${menuLayerClass}`);
return submenu && callout ? callout.nextSibling : callout;
};
/**
* Method calculates offset size for submenus.
* Calculated offset should be used to position submenu right to edge of root menu.
* - Detects if scrollbar exists.
* - Calculates size of scrollbar and stores it as value for offset.
*/
this.calculateSubmenuOffset = () => {
const callout = this.getCalloutDomRef();
if (callout) {
const scrollContainer = callout.querySelector(`.${SELECTOR_CLASSES.scrollArea}`);
this.submenuOffset = 0;
if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) {
this.submenuOffset = scrollContainer.offsetWidth - scrollContainer.clientWidth;
}
}
};
/**
* Method updates state, if focus visible, using arrow keys.
*
* @param {HTMLElement|React.FocusEvent<HTMLElement>} ev
*/
this.onFocusElementChanged = (ev) => {
const menuOption = ev.getElementsByClassName('ts-Menu-option');
const isFocusVisible = document.getElementsByClassName('ms-Fabric--isFocusVisible');
if (isFocusVisible.length > 0 && menuOption.length > 0) {
this.setState({
value: ev.value ? ev.value : menuOption[0].innerText,
valueChanged: true
});
}
};
/**
* Method handles window keydown event.
* 1. Stores last keyboard pressed event.
* 2. Disables CircularNavigation for menus.
*
* @param {KeyboardEvent | React.KeyboardEvent<HTMLInputElement>} event
*/
this.onWindowKeyDown = (event) => {
this.lastKeyDown = event.key;
// Avoid circular navigation
const activeElement = document.activeElement;
if ([KEYBOARD_KEYS.ArrowDown, KEYBOARD_KEYS.ArrowUp].includes(event.key) && activeElement) {
// Disable CircularNavigation
// There is property in focusZoneProps, but it is overwritten by fluent ui and we can not change it from outside
const positions = this.getEdgePosition(activeElement);
const fromFirst = positions.includes(EdgePosition.First) && event.key === KEYBOARD_KEYS.ArrowUp;
const fromLast = positions.includes(EdgePosition.Last) && event.key === KEYBOARD_KEYS.ArrowDown;
if (fromFirst || fromLast) {
// Circular navigation case.
// Check if first item focused in root menu.
if (fromFirst && activeElement.closest(`.${SELECTOR_CLASSES.callout}${this.state.uiidInput}`)) {
// Focus input field if navigation triggered from first item using ArrowUp
this.inputRef.current?.focus();
}
event.stopPropagation();
event.preventDefault();
}
}
};
/**
* Method handles focus logic if arrow key was pressed.
*
* @param {FocusEvent} event
*/
this.handleCustomDownKey = (event) => {
if (this.lastKeyDown.includes('Arrow')) {
this.lastKeyDown = '';
this.onFocusElementChanged(event.target);
}
};
/**
* Method appends custom keydown and focus event listeners when context menu is opened.
*/
this.applyCustomKeyDownHandlingEvents = () => {
window.addEventListener('keydown', this.onWindowKeyDown, true);
window.addEventListener('focus', this.handleCustomDownKey, true);
};
/**
* Method removes custom keydown and focus event listeners when context menu is dismissed.
*/
this.removeCustomKeyDownHandlingEvents = () => {
window.removeEventListener('keydown', this.onWindowKeyDown, true);
window.removeEventListener('focus', this.handleCustomDownKey, true);
};
/**
* Method receives any menu child element and returns edge positions if item is first or last in rendered menu.
*
* @param {Element} itemElement Item's DOM to check position.
* @returns {EdgePosition[]} Returns positions if element is first or last in menu - also can be both.
*/
this.getEdgePosition = (itemElement) => {
const container = itemElement.closest('ul');
const item = itemElement.closest('li');
const position = [];
if (container && item) {
if (container.children[0] === item) {
position.push(EdgePosition.First);
}
if (container.children[container.children.length - 1] === item) {
position.push(EdgePosition.Last);
}
}
return position;
};
/**
* Recursive method finds menu item info object in tree menu items by passed value/key of item.
*
* @param {string} [value] Value/key of item.
* @param {IContextualMenuItem[]} [items] Menu items.
* @param {TreeItemInfo} [parent] Item's parent object.
* @param {number} [level] Level of item in tree structure.
* @returns {TreeItemInfo | undefined} Found menu item.
*/
this.findItemByValue = (value, items = [], parent, level = 0) => {
let selectedItem;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const selectedItemTemp = {
item,
index: i,
parent,
level
};
if (item.value === value) {
selectedItem = selectedItemTemp;
}
else if (item.subMenuProps?.items?.length) {
selectedItem = this.findItemByValue(value, item.subMenuProps.items, selectedItemTemp, level + 1);
}
if (selectedItem) {
break;
}
}
return selectedItem;
};
/**
* Method finds DOM node of menu item based on received item object and container DOM.
*
* @param {HTMLElement} container Menu container DOM.
* @param {TreeItemInfo} item Menu item info object.
* @returns {HTMLElement | undefined} Found DOM element of item.
*/
this.getItemTarget = (container, item) => {
let itemDom;
const listDom = container.querySelector('.ms-ContextualMenu-list');
if (listDom?.childNodes[item.index]) {
const listItemDom = listDom.childNodes[item.index];
const itemElement = listItemDom.firstChild;
itemDom = itemElement;
}
return itemDom;
};
/**
* Method focuses context menu item based on recieved value/key and menude data(items and hoisted object).
* Method works with any level menu.
*
* @param {string} [value] Value/key of item.
* @param {IContextualMenuItem[]} [items] Target menu items.
*/
this.focusItemWithValue = (value, items = []) => {
const selectedItem = this.findItemByValue(value, items);
const callout = this.getCalloutDomRef(!!this.defaultSubmenuFocus);
this.defaultSubmenuFocus = undefined;
if (selectedItem && callout) {
let itemDom;
if (selectedItem.parent) {
// Item is in next level of menu - we need open submenu
const parentItemDom = this.getItemTarget(callout, selectedItem.parent);
if (parentItemDom) {
this.defaultSubmenuFocus = selectedItem;
parentItemDom.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 39, which: 39, bubbles: true }));
}
}
else {
// Item is in first level
itemDom = this.getItemTarget(callout, selectedItem);
}
// Focus target item or focus container while submenu is not opened
if (itemDom) {
itemDom.focus();
}
else {
const menuContainer = callout.querySelector(`.${SELECTOR_CLASSES.scrollArea}`);
menuContainer?.focus();
}
}
};
/**
* Generate unique id for menu component references.
*
* @param {string} value Value of item.
* @param {number} level Level of item in tree structure.
* @returns {string} Id containing value andf level in format "${value}__${level}".
*/
this.getRefId = (value, level) => {
return `${value}__${level}`;
};
this.state = {
query: '',
// value has to be set, otherwise react treats this as "uncontrolled" component
// and displays warnings when value is set later on
value: this.props.value ?? '',
isHidden: true,
originalItems: [],
items: [],
valueSeparator: this.props.valueSeparator ?? '.',
uiidInput: uuid_1.default.v4(),
isDisabled: !this.props.items.length,
isMenuOpen: false,
valueChanged: false
};
this.hasSelected = !!this.props.value;
this.toggleMenu = this.toggleMenu.bind(this);
this.onWindowKeyDown = this.onWindowKeyDown.bind(this);
this.handleCustomDownKey = this.handleCustomDownKey.bind(this);
// Suppress icon warnings, as they are irrelevant
(0, react_2.setIconOptions)({ disableWarnings: true });
}
/**
* Method resets value of dropdown input to original value, which was stored after open.
*/
resetValue() {
this.setState({
value: this.originalValue,
valueChanged: false
});
}
/**
* Method returns class names for wrapper element depending on props and component state.
*
* @returns {string} Class names of wrapper element.
*/
getClassNames() {
let classNames = `ui-treeDropdown-wrapper ui-treeDropdown-wrapper-menu${this.state.isMenuOpen ? '-open' : '-close'} ui-treeDropdown-wrapper-${this.state.uiidInput}`;
if (this.state.isDisabled) {
classNames += ' disabled';
}
if (this.props.readOnly) {
classNames += ' readonly';
}
return classNames;
}
/**
* @returns {JSX.Element}
*/
render() {
const messageInfo = (0, ValidationMessage_1.getMessageInfo)(this.props);
let useTargetWidth = true;
if (this.props.useTargetWidth) {
useTargetWidth = false;
}
return (react_1.default.createElement("div", { className: `ui-treeDropdown ui-treeDropDown-${this.state.uiidInput} ${this.props.label ? 'ui-treeDropdown-with-label' : ''}` },
this.props.label && (react_1.default.createElement("label", { className: `${this.props.required ? 'required' : ''} ${this.state.isDisabled ? 'disabled' : ''}` }, this.props.label)),
react_1.default.createElement("div", { className: this.getClassNames() },
react_1.default.createElement(UIInput_1.UITextInput, { ...this.props, componentRef: this.inputRef, disabled: this.state.isDisabled, readOnly: this.props.readOnly, autoComplete: "off", value: this.state.value, placeholder: this.props.placeholderText, onKeyDown: this.handleKeyPress, onChange: this.handleOnChangeValue, onClick: () => {
this.toggleMenu(false);
}, onFocus: (event) => {
// Select the text of the input
event.target.select();
}, errorMessage: messageInfo.message }),
react_1.default.createElement(UIIconButton_1.UIIconButton, { tabIndex: -1, allowDisabledFocus: true, className: "ui-treeDropdown-caret", iconProps: { iconName: Icons_1.UiIcons.ArrowDown }, onClick: () => {
if (this.state.isHidden) {
// Menu would become visible - focus input
this.inputRef.current?.focus();
}
this.toggleMenu(!this.state.isHidden);
} })),
!this.state.isHidden && (react_1.default.createElement(UIContextualMenu_1.UIContextualMenu, { componentRef: this.UITreeDropdownRef, onRenderMenuList: this.handleRenderMenuList, className: "ui-treeDropDown-context-menu", target: `.ui-treeDropDown-${this.state.uiidInput}`, onMenuOpened: () => {
this.originalValue = this.state.value;
this.applyCustomKeyDownHandlingEvents();
this.setState({
isMenuOpen: true
});
}, onMenuDismissed: () => {
this.removeCustomKeyDownHandlingEvents();
this.setState({ isMenuOpen: false });
if (this.state.valueChanged) {
this.handleSelection(this.state.value ? this.state.value : '');
}
}, useTargetWidth: useTargetWidth, useTargetAsMinWidth: true, onRestoreFocus: (params) => {
params.originalElement?.focus();
}, shouldUpdateWhenHidden: true, items: this.state.items, onDismiss: this.handleDismiss, shouldFocusOnContainer: false, focusZoneProps: {
componentRef: this.UITreeDropdownFocusZoneRef,
handleTabKey: react_2.FocusZoneTabbableElements.none,
isCircularNavigation: false
}, shouldFocusOnMount: false, directionalHint: this.props.directionalHint, calloutProps: {
layerProps: {
className: `${SELECTOR_CLASSES.callout}${this.state.uiidInput}`
},
onLayerMounted: () => {
this.calculateSubmenuOffset();
}
}, styles: {
container: {
maxHeight: 192,
overflowY: 'auto'
}
}, maxWidth: this.props.maxWidth }))));
}
}
exports.UITreeDropdown = UITreeDropdown;
//# sourceMappingURL=UITreeDropdown.js.map