All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.dominokit.domino.ui.menu.Menu Maven / Gradle / Ivy

There is a newer version: 2.0.3
Show newest version
/*
 * Copyright © 2019 Dominokit
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.dominokit.domino.ui.menu;

import static elemental2.dom.DomGlobal.document;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static org.dominokit.domino.ui.utils.Domino.*;
import static org.dominokit.domino.ui.utils.PopupsCloser.DOMINO_UI_AUTO_CLOSABLE;

import elemental2.dom.*;
import elemental2.dom.EventListener;
import java.util.*;
import jsinterop.base.Js;
import org.dominokit.domino.ui.IsElement;
import org.dominokit.domino.ui.config.HasComponentConfig;
import org.dominokit.domino.ui.config.ZIndexConfig;
import org.dominokit.domino.ui.elements.AnchorElement;
import org.dominokit.domino.ui.elements.DivElement;
import org.dominokit.domino.ui.elements.LIElement;
import org.dominokit.domino.ui.elements.UListElement;
import org.dominokit.domino.ui.events.EventType;
import org.dominokit.domino.ui.icons.Icon;
import org.dominokit.domino.ui.icons.MdiIcon;
import org.dominokit.domino.ui.icons.lib.Icons;
import org.dominokit.domino.ui.layout.NavBar;
import org.dominokit.domino.ui.mediaquery.MediaQuery;
import org.dominokit.domino.ui.menu.direction.BestSideUpDownDropDirection;
import org.dominokit.domino.ui.menu.direction.DropDirection;
import org.dominokit.domino.ui.menu.direction.MiddleOfScreenDropDirection;
import org.dominokit.domino.ui.menu.direction.MouseBestFitDirection;
import org.dominokit.domino.ui.search.SearchBox;
import org.dominokit.domino.ui.style.BooleanCssClass;
import org.dominokit.domino.ui.style.Elevation;
import org.dominokit.domino.ui.utils.*;

/**
 * Represents a UI Menu component that supports different configurations, items, and behaviors.
 *
 * 

Usage Example: * *

 * Menu myMenu = Menu.create()
 *    .setTitle("My Menu")
 *    .setIcon(Icons.ALL.menu())
 *    .appendChild(new MenuItem<>("Menu Item 1"));
 * 
* * @param The type of the item value that the menu holds. * @see BaseDominoElement */ public class Menu extends BaseDominoElement> implements HasSelectionListeners, AbstractMenuItem, List>>, IsPopup>, HasComponentConfig, MenuStyles { public static final String ANY = "*"; private final LazyChild menuHeader; private final LazyChild menuSearchContainer; private final LazyChild searchBox; private final LazyChild menuSubHeader; private final UListElement menuItemsList; private final DivElement menuBody; private final LazyChild menuFooter; private final LazyChild createMissingElement; private final LazyChild backIcon; private LazyChild noResultElement; protected DivElement menuElement; private HTMLElement focusElement; protected KeyboardNavigation> keyboardNavigation; protected boolean searchable; protected boolean caseSensitive = false; protected String createMissingLabel = "Create "; private MissingItemHandler missingItemHandler; protected List> menuItems = new ArrayList<>(); protected boolean autoCloseOnSelect = true; protected final Set< SelectionListener, ? super List>>> selectionListeners = new LinkedHashSet<>(); protected final Set< SelectionListener, ? super List>>> deselectionListeners = new LinkedHashSet<>(); private final List> selectedValues = new ArrayList<>(); protected boolean headerVisible = false; private Menu currentOpen; private boolean smallScreen; private DropDirection dropDirection = new BestSideUpDownDropDirection(); private final DropDirection contextMenuDropDirection = new MouseBestFitDirection(); private final DropDirection smallScreenDropDirection = new MiddleOfScreenDropDirection(); private DropDirection effectiveDropDirection = dropDirection; private Map targets = new HashMap<>(); private MenuTarget lastTarget; private Element menuAppendTarget = document.body; private AppendStrategy appendStrategy = AppendStrategy.LAST; private Menu parent; private AbstractMenuItem parentItem; private boolean selectionListenersPaused = false; private boolean multiSelect = false; private boolean autoOpen = true; private boolean preserveSelectionStyles = true; private EventListener repositionListener = evt -> { if (isOpened()) { position(); } }; private final EventListener openListener = evt -> { evt.stopPropagation(); evt.preventDefault(); lastTarget = targets.get(elementOf(Js.uncheckedCast(evt.currentTarget)).getDominoId()); if (isNull(lastTarget)) { lastTarget = targets.get(elementOf(Js.uncheckedCast(evt.target)).getDominoId()); } if (isAutoOpen()) { if (isOpened() && !isContextMenu()) { close(); } else { open(evt); } } }; private final DivElement backArrowContainer; private boolean contextMenu = false; private boolean useSmallScreensDirection = true; private boolean dropDown = false; private Set> onAddItemHandlers = new HashSet<>(); private boolean fitToTargetWidth = false; private boolean centerOnSmallScreens = false; private EventListener lostFocusListener; private boolean closeOnBlur = DominoUIConfig.CONFIG.isClosePopupOnBlur(); private OpenMenuCondition openMenuCondition = (menu) -> true; /** * Factory method to create a new Menu instance. * * @param The type of the menu item value. * @return A new menu instance. */ public static Menu create() { return new Menu<>(); } /** Default constructor to initialize the Menu component. */ public Menu() { menuElement = div().addCss(dui_menu); menuHeader = LazyChild.of(NavBar.create(), menuElement); menuSearchContainer = LazyChild.of(div().addCss(dui_menu_search), menuElement); searchBox = LazyChild.of(SearchBox.create().addCss(dui_menu_search_box), menuSearchContainer); backArrowContainer = div().addCss(dui_order_first, dui_menu_back_icon); init(this); EventListener addMissingEventListener = evt -> { evt.preventDefault(); evt.stopPropagation(); onAddMissingElement(); }; addClickListener(evt -> evt.stopPropagation()); onKeyDown( keyEvents -> { keyEvents.alphanumeric( evt -> { KeyboardEvent keyboardEvent = Js.uncheckedCast(evt); focusFirstMatch(keyboardEvent.key); }); }); menuSubHeader = LazyChild.of(div().addCss(dui_menu_sub_header), menuElement); menuItemsList = ul().addCss(dui_menu_items_list); noResultElement = LazyChild.of(li().addCss(dui_menu_no_results, dui_order_last), menuItemsList); menuBody = div().addCss(dui_menu_body); menuElement.appendChild(menuBody.appendChild(menuItemsList)); menuFooter = LazyChild.of(div().addCss(dui_menu_footer), menuBody); createMissingElement = LazyChild.of( a("#") .setAttribute("tabindex", "0") .setAttribute("aria-expanded", "true") .addCss(dui_menu_create_missing), menuFooter); createMissingElement.whenInitialized( () -> { createMissingElement .element() .removeEventListener("click", addMissingEventListener) .addClickListener(addMissingEventListener); createMissingElement .element() .onKeyDown( keyEvents -> { keyEvents .clearAll() .onEnter(addMissingEventListener) .onTab(evt -> keyboardNavigation.focusTopFocusableItem()) .onArrowDown( evt -> { evt.stopPropagation(); evt.preventDefault(); if (isSearchable()) { this.searchBox .get() .getTextBox() .getInputElement() .element() .focus(); } else { keyboardNavigation.focusTopFocusableItem(); } }) .onArrowUp( evt -> { evt.stopPropagation(); evt.preventDefault(); keyboardNavigation.focusBottomFocusableItem(); }); }); }); searchBox.whenInitialized( () -> { searchBox.element().addSearchListener(this::onSearch); this.searchBox .element() .getTextBox() .getInputElement() .onKeyDown( keyEvents -> keyEvents .onArrowDown( evt -> { evt.stopPropagation(); evt.preventDefault(); Optional> topFocusableItem = keyboardNavigation.getTopFocusableItem(); if (topFocusableItem.isPresent()) { keyboardNavigation.focusTopFocusableItem(); } else { if (isAllowCreateMissing() && createMissingElement.element().isAttached()) { createMissingElement.get().element().focus(); } } }) .onArrowUp( evt -> { evt.stopPropagation(); evt.preventDefault(); if (isAllowCreateMissing() && createMissingElement.element().isAttached()) { createMissingElement.get().element().focus(); } else { keyboardNavigation.focusBottomFocusableItem(); } }) .onEscape(evt -> close()) .onEnter( evt -> keyboardNavigation .getTopFocusableItem() .ifPresent(AbstractMenuItem::select))); }); keyboardNavigation = KeyboardNavigation.create(menuItems) .setTabOptions(new KeyboardNavigation.EventOptions(false, true)) .setTabHandler( (event, item) -> { if (keyboardNavigation.isLastFocusableItem(item)) { event.preventDefault(); if (isSearchable()) { this.searchBox.get().getTextBox().getInputElement().element().focus(); } else { keyboardNavigation.focusTopFocusableItem(); } } }) .setEnterHandler((event, item) -> item.select()) .registerNavigationHandler("ArrowRight", (event, item) -> item.openSubMenu()) .registerNavigationHandler( "ArrowLeft", (event, item) -> { if (nonNull(getParentItem())) { getParentItem().focus(); this.close(); } }) .onSelect((event, item) -> item.select()) .focusCondition(item -> !item.isCollapsed() && !item.isDisabled()) .onFocus( item -> { if (isDropDown()) { if (isOpened()) { item.focus(); } } else { item.focus(); } }) .onEscape(this::close) .setOnEndReached( navigation -> { if (isAllowCreateMissing() && createMissingElement.element().isAttached()) { createMissingElement.get().element().focus(); } else if (isSearchable()) { this.searchBox.get().getTextBox().getInputElement().element().focus(); } else { navigation.focusTopFocusableItem(); } }) .setOnStartReached( navigation -> { if (isSearchable()) { this.searchBox.get().getTextBox().getInputElement().element().focus(); } else if (isAllowCreateMissing() && createMissingElement.element().isAttached()) { createMissingElement.get().element().focus(); } else { navigation.focusBottomFocusableItem(); } }); ; element.addEventListener("keydown", keyboardNavigation); MediaQuery.addOnSmallAndDownListener( () -> { if (centerOnSmallScreens) { this.smallScreen = true; } }); MediaQuery.addOnMediumAndUpListener( () -> { if (centerOnSmallScreens) { this.smallScreen = false; backArrowContainer.remove(); } }); backIcon = LazyChild.of(Icons.keyboard_backspace().addCss(dui_menu_back_icon), menuHeader); backIcon.whenInitialized( () -> { backIcon .get() .clickable() .addClickListener(this::backToParent) .addEventListener("touchend", this::backToParent) .addEventListener("touchstart", Event::stopPropagation); }); lostFocusListener = evt -> { if (isDropDown() && isCloseOnBlur()) { DomGlobal.setTimeout( p0 -> { Element e = DomGlobal.document.activeElement; if (getTarget().isPresent()) { Element target = getTarget().get().getTargetElement().element(); if (!(target.contains(e) || e.equals(target) || this.element().contains(e) || e.equals(this.element()))) { close(); } } else { if (!(this.element().contains(e) || e.equals(this.element()))) { close(); } } }, 0); } }; this.addEventListener(EventType.touchstart.getName(), Event::stopPropagation); this.addEventListener(EventType.touchend.getName(), Event::stopPropagation); } public void focusFirstMatch(String token) { findOptionStarsWith(token).ifPresent(AbstractMenuItem::focus); } public Optional> findOptionStarsWith(String token) { return this.menuItems.stream() .filter(menuItem -> !menuItem.isGrouped()) .filter(dropDownItem -> dropDownItem.startsWith(token)) .findFirst(); } /** * Handles the behavior when an expected menu item is missing. * *

If a missing item handler is set, this method triggers the handler's onMissingItem method, * performs a search with the current value of the search box, and then removes the create missing * element and focuses on the top focusable item in the menu. */ private void onAddMissingElement() { if (nonNull(missingItemHandler)) { missingItemHandler.onMissingItem(searchBox.get().getTextBox().getValue(), this); onSearch(searchBox.get().getTextBox().getValue()); createMissingElement.remove(); keyboardNavigation.focusTopFocusableItem(); } } /** * Determines if the menu is set to be centered on small screen devices. * * @return true if the menu should be centered on small screens, false otherwise. */ public boolean isCenterOnSmallScreens() { return centerOnSmallScreens; } /** * Sets the behavior for the menu to be centered or not on small screen devices. * * @param centerOnSmallScreens true to center the menu on small screens, false otherwise. * @return The current {@link Menu} instance. */ public Menu setCenterOnSmallScreens(boolean centerOnSmallScreens) { this.centerOnSmallScreens = centerOnSmallScreens; return this; } /** * Allows adding an icon to the menu header. * * @param icon The icon to be set. * @return The current Menu instance. */ public Menu setIcon(Icon icon) { menuHeader.get().appendChild(PrefixAddOn.of(icon)); return this; } /** * Sets the title for the menu header. * * @param title The title to be set. * @return The current Menu instance. */ public Menu setTitle(String title) { menuHeader.get().setTitle(title); return this; } /** * Appends a subheader addon to the menu. * * @param addon The subheader addon to be added. * @return The current Menu instance. */ public Menu appendChild(SubheaderAddon addon) { menuSubHeader.get().appendChild(addon); return this; } /** * Appends a menu item to the menu. * * @param menuItem The menu item to be added. * @return The current Menu instance. */ public Menu appendChild(AbstractMenuItem menuItem) { if (nonNull(menuItem)) { menuItemsList.appendChild(menuItem); menuItems.add(menuItem); afterAddItem(menuItem); } return this; } /** * Inserts a menu item to the menu at the specified index, the index should be within the valid * range otherwise an exception is thrown. * * @param index The index to insert the menu item at. * @param menuItem The menu item to be added. * @return The current Menu instance. */ public Menu insertChild(int index, AbstractMenuItem menuItem) { if (nonNull(menuItem)) { if (index < 0 || (index > 0 && index >= menuItemsList.getChildElementCount())) { throw new IndexOutOfBoundsException( "Could not insert menu item at index [" + index + "], index out of range [0," + (menuItemsList.getChildElementCount() - 1) + "]"); } if (menuItemsList.getChildElementCount() > 0) { DominoElement elementDominoElement = menuItemsList.childElements().get(index); menuItemsList.insertBefore(menuItem, elementDominoElement); menuItems.add(index, menuItem); } else { menuItemsList.appendChild(menuItem); menuItems.add(menuItem); } afterAddItem(menuItem); } return this; } private void afterAddItem(AbstractMenuItem menuItem) { menuItem.setParent(this); onItemAdded(menuItem); } void onItemAdded(AbstractMenuItem menuItem) { onAddItemHandlers.forEach(handler -> handler.onAdded(this, menuItem)); } /** * Appends a menu items group to the menu with a provided handler. * * @param The type of the abstract menu item. * @param menuGroup The menu items group to be added. * @param groupHandler The handler for the menu items group. * @return The current Menu instance. */ public > Menu appendGroup( MenuItemsGroup menuGroup, MenuItemsGroupHandler groupHandler) { if (nonNull(menuGroup)) { menuItemsList.appendChild(menuGroup); menuItems.add(menuGroup); menuGroup.setParent(this); groupHandler.handle(menuGroup); } return this; } /** * Inserts a menu items group to the menu at the specified index, the index should be within the * valid range otherwise an exception is thrown. * * @param index The index to insert the menu items group at. * @param The type of the abstract menu item. * @param menuGroup The menu items group to be added. * @param groupHandler The handler for the menu items group. * @return The current Menu instance. */ public > Menu insertGroup( int index, MenuItemsGroup menuGroup, MenuItemsGroupHandler groupHandler) { if (nonNull(menuGroup)) { if (index < 0 || (index > 0 && index >= menuItemsList.getChildElementCount())) { throw new IndexOutOfBoundsException( "Could not insert menu item at index [" + index + "], index out of range [0," + (menuItemsList.getChildElementCount() - 1) + "]"); } if (menuItemsList.getChildElementCount() > 0) { DominoElement elementDominoElement = menuItemsList.childElements().get(index); menuItemsList.insertBefore(menuGroup, elementDominoElement); menuItems.add(index, menuGroup); } else { menuItemsList.appendChild(menuGroup); menuItems.add(menuGroup); } menuGroup.setParent(this); groupHandler.handle(menuGroup); } return this; } /** * Removes a menu item from the menu. * * @param menuItem The menu item to be removed. * @return The current Menu instance. */ public Menu removeItem(AbstractMenuItem menuItem) { if (this.menuItems.contains(menuItem)) { menuItem.remove(); this.menuItems.remove(menuItem); } return this; } /** * Removes a menu item from the menu at the specified index. * * @param index the index of the menu item to be removed. * @return The current Menu instance. */ public Menu removeItemAt(int index) { return removeItem(menuItems.get(index)); } /** * Removes all items and sub-items from the menu. * * @return The current Menu instance. */ public Menu removeAll() { menuItems.forEach(BaseDominoElement::remove); menuItems.clear(); closeCurrentOpen(); currentOpen = null; return this; } /** * Appends a separator to the menu. * * @param separator The separator to be added. * @return The current Menu instance. */ public Menu appendChild(Separator separator) { this.menuItemsList.appendChild(separator.addCss(dui_menu_separator)); return this; } /** * Inserts a separator to the menu at the specified index, the index should be within the valid * range otherwise an exception is thrown. * * @param index The index to insert the separator at. * @param separator The separator to be added. * @return The current Menu instance. */ public Menu insertChild(int index, Separator separator) { if (nonNull(separator)) { if (index < 0 || (index > 0 && index >= menuItemsList.getChildElementCount())) { throw new IndexOutOfBoundsException( "Could not insert menu item at index [" + index + "], index out of range [0," + (menuItemsList.getChildElementCount() - 1) + "]"); } if (menuItemsList.getChildElementCount() > 0) { DominoElement elementDominoElement = menuItemsList.childElements().get(index); menuItemsList.insertBefore(separator, elementDominoElement); } else { this.menuItemsList.appendChild(separator.addCss(dui_menu_separator)); } } return this; } /** * {@inheritDoc} * *

Retrieves the main HTMLDivElement element representing this menu. * * @return the main HTMLDivElement element. */ @Override public HTMLDivElement element() { return menuElement.element(); } /** Clears the contents of the search box within the menu. */ private void clearSearch() { searchBox.get().clearSearch(); } /** * Clears the current selection of menu items. * * @param silent if true, does not trigger the deselection listeners; otherwise, does. */ public void clearSelection(boolean silent) { selectedValues.clear(); if (!silent) { triggerDeselectionListeners(null, selectedValues); } } /** * Filters the menu items based on a given search token. * *

If no results match, a "no results" message is displayed. * * @param token the string to use for filtering the menu items. * @return true if one or more items match the search token, false otherwise. */ public boolean onSearch(String token) { this.menuItems.forEach(AbstractMenuItem::closeSubMenu); boolean emptyToken = emptyToken(token); if (emptyToken) { this.createMissingElement.remove(); } if (isAllowCreateMissing() && !emptyToken) { createMissingElement.get().setInnerHtml(getCreateMissingLabel() + "" + token + ""); } long count = this.menuItems.stream() .filter(menuItem -> !menuItem.isGrouped()) .filter(dropDownItem -> dropDownItem.onSearch(token, isCaseSensitive())) .count(); if (count < 1 && menuItems.size() > 0) { this.menuItemsList.appendChild( noResultElement.get().setInnerHtml("No results matched " + " " + token + "")); } else { noResultElement.remove(); } position(); return count > 0; } /** * Gets the label used when an item is not found during a search. * * @return the label string. */ public String getCreateMissingLabel() { return createMissingLabel; } /** * Sets the label to be displayed when an item is not found during a search. * * @param createMissingLabel the label string. * @return The current {@link Menu} instance. */ public Menu setCreateMissingLabel(String createMissingLabel) { if (isNull(createMissingLabel) || createMissingLabel.isEmpty()) { this.createMissingLabel = "Create "; } else { this.createMissingLabel = createMissingLabel; } return this; } /** * Determines if the provided search token is empty or null. * * @param token the search string. * @return true if the token is null or empty, false otherwise. */ private boolean emptyToken(String token) { return isNull(token) || token.isEmpty(); } /** * Retrieves the list of direct menu items (excluding sub-menu items) contained in this menu. * * @return the list of direct menu items. */ public List> getMenuItems() { return menuItems; } /** * Retrieves a flattened list of all menu items, including items within groups. * *

This method will return both direct menu items and those that are part of a {@link * MenuItemsGroup}. * * @return a flattened list of all menu items. */ public List> getFlatMenuItems() { List> items = new ArrayList<>(); menuItems.forEach( item -> { if (item instanceof MenuItemsGroup) { ((MenuItemsGroup) item) .getMenuItems() .forEach(subItem -> items.add((AbstractMenuItem) subItem)); } else { items.add(item); } }); return items; } /** * Retrieves the element used to display a "no results" message when a search yields no results. * * @return the "no results" element wrapped in a {@link LazyChild} container. */ public LazyChild getNoResultElement() { return noResultElement; } /** * Sets the element used to display a "no results" message when a search yields no results. * * @param noResultElement the HTMLLIElement to be used for displaying "no results". * @return The current {@link Menu} instance. */ public Menu setNoResultElement(HTMLLIElement noResultElement) { if (nonNull(noResultElement)) { this.noResultElement.remove(); this.noResultElement = LazyChild.of(LIElement.of(noResultElement).addCss(dui_menu_no_results), menuItemsList); } return this; } /** * Sets the element used to display a "no results" message when a search yields no results. * * @param noResultElement the IsElement wrapping an HTMLLIElement to be used for displaying "no * results". * @return The current {@link Menu} instance. */ public Menu setNoResultElement(IsElement noResultElement) { if (nonNull(noResultElement)) { setNoResultElement(noResultElement.element()); } return this; } /** * Checks if the menu's search functionality is case-sensitive. * * @return true if the search is case-sensitive, false otherwise. */ public boolean isCaseSensitive() { return caseSensitive; } /** * Sets the menu's search functionality to be case-sensitive or not. * * @param caseSensitive a boolean indicating whether to enable or disable case-sensitivity. * @return The current {@link Menu} instance. */ public Menu setCaseSensitive(boolean caseSensitive) { this.caseSensitive = caseSensitive; return this; } /** * Retrieves the current focus element of the menu. * *

The focus element is determined based on the following criteria: - If a custom focus element * has been set, it will be returned. - If the menu is searchable, the search box input will be * the focus element. - If the menu contains menu items, the first item will be the focus element. * - Otherwise, the root element of the menu items list will be the focus element. * * @return the current focus element of the menu. */ public HTMLElement getFocusElement() { if (isNull(this.focusElement)) { if (isSearchable()) { return this.searchBox.get().getTextBox().getInputElement().element(); } else if (!this.menuItems.isEmpty()) { return menuItems.get(0).getClickableElement(); } else { return this.menuItemsList.element(); } } return focusElement; } /** * Sets the focus element for the menu. * * @param focusElement the HTMLElement to set as the focus element. * @return The current {@link Menu} instance. */ public Menu setFocusElement(HTMLElement focusElement) { this.focusElement = focusElement; return this; } /** * Sets the focus element for the menu. * * @param focusElement the IsElement wrapping an HTMLElement to be set as the focus element. * @return The current {@link Menu} instance. */ public Menu setFocusElement(IsElement focusElement) { return setFocusElement(focusElement.element()); } /** * Retrieves the search box component used within the menu. * * @return the {@link SearchBox} instance. */ public SearchBox getSearchBox() { return searchBox.get(); } /** * Retrieves the keyboard navigation handler for the menu items. * * @return the keyboard navigation instance. */ public KeyboardNavigation> getKeyboardNavigation() { return keyboardNavigation; } /** * Retrieves the header component of the menu. * * @return the {@link NavBar} instance representing the menu's header. */ public NavBar getMenuHeader() { return menuHeader.get(); } /** * Toggles the visibility of the menu's header. * * @param visible true to make the header visible, false to hide it. * @return The current {@link Menu} instance. */ public Menu setHeaderVisible(boolean visible) { menuHeader.get().toggleDisplay(visible); this.headerVisible = visible; return this; } /** * Checks if the menu has a search functionality enabled. * * @return true if the menu is searchable, false otherwise. */ public boolean isSearchable() { return searchable; } /** * Checks if the menu allows for the creation of missing items. * * @return true if missing items can be created, false otherwise. */ public boolean isAllowCreateMissing() { return nonNull(missingItemHandler); } /** * Enables or disables the search functionality within the menu. * * @param searchable true to enable search, false to disable. * @return The current {@link Menu} instance. */ public Menu setSearchable(boolean searchable) { if (searchable) { searchBox.get(); } else { searchBox.remove(); menuSearchContainer.remove(); } this.searchable = searchable; return this; } /** * Sets the handler for missing items in the menu. When set, it allows the creation of missing * items. * * @param missingItemHandler the handler to manage missing items. * @return The current {@link Menu} instance. */ public Menu setMissingItemHandler(MissingItemHandler missingItemHandler) { this.missingItemHandler = missingItemHandler; return this; } /** * Selects a given menu item. * * @param menuItem The menu item to select. * @return The current {@link Menu} instance. */ public Menu select(AbstractMenuItem menuItem) { return select(menuItem, isSelectionListenersPaused()); } /** * Selects a given menu item with the option to silence selection events. * * @param menuItem The menu item to select. * @param silent If true, selection listeners will be paused; otherwise, they will be active. * @return The current {@link Menu} instance. */ public Menu select(AbstractMenuItem menuItem, boolean silent) { menuItem.select(silent); return this; } /** * Selects a menu item at a specified index. * * @param index The index of the menu item to select. * @return The current {@link Menu} instance. */ public Menu selectAt(int index) { return selectAt(index, isSelectionListenersPaused()); } /** * Selects a menu item at a specified index with the option to silence selection events. * * @param index The index of the menu item to select. * @param silent If true, selection listeners will be paused; otherwise, they will be active. * @return The current {@link Menu} instance. */ public Menu selectAt(int index, boolean silent) { if (index < menuItems.size() && index >= 0) { select(menuItems.get(index), silent); } return this; } /** * Selects a menu item by its key identifier. * * @param key The key identifier of the menu item to select. * @return The current {@link Menu} instance. */ public Menu selectByKey(String key) { return selectByKey(key, false); } /** * Selects a menu item by its key identifier with the option to silence selection events. * * @param key The key identifier of the menu item to select. * @param silent If true, selection listeners will be paused; otherwise, they will be active. * @return The current {@link Menu} instance. */ public Menu selectByKey(String key, boolean silent) { for (AbstractMenuItem menuItem : getMenuItems()) { if (menuItem.getKey().equals(key)) { select(menuItem, silent); } } return this; } /** * Checks if the menu is set to automatically close upon selection of an item. * * @return true if the menu will auto-close on selection, false otherwise. */ public boolean isAutoCloseOnSelect() { return autoCloseOnSelect; } /** * Sets whether the menu should automatically close upon selecting an item. * * @param autoCloseOnSelect If true, the menu will auto-close on selection. * @return The current {@link Menu} instance. */ public Menu setAutoCloseOnSelect(boolean autoCloseOnSelect) { this.autoCloseOnSelect = autoCloseOnSelect; return this; } /** * Checks if the menu supports selecting multiple items simultaneously. * * @return true if the menu supports multi-selection, false otherwise. */ public boolean isMultiSelect() { return multiSelect; } /** * Enables or disables the ability to select multiple items in the menu. * * @param multiSelect If true, multi-selection will be enabled. * @return The current {@link Menu} instance. */ public Menu setMultiSelect(boolean multiSelect) { this.multiSelect = multiSelect; return this; } /** * Checks if the menu is set to automatically open. * * @return true if the menu will auto-open, false otherwise. */ public boolean isAutoOpen() { return autoOpen; } /** * Sets whether the menu should automatically open. * * @param autoOpen If true, the menu will auto-open. * @return The current {@link Menu} instance. */ public Menu setAutoOpen(boolean autoOpen) { this.autoOpen = autoOpen; return this; } /** * Checks if the menu is set to fit the width of its target. * * @return true if the menu fits the target width, false otherwise. */ public boolean isFitToTargetWidth() { return fitToTargetWidth; } /** * Sets whether the menu should fit the width of its target. * * @param fitToTargetWidth If true, the menu will fit the target width. * @return The current {@link Menu} instance. */ public Menu setFitToTargetWidth(boolean fitToTargetWidth) { this.fitToTargetWidth = fitToTargetWidth; return this; } /** * Pauses the selection listeners of the menu. * * @return The current {@link Menu} instance. */ @Override public Menu pauseSelectionListeners() { this.togglePauseSelectionListeners(true); return this; } /** * Resumes the paused selection listeners of the menu. * * @return The current {@link Menu} instance. */ @Override public Menu resumeSelectionListeners() { this.togglePauseSelectionListeners(false); return this; } /** * Toggles the pause state of the selection listeners. * * @param toggle If true, pauses the selection listeners; if false, resumes them. * @return The current {@link Menu} instance. */ @Override public Menu togglePauseSelectionListeners(boolean toggle) { this.selectionListenersPaused = toggle; return this; } /** * Retrieves the set of selection listeners associated with the menu. * * @return A set of selection listeners. */ @Override public Set, ? super List>>> getSelectionListeners() { return selectionListeners; } /** * Retrieves the set of deselection listeners associated with the menu. * * @return A set of deselection listeners. */ @Override public Set, ? super List>>> getDeselectionListeners() { return deselectionListeners; } /** * Checks if the selection listeners of the menu are currently paused. * * @return true if the selection listeners are paused, false otherwise. */ @Override public boolean isSelectionListenersPaused() { return this.selectionListenersPaused; } /** * Triggers the selection listeners of the menu. * * @param source The source menu item that caused the selection. * @param selection A list of selected menu items. * @return The current {@link Menu} instance. */ @Override public Menu triggerSelectionListeners( AbstractMenuItem source, List> selection) { selectionListeners.forEach( listener -> listener.onSelectionChanged(Optional.ofNullable(source), selection)); return this; } /** * Triggers the deselection listeners of the menu. * * @param source The source menu item that caused the deselection. * @param selection A list of deselected menu items. * @return The current {@link Menu} instance. */ @Override public Menu triggerDeselectionListeners( AbstractMenuItem source, List> selection) { deselectionListeners.forEach( listener -> listener.onSelectionChanged(Optional.ofNullable(source), selection)); return this; } /** * Returns the current selection of menu items. * * @return A list of currently selected menu items. */ @Override public List> getSelection() { return selectedValues; } /** * Sets the menu to have a bordered appearance. * * @param bordered If true, the menu will have a border; if false, it will not. * @return The current {@link Menu} instance. */ public Menu setBordered(boolean bordered) { removeCss("menu-bordered"); if (bordered) { css("menu-bordered"); } return this; } /** * Opens the specified submenu and closes the currently open submenu. * * @param dropMenu The submenu to open. * @return The current {@link Menu} instance. */ public Menu openSubMenu(Menu dropMenu) { if (!Objects.equals(currentOpen, dropMenu)) { closeCurrentOpen(); } dropMenu.open(); setCurrentOpen(dropMenu); return this; } /** * Sets the current open submenu. * * @param dropMenu The submenu to be set as currently open. */ void setCurrentOpen(Menu dropMenu) { this.currentOpen = dropMenu; } /** Closes the currently open submenu. */ void closeCurrentOpen() { if (nonNull(currentOpen)) { currentOpen.close(); } } /** * Closes the current menu and reopens its parent menu. * * @param evt The event that triggered the action. */ private void backToParent(Event evt) { evt.stopPropagation(); evt.preventDefault(); this.close(); if (nonNull(parent)) { this.parent.open(true); } } /** * Checks if the menu is currently open. * * @return true if the menu is open, false otherwise. */ public boolean isOpened() { return isDropDown() && isAttached(); } /** * Opens the menu based on a triggering event. * * @param evt The event that triggered the open action. */ private void open(Event evt) { getEffectiveDropDirection().init(evt); open(); } /** * Opens the menu and optionally sets focus on it. * * @param focus If true, the menu will be focused upon opening. */ public void open(boolean focus) { if (isDropDown() && openMenuCondition.check(this)) { if (getTarget().isPresent()) { DominoElement targetElement = getTarget().get().getTargetElement(); if (!(targetElement.isReadOnly() || targetElement.isDisabled())) { doOpen(focus); } } else { doOpen(focus); } } } /** * Opens the menu and manages the necessary UI changes and events. * * @param focus If true, the menu will be focused upon opening. */ private void doOpen(boolean focus) { getConfig().getZindexManager().onPopupOpen(this); if (isOpened()) { position(); } else { closeOthers(); if (isSearchable()) { searchBox.get().clearSearch(); } triggerOpenListeners(this); onAttached( mutationRecord -> { position(); if (focus) { focus(); } elementOf(getMenuAppendTarget()).onDetached(targetDetach -> close()); }); appendStrategy.onAppend(getMenuAppendTarget(), element.element()); onDetached(record -> close()); if (smallScreen && nonNull(parent) && parent.isDropDown()) { parent.collapse(); menuHeader.get().insertFirst(backArrowContainer); } show(); DomGlobal.document.body.addEventListener("blur", lostFocusListener, true); } } /** Adjusts the position of the menu relative to its target element. */ private void position() { if (isDropDown() && isOpened()) { Optional menuTarget = getTarget(); menuTarget.ifPresent( target -> { if (fitToTargetWidth) { element.setWidth( target.getTargetElement().element().getBoundingClientRect().width + "px"); } getEffectiveDropDirection() .position(element.element(), target.getTargetElement().element()); }); } } /** * Determines the effective drop direction of the menu based on various conditions. * * @return The drop direction for the menu. */ protected DropDirection getEffectiveDropDirection() { if (isUseSmallScreensDirection() && smallScreen) { return smallScreenDropDirection; } else { if (isContextMenu()) { return contextMenuDropDirection; } else { return dropDirection; } } } /** Closes other menus if they are opened. */ private void closeOthers() { if (this.hasAttribute("domino-sub-menu") && Boolean.parseBoolean(this.getAttribute("domino-sub-menu"))) { return; } PopupsCloser.close(); } /** Sets the focus on the menu. */ public void focus() { getFocusElement().focus(); } /** * Gets the current target element for the menu. * * @return An optional containing the menu target, or empty if no target is set. */ public Optional getTarget() { if (isNull(lastTarget) && targets.size() == 1) { return targets.values().stream().findFirst(); } return Optional.ofNullable(lastTarget); } /** * Sets the target element for the menu. * * @param targetElement The element to be set as the menu's target. * @return The current {@link Menu} instance. */ public Menu setTargetElement(IsElement targetElement) { return setTargetElement(targetElement.element()); } /** * Sets the target element for the menu. * * @param targetElement The element to be set as the menu's target. * @return The current {@link Menu} instance. */ public Menu setTargetElement(Element targetElement) { setTarget(MenuTarget.of(targetElement)); return this; } /** * Sets the menu target. * * @param menuTarget The {@link MenuTarget} instance representing the menu's target. * @return The current {@link Menu} instance. */ public Menu setTarget(MenuTarget menuTarget) { this.targets .values() .forEach( target -> { target .getTargetElement() .removeEventListener( isContextMenu() ? EventType.contextmenu.getName() : EventType.click.getName(), openListener); target.getTargetElement().removeDetachObserver(menuTarget.getTargetDetachObserver()); target.getTargetElement().removeAttachObserver(menuTarget.getTargetAttachObserver()); }); this.targets.clear(); return addTarget(menuTarget); } /** * Adds a new target for the menu. * * @param menuTarget The new target to add. * @return The current {@link Menu} instance. */ public Menu addTarget(MenuTarget menuTarget) { if (nonNull(menuTarget)) { this.targets.put(menuTarget.getTargetElement().getDominoId(), menuTarget); menuTarget.setTargetDetachObserver( mutationRecord -> { if (Objects.equals(menuTarget, lastTarget)) { close(); } this.targets.remove(menuTarget.getTargetElement().getDominoId()); }); menuTarget.setTargetAttachObserver( mutationRecord -> { this.targets.put(menuTarget.getTargetElement().getDominoId(), menuTarget); }); elementOf(menuTarget.getTargetElement()).onDetached(menuTarget.getTargetDetachObserver()); elementOf(menuTarget.getTargetElement()).onAttached(menuTarget.getTargetAttachObserver()); } if (!this.targets.isEmpty()) { applyTargetListeners(menuTarget); setDropDown(true); } else { setDropDown(false); } return this; } /** * Gets the element to which the menu is appended in the DOM. * * @return The append target element. */ public Element getMenuAppendTarget() { return menuAppendTarget; } /** * Sets the element to which the menu will be appended in the DOM. * * @param appendTarget The new append target element. * @return The current {@link Menu} instance. */ public Menu setMenuAppendTarget(Element appendTarget) { if (isNull(appendTarget)) { this.menuAppendTarget = document.body; } else { this.menuAppendTarget = appendTarget; } return this; } /** * Opens the menu if it is a dropdown type. * * @return The current {@link Menu} instance. */ public Menu open() { if (isDropDown()) { open(true); } return this; } /** * Closes the menu if it is a dropdown type and if it is currently open. * * @return The current {@link Menu} instance. */ public Menu close() { if (isDropDown()) { if (isOpened()) { this.remove(); getTarget() .ifPresent( menuTarget -> { menuTarget.getTargetElement().element().focus(); }); DomGlobal.document.body.removeEventListener("blur", lostFocusListener, true); if (isSearchable()) { searchBox.get().clearSearch(); } menuItems.forEach(AbstractMenuItem::onParentClosed); triggerCloseListeners(this); if (smallScreen && nonNull(parent) && parent.isDropDown()) { parent.expand(); } } } return this; } /** * Retrieves the direction in which the menu will drop when opened. * * @return The current drop direction for the menu. */ public DropDirection getDropDirection() { return dropDirection; } /** * Sets the direction in which the menu will drop when opened. * * @param dropDirection The desired drop direction. * @return The current {@link Menu} instance. */ public Menu setDropDirection(DropDirection dropDirection) { if (nonNull(this.dropDirection)) { this.dropDirection.cleanup(this.element()); } if (nonNull(this.effectiveDropDirection)) { this.effectiveDropDirection.cleanup(this.element()); } if (effectiveDropDirection.equals(this.dropDirection)) { this.dropDirection = dropDirection; this.effectiveDropDirection = this.dropDirection; } else { this.dropDirection = dropDirection; } return this; } /** * Sets the parent menu for the current menu. This is typically used for sub-menus. * * @param parent The parent menu. */ void setParent(Menu parent) { this.parent = parent; } /** * Retrieves the parent menu of the current menu. * * @return The parent menu or null if there isn't any. */ public Menu getParent() { return parent; } /** * Sets the menu item that acts as the parent for the current menu. * * @param parentItem The parent menu item. */ void setParentItem(AbstractMenuItem parentItem) { this.parentItem = parentItem; } /** * Retrieves the menu item that acts as the parent for the current menu. * * @return The parent menu item or null if there isn't any. */ public AbstractMenuItem getParentItem() { return parentItem; } /** * Checks if the menu is set as a context menu. * * @return {@code true} if the menu is a context menu, {@code false} otherwise. */ public boolean isContextMenu() { return contextMenu; } /** * Sets the menu as a context menu or not. * * @param contextMenu {@code true} to set the menu as a context menu, {@code false} otherwise. * @return The current {@link Menu} instance. */ public Menu setContextMenu(boolean contextMenu) { this.contextMenu = contextMenu; addCss(BooleanCssClass.of(dui_context_menu, contextMenu)); targets.values().forEach(this::applyTargetListeners); return this; } /** * Applies the appropriate event listeners to the target element based on whether the menu is a * context menu or not. * * @param menuTarget The target menu to which the listeners should be applied. */ private void applyTargetListeners(MenuTarget menuTarget) { if (isContextMenu()) { menuTarget.getTargetElement().removeEventListener(EventType.click.getName(), openListener); menuTarget.getTargetElement().addEventListener(EventType.contextmenu.getName(), openListener); } else { menuTarget .getTargetElement() .removeEventListener(EventType.contextmenu.getName(), openListener); menuTarget.getTargetElement().addEventListener(EventType.click.getName(), openListener); } } /** * Handles the event when an item is selected in the menu. * * @param item The item that was selected. * @param silent Indicates whether the selection was silent or should trigger events. */ protected void onItemSelected(AbstractMenuItem item, boolean silent) { if (nonNull(parent)) { parent.onItemSelected(item, silent); } else { if (isAutoCloseOnSelect() && !item.hasMenu()) { close(); PopupsCloser.close(); } if (!this.selectedValues.contains(item)) { if (!multiSelect && !this.selectedValues.isEmpty()) { this.selectedValues.get(0).deselect(silent); this.selectedValues.clear(); } this.selectedValues.add(item); if (!silent) { triggerSelectionListeners(item, getSelection()); } } } } /** * Handles the event when an item is deselected in the menu. * * @param item The item that was deselected. * @param silent Indicates whether the deselection was silent or should trigger events. */ protected void onItemDeselected(AbstractMenuItem item, boolean silent) { if (nonNull(parent)) { parent.onItemDeselected(item, silent); } else { if (isAutoCloseOnSelect() && !item.hasMenu()) { close(); PopupsCloser.close(); } this.selectedValues.remove(item); if (!silent) { triggerDeselectionListeners(item, getSelection()); } } } /** * Checks if the menu is configured to use the small screens direction for dropping. * * @return {@code true} if the menu uses the small screens direction, {@code false} otherwise. */ public boolean isUseSmallScreensDirection() { return useSmallScreensDirection; } /** * Sets whether the menu should use the small screens drop direction. * * @param useSmallScreensDropDirection {@code true} to enable small screens drop direction, {@code * false} otherwise. * @return The current {@link Menu} instance. */ public Menu setUseSmallScreensDirection(boolean useSmallScreensDropDirection) { this.useSmallScreensDirection = useSmallScreensDropDirection; if (!useSmallScreensDropDirection && getEffectiveDropDirection() == smallScreenDropDirection) { this.effectiveDropDirection = dropDirection; } return this; } /** * Determines if the menu acts as a drop-down or a context menu. * * @return {@code true} if the menu acts as a drop-down or a context menu, {@code false} * otherwise. */ public boolean isDropDown() { return dropDown || isContextMenu(); } /** * Sets the menu's behavior to be a dropdown or not. It also adjusts attributes and listeners * accordingly. * * @param dropdown {@code true} to set the menu as a dropdown, {@code false} otherwise. */ private void setDropDown(boolean dropdown) { if (dropdown) { this.setAttribute("domino-ui-root-menu", true).setAttribute(DOMINO_UI_AUTO_CLOSABLE, true); menuElement.elevate(Elevation.LEVEL_1); document.addEventListener("scroll", repositionListener, true); } else { this.removeAttribute("domino-ui-root-menu").removeAttribute(DOMINO_UI_AUTO_CLOSABLE); menuElement.elevate(Elevation.NONE); document.removeEventListener("scroll", repositionListener); } addCss(BooleanCssClass.of(dui_menu_drop, dropdown)); this.dropDown = dropdown; setAutoClose(this.dropDown); } /** * Adds a handler that is triggered when a new item is added to the menu. * * @param onAddItemHandler The handler to add. * @return The current {@link Menu} instance. */ public Menu addOnAddItemHandler(OnAddItemHandler onAddItemHandler) { if (nonNull(onAddItemHandler)) { this.onAddItemHandlers.add(onAddItemHandler); } return this; } /** * Configures the menu to include a header. * * @return The current {@link Menu} instance. */ public Menu withHeader() { menuHeader.get(); return this; } /** * Configures the menu to include a customized header. * * @param handler A handler to customize the header. * @return The current {@link Menu} instance. */ public Menu withHeader(ChildHandler, NavBar> handler) { handler.apply(this, menuHeader.get()); return this; } /** * Checks if the menu is modal. * * @return {@code false} since the menu isn't a modal. */ @Override public boolean isModal() { return false; } /** * Checks if the menu is set to auto-close. * * @return {@code true} if the menu is set to auto-close, {@code false} otherwise. */ @Override public boolean isAutoClose() { return Boolean.parseBoolean(getAttribute(DOMINO_UI_AUTO_CLOSABLE, "false")); } /** * Sets the auto-close behavior for the menu. * * @param autoClose {@code true} to set the menu to auto-close, {@code false} otherwise. * @return The current {@link Menu} instance. */ public Menu setAutoClose(boolean autoClose) { if (autoClose) { setAttribute(DOMINO_UI_AUTO_CLOSABLE, "true"); } else { removeAttribute(DOMINO_UI_AUTO_CLOSABLE); } return this; } /** * Sets the condition for opening the menu. * * @param openMenuCondition A condition that needs to be met for the menu to open. If null, * defaults to always allow the menu to open. * @return The current {@link Menu} instance. */ public Menu setOpenMenuCondition(OpenMenuCondition openMenuCondition) { if (isNull(openMenuCondition)) { this.openMenuCondition = menu -> true; return this; } this.openMenuCondition = openMenuCondition; return this; } /** * Checks if the menu is set to close when it loses focus. * * @return {@code true} if the menu is set to close on blur, {@code false} otherwise. */ public boolean isCloseOnBlur() { return closeOnBlur; } /** * Sets the close-on-blur behavior for the menu. * * @param closeOnBlur {@code true} to set the menu to close when it loses focus, {@code false} * otherwise. * @return The current {@link Menu} instance. */ public Menu setCloseOnBlur(boolean closeOnBlur) { this.closeOnBlur = closeOnBlur; return this; } /** * @return boolean true if the selection style should be preserved after the menu item loses the * selection focus, otherwise false. */ public boolean isPreserveSelectionStyles() { return preserveSelectionStyles; } /** * if true selecting an Item in the menu will preserve the selection style when the menu loses the * focus. * * @param preserveSelectionStyles boolean, true to preserve the style, false to remove the style. * @return same Menu instance. */ public Menu setPreserveSelectionStyles(boolean preserveSelectionStyles) { this.preserveSelectionStyles = preserveSelectionStyles; return this; } /** Represents a handler for a group of menu items. */ @FunctionalInterface public interface MenuItemsGroupHandler> { /** * Handles the group of menu items. * * @param initializedGroup The group of menu items. */ void handle(MenuItemsGroup initializedGroup); } /** Represents a handler called when a new item is added to the menu. */ public interface OnAddItemHandler { /** * Called when a new menu item is added. * * @param menu The menu to which the item was added. * @param menuItem The added menu item. */ void onAdded(Menu menu, AbstractMenuItem menuItem); } }