UNPKG

@sap-ux/ui-components

Version:

SAP UI Components Library

655 lines 28.4 kB
import React from 'react'; import { v4 as uuidv4 } from 'uuid'; import { DirectionalHint, FocusZoneTabbableElements, setIconOptions } from '@fluentui/react'; import { UITextInput } from '../UIInput/index.js'; import { UIContextualMenu, UIHighlightMenuOption } from '../UIContextualMenu/index.js'; import { UIIconButton } from '../UIButton/UIIconButton.js'; import { UiIcons } from '../Icons.js'; import { getMessageInfo } from '../../helper/ValidationMessage/index.js'; import './UITreeDropdown.scss'; export { DirectionalHint as UIDirectionalHint }; const SELECTOR_CLASSES = { callout: 'ui-tree-callout', scrollArea: 'ms-ContextualMenu-container', splitButton: 'ms-ContextualMenu-splitMenu' }; export var EdgePosition; (function (EdgePosition) { EdgePosition["First"] = "First"; EdgePosition["Last"] = "Last"; })(EdgePosition || (EdgePosition = {})); const KEYBOARD_KEYS = { ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', Enter: 'Enter', Escape: 'Escape' }; /** * UITreeDropdown component. * * @exports * @class UIVerticalDivider * @extends {React.Component<UITreeDropdownProps, UITreeDropdownState>} */ export class UITreeDropdown extends React.Component { /** * Initializes component properties. * * @param {UITreeDropdownProps} props */ constructor(props) { super(props); this.UITreeDropdownRef = React.createRef(); this.UITreeDropdownFocusZoneRef = React.createRef(); this.inputRef = React.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.createRef(); } item.subMenuProps = { componentRef: this.submenuRefs[refId], items: this.mapValuesToContextMenu(item.children, level + 1), focusZoneProps: { handleTabKey: FocusZoneTabbableElements.none, onFocus: () => { const openerItem = this.defaultSubmenuFocus?.parent; if (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.createElement(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: uuidv4(), 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 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 = getMessageInfo(this.props); let useTargetWidth = true; if (this.props.useTargetWidth) { useTargetWidth = false; } return (React.createElement("div", { className: `ui-treeDropdown ui-treeDropDown-${this.state.uiidInput} ${this.props.label ? 'ui-treeDropdown-with-label' : ''}` }, this.props.label && (React.createElement("label", { className: `${this.props.required ? 'required' : ''} ${this.state.isDisabled ? 'disabled' : ''}` }, this.props.label)), React.createElement("div", { className: this.getClassNames() }, React.createElement(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.createElement(UIIconButton, { tabIndex: -1, allowDisabledFocus: true, className: "ui-treeDropdown-caret", iconProps: { iconName: 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.createElement(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: 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 })))); } } //# sourceMappingURL=UITreeDropdown.js.map