/** @jsxImportSource react */ import { Widget, VDOM } from "../../ui/Widget"; import { Cx } from "../../ui/Cx"; import { HtmlElement, HtmlElementConfigBase, HtmlElementInstance } from "../HtmlElement"; import { Instance } from "../../ui/Instance"; import { RenderingContext } from "../../ui/RenderingContext"; import { findFirstChild, isFocusable, isSelfOrDescendant, closest, isFocusedDeep, isFocused } from "../../util/DOM"; import { Dropdown, DropdownConfig } from "../overlay/Dropdown"; import { FocusManager, oneFocusOut, offFocusOut } from "../../ui/FocusManager"; import { debug, menuFlag } from "../../util/Debug"; import DropdownIcon from "../icons/drop-down"; import { Icon } from "../Icon"; import { Localization } from "../../ui/Localization"; import { KeyCode } from "../../util/KeyCode"; import { registerKeyboardShortcut, KeyboardShortcut } from "../../ui/keyboardShortcuts"; import { getActiveElement } from "../../util/getActiveElement"; import { tooltipMouseLeave, tooltipMouseMove, tooltipParentWillUnmount, tooltipParentDidMount, } from "../overlay/tooltip-ops"; import { yesNo } from "../overlay/alerts"; import { isTextInputElement, stopPropagation } from "../../util"; import { unfocusElement } from "../../ui/FocusManager"; import { BooleanProp, Prop, StringProp } from "../../ui/Prop"; import { Config } from "../../ui/Prop"; /* Functionality: - renders dropdown when focused - tracks focus and closes if focusElement goes outside the dropdown - switches focus to the dropdown when right key pressed - listens to dropdown's key events and captures focus back when needed - automatically opens the dropdown if mouse is held over for a period of time */ export interface MenuItemConfig extends HtmlElementConfigBase { baseClass?: string; hoverFocusTimeout?: number; clickToOpen?: boolean; hoverToOpen?: boolean; horizontal?: boolean; arrow?: BooleanProp; dropdownOptions?: Partial; showCursor?: boolean; pad?: boolean; placement?: string; placementOrder?: string; autoClose?: boolean; icons?: boolean; icon?: StringProp; keyboardShortcut?: KeyboardShortcut | false; tooltip?: string | Config; openOnFocus?: boolean; disabled?: BooleanProp; checked?: BooleanProp; confirm?: Prop; checkedIcon?: string; uncheckedIcon?: string; padding?: string; hideCursor?: boolean; dropdown?: any; onClick?: string | ((e: React.MouseEvent | null, instance: HtmlElementInstance) => void); onMouseDown?: string | ((e: React.MouseEvent, instance: HtmlElementInstance) => void); } export class MenuItemInstance extends HtmlElementInstance { declare horizontal?: boolean; declare padding?: string; declare icons?: boolean; declare parentPositionChangeEvent?: any; } export class MenuItem extends HtmlElement { declare public baseClass: string; declare public hoverFocusTimeout: number; declare public clickToOpen: boolean; declare public hoverToOpen: boolean; declare public horizontal: boolean; declare public arrow: BooleanProp; declare public dropdownOptions: Partial | null; declare public showCursor: boolean; declare public pad: boolean; declare public placement: string | null; declare public placementOrder: string | null; declare public autoClose: boolean; declare public checkedIcon: string; declare public uncheckedIcon: string; declare public keyboardShortcut: KeyboardShortcut | false; declare public openOnFocus: boolean; declare public hideCursor?: boolean; declare public checked?: BooleanProp; declare public padding?: string; declare public dropdown?: any; init() { if (this.hideCursor) this.showCursor = false; super.init(); } declareData() { super.declareData(...arguments, { icon: undefined, disabled: undefined, checked: false, arrow: undefined, confirm: undefined, }); } explore(context: RenderingContext, instance: MenuItemInstance) { instance.horizontal = this.horizontal; let { lastMenu } = context; if (lastMenu) { instance.horizontal = lastMenu.horizontal; instance.padding = lastMenu.itemPadding; instance.icons = lastMenu.icons; } instance.parentPositionChangeEvent = context.parentPositionChangeEvent; if (!instance.padding && this.pad == true) instance.padding = "medium"; if (this.padding) instance.padding = this.padding; context.push("lastMenuItem", this); super.explore(context, instance); } exploreCleanup(context: RenderingContext, instance: MenuItemInstance) { context.pop("lastMenuItem"); } render(context: RenderingContext, instance: MenuItemInstance, key: string) { return ( {instance.data.text ? {instance.data.text} : this.renderChildren(context, instance)} ); } add(element: any) { if (element && typeof element == "object" && element.putInto == "dropdown") { this.dropdown = { ...element }; delete this.dropdown.putInto; } else super.add(...arguments); } addText(text: any) { this.add({ type: HtmlElement, tag: "span", text: text, }); } } MenuItem.prototype.baseClass = "menuitem"; MenuItem.prototype.hoverFocusTimeout = 500; MenuItem.prototype.hoverToOpen = false; MenuItem.prototype.clickToOpen = false; MenuItem.prototype.horizontal = true; MenuItem.prototype.arrow = false; MenuItem.prototype.dropdownOptions = null; MenuItem.prototype.showCursor = true; MenuItem.prototype.pad = true; MenuItem.prototype.placement = null; //default dropdown placement MenuItem.prototype.placementOrder = null; //allowed menu placements MenuItem.prototype.autoClose = false; MenuItem.prototype.checkedIcon = "check"; MenuItem.prototype.uncheckedIcon = "dummy"; MenuItem.prototype.keyboardShortcut = false; MenuItem.prototype.openOnFocus = true; Widget.alias("submenu", MenuItem); Localization.registerPrototype("cx/widgets/MenuItem", MenuItem); interface MenuItemComponentProps { instance: MenuItemInstance; data: any; children?: any; } interface MenuItemComponentState { dropdownOpen: boolean; } class MenuItemComponent extends VDOM.Component { declare dropdown?: Widget; declare el?: HTMLElement; validateDropdownPosition?: () => void; unregisterKeyboardShortcut?: () => void; declare autoFocusTimerId?: number; declare initialScreenPosition?: any; offParentPositionChange?: () => void; constructor(props: MenuItemComponentProps) { super(props); this.state = { dropdownOpen: false, }; } getDefaultPlacementOrder(horizontal?: boolean) { return horizontal ? "down-right down down-left up-right up up-left" : "right-down right right-up left-down left left-up"; } getDropdown() { let { horizontal, widget, parentPositionChangeEvent } = this.props.instance; if (!this.dropdown && widget.dropdown) { this.dropdown = Widget.create(Dropdown, { matchWidth: false, placementOrder: widget.placementOrder || this.getDefaultPlacementOrder(horizontal), trackScroll: true, inline: true, onClick: stopPropagation, ...widget.dropdownOptions, relatedElement: this.el!.parentElement, placement: widget.placement, onKeyDown: this.onDropdownKeyDown.bind(this), onMouseDown: stopPropagation, items: widget.dropdown, parentPositionChangeEvent, pipeValidateDropdownPosition: (cb: any) => { this.validateDropdownPosition = cb; }, onDismissAfterScroll: () => { this.closeDropdown(); return false; }, }); } return this.dropdown; } render() { let { instance, data, children } = this.props; let { widget } = instance; let { CSS, baseClass } = widget; let dropdown = this.state.dropdownOpen && ( ); let arrow = data.arrow && ; let icon = null; let checkbox = widget.checked != null; if (checkbox) { data.icon = data.checked ? widget.checkedIcon : widget.uncheckedIcon; } if (data.icon) { icon = (
{ e.preventDefault(); if (!instance.set("checked", !data.checked)) this.onClick(e); }} onMouseDown={(e) => { if (checkbox) e.stopPropagation(); }} > {Icon.render(data.icon, { className: CSS.element(baseClass, "icon") })}
); } let empty = !children || (Array.isArray(children) && children.length == 0); let classNames = CSS.expand( data.classNames, CSS.state({ open: this.state.dropdownOpen, horizontal: instance.horizontal, vertical: !instance.horizontal, arrow: data.arrow, cursor: widget.showCursor, [instance.padding + "-padding"]: instance.padding, icon: !!icon || instance.icons, disabled: data.disabled, empty, }), ); if (empty) children =  ; return (
{ this.el = el; }} onKeyDown={this.onKeyDown.bind(this)} onMouseDown={this.onMouseDown.bind(this)} onMouseEnter={this.onMouseEnter.bind(this)} onMouseLeave={this.onMouseLeave.bind(this)} onFocus={this.onFocus.bind(this)} onClick={this.onClick.bind(this)} onBlur={this.onBlur.bind(this)} > {icon} {children} {arrow} {dropdown}
); } componentDidUpdate() { if (this.state.dropdownOpen && this.validateDropdownPosition) { this.validateDropdownPosition(); } } componentDidMount() { let { widget } = this.props.instance; if (widget.keyboardShortcut) this.unregisterKeyboardShortcut = registerKeyboardShortcut(widget.keyboardShortcut, (e: any) => { this.el!.focus(); //open the dropdown this.onClick(e); //execute the onClick handler }); tooltipParentDidMount(this.el!, this.props.instance, widget.tooltip); } onDropdownKeyDown(e: React.KeyboardEvent) { debug(menuFlag, "MenuItem", "dropdownKeyDown"); let { horizontal } = this.props.instance; if ( e.keyCode == KeyCode.esc || (!isTextInputElement(e.currentTarget) && (horizontal ? e.keyCode == KeyCode.up : e.keyCode == KeyCode.left)) ) { FocusManager.focus(this.el!); e.preventDefault(); e.stopPropagation(); } } clearAutoFocusTimer() { if (this.autoFocusTimerId) { debug(menuFlag, "MenuItem", "autoFocusCancel"); clearTimeout(this.autoFocusTimerId); delete this.autoFocusTimerId; } } onMouseEnter(e: React.MouseEvent) { debug(menuFlag, "MenuItem", "mouseEnter", this.el); let { widget } = this.props.instance; if (widget.dropdown && !this.state.dropdownOpen) { this.clearAutoFocusTimer(); if (widget.hoverToOpen) FocusManager.focus(this.el!); else if (!widget.clickToOpen) { // Automatically open the dropdown only if parent menu is focused let commonParentMenu = closest(this.el!, (el) => el.tagName == "UL" && el.contains(getActiveElement())); if (commonParentMenu) this.autoFocusTimerId = setTimeout(() => { delete this.autoFocusTimerId; if (!this.state.dropdownOpen) { debug(menuFlag, "MenuItem", "hoverFocusTimeout:before", this.el); FocusManager.focus(this.el!); debug(menuFlag, "MenuItem", "hoverFocusTimeout:after", this.el, getActiveElement()); } }, widget.hoverFocusTimeout) as any; } e.stopPropagation(); e.preventDefault(); } tooltipMouseMove(e, this.props.instance, widget.tooltip); } onMouseLeave(e: React.MouseEvent) { let { widget } = this.props.instance; if (widget.dropdown) { debug(menuFlag, "MenuItem", "mouseLeave", this.el); this.clearAutoFocusTimer(); if (widget.hoverToOpen && document.activeElement == this.el) unfocusElement(this.el!); } tooltipMouseLeave(e, this.props.instance, widget.tooltip); } onKeyDown(e: React.KeyboardEvent) { debug(menuFlag, "MenuItem", "keyDown", this.el); let { horizontal, widget } = this.props.instance; if (widget.dropdown) { if ( !this.state.dropdownOpen && e.target == this.el && (e.keyCode == KeyCode.enter || (horizontal ? e.keyCode == KeyCode.down : e.keyCode == KeyCode.right)) ) { this.openDropdown(() => { let focusableChild = findFirstChild(this.el!, isFocusable); if (focusableChild) FocusManager.focus(focusableChild); }); e.preventDefault(); e.stopPropagation(); } if (e.keyCode == KeyCode.esc) { if (!isFocused(this.el!)) { FocusManager.focus(this.el!); e.preventDefault(); e.stopPropagation(); } this.closeDropdown(); } } else { if (e.keyCode == KeyCode.enter && widget.onClick) this.onClick(e); } } onMouseDown(e: React.MouseEvent) { let { widget } = this.props.instance; if (widget.dropdown) { e.stopPropagation(); if (this.state.dropdownOpen && !widget.hoverToOpen) this.closeDropdown(); else { //IE sometimes does not focus parent on child click if (!isFocusedDeep(this.el!)) FocusManager.focus(this.el!); this.openDropdown(); //If one of the elements is auto focused prevent stealing focus if (isFocusedDeep(this.el!)) e.preventDefault(); } } } openDropdown(callback?: any) { let { widget } = this.props.instance; if (widget.dropdown) { if (!this.state.dropdownOpen) { this.setState( { dropdownOpen: true, }, callback, ); //hide tooltip if dropdown is open tooltipMouseLeave(null as any, this.props.instance, widget.tooltip); } else if (callback) callback(this.state); } } onClick(e: any) { e.stopPropagation(); let { instance } = this.props; let { data } = instance; if (data.disabled) { e.preventDefault(); return; } let { widget } = instance; if (widget.dropdown) e.preventDefault(); //prevent navigation else { instance.set("checked", !instance.data.checked); if (widget.onClick) { if (data.confirm) { yesNo(data.confirm).then((btn) => { if (btn == "yes") instance.invoke("onClick", null, instance); }); } else instance.invoke("onClick", e, instance); } } if (widget.autoClose) unfocusElement(this.el, true); } onFocus() { let { widget } = this.props.instance; if (widget.dropdown) { oneFocusOut(this, this.el!, this.onFocusOut.bind(this)); debug(menuFlag, "MenuItem", "focus", this.el, document.activeElement); this.clearAutoFocusTimer(); if (widget.openOnFocus) this.openDropdown(); } } onBlur() { FocusManager.nudge(); } closeDropdown() { this.setState({ dropdownOpen: false, }); delete this.initialScreenPosition; } onFocusOut(focusedElement: any) { debug(menuFlag, "MenuItem", "focusout", this.el, focusedElement); this.clearAutoFocusTimer(); if (this.el && !isSelfOrDescendant(this.el, focusedElement)) { debug(menuFlag, "MenuItem", "closing dropdown", this.el, focusedElement); this.closeDropdown(); } } componentWillUnmount() { this.clearAutoFocusTimer(); offFocusOut(this); if (this.offParentPositionChange) this.offParentPositionChange(); if (this.unregisterKeyboardShortcut) this.unregisterKeyboardShortcut(); tooltipParentWillUnmount(this.props.instance); } }