
org.dominokit.domino.ui.dropdown.DropDownMenu Maven / Gradle / Ivy
/*
* 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.dropdown;
import static elemental2.dom.DomGlobal.document;
import static java.util.Objects.nonNull;
import static org.jboss.elemento.Elements.div;
import static org.jboss.elemento.Elements.h;
import static org.jboss.elemento.Elements.input;
import static org.jboss.elemento.Elements.li;
import static org.jboss.elemento.Elements.ul;
import elemental2.dom.Element;
import elemental2.dom.Event;
import elemental2.dom.HTMLDivElement;
import elemental2.dom.HTMLElement;
import elemental2.dom.HTMLInputElement;
import elemental2.dom.HTMLUListElement;
import elemental2.dom.Node;
import elemental2.dom.NodeList;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import jsinterop.base.Js;
import org.dominokit.domino.ui.grid.flex.FlexItem;
import org.dominokit.domino.ui.grid.flex.FlexLayout;
import org.dominokit.domino.ui.icons.Icons;
import org.dominokit.domino.ui.icons.MdiIcon;
import org.dominokit.domino.ui.keyboard.KeyboardEvents;
import org.dominokit.domino.ui.modals.ModalBackDrop;
import org.dominokit.domino.ui.style.Color;
import org.dominokit.domino.ui.utils.AppendStrategy;
import org.dominokit.domino.ui.utils.BaseDominoElement;
import org.dominokit.domino.ui.utils.DominoElement;
import org.dominokit.domino.ui.utils.HasBackground;
import org.dominokit.domino.ui.utils.IsCollapsible;
import org.dominokit.domino.ui.utils.KeyboardNavigation;
import org.dominokit.domino.ui.utils.LazyInitializer;
import org.jboss.elemento.EventType;
import org.jboss.elemento.IsElement;
/**
* A component which provides a dropdown menu relative to an element
*
* The menu can have different actions and can be placed at specific position
*
*
Customize the component can be done by overwriting classes provided by {@link DropDownStyles}
*
*
For example:
*
*
* DropDownMenu.create(element)
* .addAction(DropdownAction.create("action 1"))
* .open();
*
*
* @see BaseDominoElement
* @see HasBackground
*/
public class DropDownMenu extends BaseDominoElement
implements HasBackground {
private KeyboardNavigation> keyboardNavigation;
private final DominoElement element =
DominoElement.of(div()).css(DropDownStyles.DROPDOWN);
private final DominoElement menuElement =
DominoElement.of(ul()).css(DropDownStyles.DROPDOWN_MENU);
private final HTMLElement targetElement;
private DropDownPosition position = DropDownPosition.BOTTOM;
private final DominoElement titleContainer =
DominoElement.of(div()).addCss(DropDownStyles.DROPDOWN_TITLE_CONTAINER);
private final DominoElement searchContainer =
DominoElement.of(div()).css(DropDownStyles.DROPDOWN_SEARCH_CONTAINER);
private final DominoElement searchBox =
DominoElement.of(input("text")).css(DropDownStyles.DROPDOWN_SEARCH_BOX);
private DominoElement noSearchResultsElement;
private MdiIcon createIcon;
private String noMatchSearchResultText = "No results matched";
private final List> actions = new ArrayList<>();
private static boolean touchMoved;
private final List closeHandlers = new ArrayList<>();
private final List openHandlers = new ArrayList<>();
private boolean searchable = false;
private boolean creatable = false;
private boolean caseSensitiveSearch = false;
private final List> groups = new ArrayList<>();
private Color background;
private HTMLElement appendTarget = document.body;
private AppendStrategy appendStrategy = AppendStrategy.LAST;
private SearchFilter searchFilter =
(searchText, dropdownAction, caseSensitive) -> {
if (caseSensitive) {
return dropdownAction.getContent().textContent.contains(searchText);
} else {
return dropdownAction
.getContent()
.textContent
.toLowerCase()
.contains(searchText.toLowerCase());
}
};
private OnAdd addListener = (String search) -> {};
private LazyInitializer dropDownMenuInitializer;
static {
document.addEventListener(EventType.click.getName(), evt -> DropDownMenu.closeAllMenus());
document.addEventListener(EventType.touchmove.getName(), evt -> DropDownMenu.touchMoved = true);
document.addEventListener(
EventType.touchend.getName(),
evt -> {
if (!DropDownMenu.touchMoved) {
closeAllMenus();
}
DropDownMenu.touchMoved = false;
});
}
public DropDownMenu(HTMLElement targetElement) {
this.targetElement = targetElement;
init(this);
dropDownMenuInitializer =
new LazyInitializer(
() -> {
element.appendChild(searchContainer).appendChild(menuElement);
menuElement.setAttribute("role", "listbox");
element.addEventListener(EventType.touchend, Event::stopPropagation);
element.addEventListener(EventType.touchmove, Event::stopPropagation);
element.addEventListener(EventType.touchstart, Event::stopPropagation);
addMenuNavigationListener();
searchContainer.addClickListener(
evt -> {
evt.preventDefault();
evt.stopPropagation();
});
createIcon = Icons.ALL.plus_mdi().clickable();
searchContainer
.toggleDisplay(this.searchable)
.appendChild(
FlexLayout.create()
.appendChild(
FlexItem.create().appendChild(Icons.ALL.magnify_mdi().clickable()))
.appendChild(FlexItem.create().setFlexGrow(1).appendChild(searchBox))
.appendChild(
FlexItem.create()
.appendChild(
createIcon
.setAttribute("tabindex", "0")
.setAttribute("aria-expanded", "true")
.setAttribute("href", "#")
.toggleDisplay(this.creatable)
.addClickListener(
evt ->
addListener.onAdd(searchBox.element().value)))));
KeyboardEvents.listenOnKeyDown(createIcon)
.setDefaultOptions(
KeyboardEvents.KeyboardEventOptions.create()
.setPreventDefault(true)
.setStopPropagation(true))
.onEnter(evt -> addListener.onAdd(searchBox.element().value));
KeyboardEvents.listenOnKeyDown(searchBox)
.setDefaultOptions(
KeyboardEvents.KeyboardEventOptions.create()
.setPreventDefault(true)
.setStopPropagation(true))
.onArrowUp(evt -> keyboardNavigation.focusAt(lastVisibleActionIndex()))
.onArrowDown(evt -> keyboardNavigation.focusAt(firstVisibleActionIndex()))
.onEscape(evt -> close())
.onEnter(evt -> selectFirstSearchResult());
searchBox.addEventListener(
"input",
evt -> {
if (searchable) {
doSearch();
}
});
setNoSearchResultsElement(
DominoElement.of(li()).css(DropDownStyles.NO_RESULTS).hide().element());
menuElement.appendChild(noSearchResultsElement);
titleContainer.addClickListener(Event::stopPropagation);
});
}
private void selectFirstSearchResult() {
List> filteredAction = getFilteredAction();
if (!filteredAction.isEmpty()) {
selectAt(actions.indexOf(filteredAction.get(0)));
filteredAction.get(0).select();
}
}
private int firstVisibleActionIndex() {
for (int i = 0; i < actions.size(); i++) {
if (actions.get(i).isExpanded()) {
return i;
}
}
return 0;
}
private int lastVisibleActionIndex() {
for (int i = actions.size() - 1; i >= 0; i--) {
if (actions.get(i).isExpanded()) {
return i;
}
}
return 0;
}
private void doSearch() {
String searchValue = searchBox.element().value;
boolean thereIsValues = false;
for (DropdownAction> action : actions) {
action.setFilteredOut(false);
boolean contains = searchFilter.filter(searchValue, action, caseSensitiveSearch);
contains = contains && !action.isExcludeFromSearchResults();
if (!contains) {
action.filter();
} else {
thereIsValues = true;
action.deFilter();
}
}
if (!searchValue.isEmpty() && creatable) {
createIcon.active();
} else {
createIcon.inactive();
}
if (thereIsValues) {
noSearchResultsElement.hide();
} else {
noSearchResultsElement.show();
noSearchResultsElement.setTextContent(noMatchSearchResultText + " \"" + searchValue + "\"");
}
groups.forEach(DropdownActionsGroup::changeVisibility);
}
/** @return All {@link DropdownAction} filtered based on the search criteria */
public List> getFilteredAction() {
return actions.stream()
.filter(dropdownAction -> !dropdownAction.isFilteredOut())
.collect(Collectors.toList());
}
private void addMenuNavigationListener() {
keyboardNavigation =
KeyboardNavigation.create(actions)
.onSelect((event, dropdownAction) -> dropdownAction.select())
.focusCondition(IsCollapsible::isExpanded)
.onFocus(
item -> {
if (isOpened()) {
item.focus();
}
})
.onEscape(this::close);
element.addEventListener("keydown", keyboardNavigation);
}
/** Closes all current opened menus */
public static void closeAllMenus() {
NodeList elementsByName = document.body.querySelectorAll(".dropdown");
for (int i = 0; i < elementsByName.length; i++) {
HTMLElement item = Js.uncheckedCast(elementsByName.item(i));
close(item);
}
}
private static void close(HTMLElement item) {
item.remove();
}
/**
* Creates drop down menu relative to {@code targetElement}.
*
* The target element will be used to position the menu according to its location
*
* @param targetElement The target {@link HTMLElement}
* @return new instance
*/
public static DropDownMenu create(HTMLElement targetElement) {
return new DropDownMenu(targetElement);
}
/**
* Same as {@link DropDownMenu#create(HTMLElement)} but accepts a wrapper {@link IsElement}
*
* @param targetElement The {@link IsElement}
* @return new instance
*/
public static DropDownMenu create(IsElement> targetElement) {
return new DropDownMenu(targetElement.element());
}
/**
* Inserts an action at the first index
*
* @param action The {@link DropdownAction} to add
* @return same instance
*/
public DropDownMenu insertFirst(DropdownAction> action) {
dropDownMenuInitializer.doOnce(
() -> {
action.addSelectionHandler(
value -> {
if (action.isAutoClose()) {
close();
}
});
actions.add(0, action);
menuElement.insertFirst(action.element());
});
return this;
}
/**
* Adds an action
*
* @param action The {@link DropdownAction} to add
* @return same instance
*/
public DropDownMenu appendChild(DropdownAction> action) {
dropDownMenuInitializer.doOnce(
() -> {
action.addSelectionHandler(
value -> {
if (action.isAutoClose()) {
close();
}
});
actions.add(action);
action.setBackground(this.background);
menuElement.appendChild(action.element());
});
return this;
}
/** Use {@link DropDownMenu#appendChild(DropdownAction)} instead */
@Deprecated
public DropDownMenu addAction(DropdownAction> action) {
return appendChild(action);
}
/**
* Adds a separator element
*
* @return same instance
*/
public DropDownMenu separator() {
dropDownMenuInitializer.doOnce(
() ->
menuElement.appendChild(
DominoElement.of(li()).attr("role", "separator").css(DropDownStyles.DIVIDER)));
return this;
}
/** {@inheritDoc} */
@Override
public DropDownMenu appendChild(Node child) {
element.appendChild(child);
return this;
}
/** Closes the menu */
public void close() {
if (isOpened()) {
element.remove();
closeHandlers.forEach(CloseHandler::onClose);
}
}
/** Opens the menu */
public void open() {
open(true);
}
/**
* Opens the menu with a boolean to indicate if the first element should be focused
*
* @param focus true to focus the first element
*/
public void open(boolean focus) {
dropDownMenuInitializer.apply();
if (hasActions() || creatable) {
onAttached(
mutationRecord -> {
position.position(element.element(), targetElement);
if (searchable) {
searchBox.element().focus();
clearSearch();
} else if (focus) {
focus();
}
element.setCssProperty("z-index", ModalBackDrop.getNextZIndex() + 10 + "");
openHandlers.forEach(OpenHandler::onOpen);
DominoElement.of(targetElement).onDetached(targetDetach -> close());
onDetached(
detachRecord -> {
closeHandlers.forEach(CloseHandler::onClose);
});
});
if (!appendTarget.contains(element.element())) {
appendStrategy.onAppend(appendTarget, element.element());
}
}
}
/** Clears the current search */
public void clearSearch() {
dropDownMenuInitializer.ifInitialized(
() -> {
searchBox.element().value = "";
noSearchResultsElement.hide();
createIcon.inactive();
actions.forEach(DropdownAction::show);
});
}
/** @return True if the menu is opened, false otherwise */
public boolean isOpened() {
return element.isAttached();
}
/**
* Sets the position of the menu
*
* @param position The new {@link DropDownPosition}
* @return same instance
*/
public DropDownMenu setPosition(DropDownPosition position) {
this.position = position;
return this;
}
/** {@inheritDoc} */
@Override
public HTMLDivElement element() {
return element.element();
}
/**
* Clears all the actions
*
* @return same instance
*/
public DropDownMenu clearActions() {
dropDownMenuInitializer.apply();
menuElement.clearElement();
actions.clear();
groups.clear();
menuElement.appendChild(noSearchResultsElement);
return this;
}
/** @return True if it is has actions, false otherwise */
public boolean hasActions() {
return !actions.isEmpty();
}
/**
* Focuses an action at a specific {@code index}
*
* @param index the index of the action
* @return same instance
*/
public DropDownMenu selectAt(int index) {
if (index >= 0 && index < actions.size()) {
keyboardNavigation.focusAt(index);
}
return this;
}
/**
* Adds a close handler to be called when the menu is closed
*
* @param closeHandler The {@link CloseHandler} to add
* @return same instance
*/
public DropDownMenu addCloseHandler(CloseHandler closeHandler) {
closeHandlers.add(closeHandler);
return this;
}
/**
* Removes a close handler
*
* @param closeHandler The {@link CloseHandler} to remove
* @return same instance
*/
public DropDownMenu removeCloseHandler(CloseHandler closeHandler) {
closeHandlers.remove(closeHandler);
return this;
}
/**
* Adds an open handler to be called when the menu is opened
*
* @param openHandler The {@link OpenHandler} to add
* @return same instance
*/
public DropDownMenu addOpenHandler(OpenHandler openHandler) {
openHandlers.add(openHandler);
return this;
}
/**
* Removes an open handler
*
* @param openHandler The {@link OpenHandler} to remove
* @return same instance
*/
public DropDownMenu removeOpenHandler(OpenHandler openHandler) {
openHandlers.remove(openHandler);
return this;
}
/** @return All the actions */
public List> getActions() {
return actions;
}
/**
* Sets if the menu is searchable or not. Searchable menu will filter the actions based on the
* search criteria provided in the search element
*
* @param searchable true if this menu is searchable, false otherwise
* @return same instance
*/
public DropDownMenu setSearchable(boolean searchable) {
this.searchable = searchable;
dropDownMenuInitializer.whenInitialized(() -> searchContainer.toggleDisplay(this.searchable));
return this;
}
/**
* Sets if the menu accepts creating new actions on the fly.
*
* By configuring the menu as creatable means that the user can create a new action by setting
* the action text and then adding it directly to the actions list
*
* @param creatable true if the menu accepts creating new actions on the fly, false otherwise
* @return same instance
*/
public DropDownMenu setCreatable(boolean creatable) {
this.creatable = creatable;
dropDownMenuInitializer.ifInitialized(() -> createIcon.toggleDisplay(this.creatable));
return this;
}
/**
* Set the text which is displayed in case nothing is found at a searchable {@link DropDownMenu}
* Default is "No results matched".
*
* @param text the new text
* @return same instance
*/
public DropDownMenu setNoMatchSearchResultText(String text) {
this.noMatchSearchResultText = text;
return this;
}
/**
* Adds a listener that will be called when a new action is added
*
* @param onAdd the {@link OnAdd} listener to add
* @return same instance
*/
public DropDownMenu setOnAddListener(OnAdd onAdd) {
this.addListener = onAdd;
return this;
}
/**
* Adds new group of actions to this menu as a one unit
*
* @param group the {@link DropdownActionsGroup} to add
* @return same instance
*/
public DropDownMenu addGroup(DropdownActionsGroup> group) {
dropDownMenuInitializer.doOnce(
() -> {
groups.add(group);
menuElement.appendChild(group.element());
});
group.bindTo(this);
return this;
}
/**
* Sets the title of this menu
*
* @param title the title text
* @return same instance
*/
public DropDownMenu setTitle(String title) {
if (!element.contains(titleContainer)) {
element.insertFirst(titleContainer.appendChild(h(5).textContent(title)));
}
return this;
}
/**
* Sets the target element for this menu that will be positioned according to its location
*
* @param appendTarget the new target element
* @return same instance
*/
public DropDownMenu setAppendTarget(HTMLElement appendTarget) {
if (nonNull(appendTarget)) {
this.appendTarget = appendTarget;
}
return this;
}
/** @return The current target element */
public HTMLElement getAppendTarget() {
return this.appendTarget;
}
/**
* Sets the strategy for adding the menu to the target element.
*
* @param appendStrategy the {@link AppendStrategy}
* @return same instance
*/
public DropDownMenu setAppendStrategy(AppendStrategy appendStrategy) {
if (nonNull(appendStrategy)) {
this.appendStrategy = appendStrategy;
}
return this;
}
/** @return The current {@link AppendStrategy} */
public AppendStrategy getAppendStrategy() {
return this.appendStrategy;
}
/** @return The no search result element */
public DominoElement getNoSearchResultsElement() {
return noSearchResultsElement;
}
/**
* Sets the no search result element which will be shown when there is no results found according
* to the search criteria
*
* @param noSearchResultsElement the new no search results element
*/
public void setNoSearchResultsElement(HTMLElement noSearchResultsElement) {
this.noSearchResultsElement = DominoElement.of(noSearchResultsElement);
}
/** @return True if the search is case sensitive, false otherwise */
public boolean isCaseSensitiveSearch() {
return caseSensitiveSearch;
}
/**
* Sets if the search is case sensitive
*
* @param caseSensitiveSearch true if search is case sensitive, false otherwise
*/
public void setCaseSensitiveSearch(boolean caseSensitiveSearch) {
this.caseSensitiveSearch = caseSensitiveSearch;
}
/** @return The menu container element */
public DominoElement getMenuElement() {
return menuElement;
}
/** {@inheritDoc} */
@Override
public DropDownMenu setBackground(Color background) {
if (nonNull(background)) {
if (nonNull(this.background)) {
getMenuElement().removeCss(this.background.getBackground());
}
getMenuElement().addCss(background.getBackground());
this.background = background;
actions.forEach(dropdownAction -> dropdownAction.setBackground(background));
}
return this;
}
/** @return The search element container */
public DominoElement getSearchContainer() {
return searchContainer;
}
/** Sets focus at the first element of the menu */
public void focus() {
dropDownMenuInitializer.apply();
keyboardNavigation.focusAt(0);
}
/** @return The current search filter */
public SearchFilter getSearchFilter() {
return searchFilter;
}
/**
* Sets the search filter strategy that will be called to filter the actions based on the search
* value
*
* @param searchFilter The new {@link SearchFilter}
* @return same instance
*/
public DropDownMenu setSearchFilter(SearchFilter searchFilter) {
if (nonNull(searchFilter)) {
this.searchFilter = searchFilter;
}
return this;
}
/** A handler that will be called when closing the menu */
@FunctionalInterface
public interface CloseHandler {
/** Will be called when the menu is closed */
void onClose();
}
/** A handler that will be called when opening the menu */
@FunctionalInterface
public interface OpenHandler {
/** Will be called when the menu is opened */
void onOpen();
}
/** The search filter strategy which will filter the actions based on the search criteria */
@FunctionalInterface
public interface SearchFilter {
/**
* Checks if the {@code dropdownAction} should be displayed or not based on the {@code
* searchText}
*
* @param searchText the search criteria
* @param dropdownAction the {@link DropdownAction}
* @param caseSensitive case sensitive search or not
* @return true if the action should be displayed, false otherwise
*/
boolean filter(String searchText, DropdownAction> dropdownAction, boolean caseSensitive);
}
/** A handler that will be called when adding a new action */
@FunctionalInterface
public interface OnAdd {
/**
* Will be called when a new action is added
*
* @param input the content of the action
*/
void onAdd(String input);
}
}