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

org.dominokit.domino.ui.forms.AbstractSuggestBox 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.forms;

import static elemental2.dom.DomGlobal.document;
import static elemental2.dom.DomGlobal.window;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static org.dominokit.domino.ui.style.Unit.px;
import static org.jboss.elemento.Elements.div;

import elemental2.dom.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.dominokit.domino.ui.dropdown.DropDownMenu;
import org.dominokit.domino.ui.dropdown.DropDownPosition;
import org.dominokit.domino.ui.dropdown.DropdownAction;
import org.dominokit.domino.ui.forms.SuggestBoxStore.MissingEntryProvider;
import org.dominokit.domino.ui.forms.SuggestBoxStore.MissingSuggestProvider;
import org.dominokit.domino.ui.keyboard.KeyboardEvents;
import org.dominokit.domino.ui.loaders.Loader;
import org.dominokit.domino.ui.loaders.LoaderEffect;
import org.dominokit.domino.ui.style.Color;
import org.dominokit.domino.ui.utils.AppendStrategy;
import org.dominokit.domino.ui.utils.DelayedTextInput;
import org.dominokit.domino.ui.utils.DominoElement;
import org.dominokit.domino.ui.utils.DominoUIConfig;
import org.dominokit.domino.ui.utils.HasSelectionHandler;
import org.jboss.elemento.Elements;

/**
 * A component that dynamically loads suggestions from a {@link SuggestBoxStore} while the user is
 * typing
 *
 * @param  The type of the component extending form this class
 * @param  the type of the AbstractSuggestBox value
 */
public abstract class AbstractSuggestBox, V>
    extends AbstractValueBox
    implements HasSelectionHandler> {

  private static final String TEXT = "text";
  private DropDownMenu suggestionsMenu;
  private List>> selectionHandlers;
  private SuggestBoxStore store;
  private HTMLDivElement loaderContainer =
      DominoElement.of(div()).css("suggest-box-loader").element();
  private Loader loader;
  private boolean emptyAsNull;
  private Color highlightColor;
  private V value;
  private int typeAheadDelay = 200;
  private SuggestItem selectedItem;
  private DelayedTextInput delayedTextInput;
  private boolean focusOnClose = true;
  private DelayedTextInput.DelayedAction delayedAction =
      () -> {
        if (isEmptyIgnoreSpaces()) {
          suggestionsMenu.close();
          clearValue();
        } else {
          search();
        }
      };
  private boolean autoSelect = true;
  private String dropdownMaxWidth = null;

  /** Creates an instance without a label and a null store */
  public AbstractSuggestBox() {
    this("");
  }

  /**
   * Creates an instance with a label and a null store
   *
   * @param label String label
   */
  public AbstractSuggestBox(String label) {
    this(label, null);
  }

  /**
   * Creates an instance without a label and initialized with a store
   *
   * @param store {@link SuggestBoxStore}
   */
  public AbstractSuggestBox(SuggestBoxStore store) {
    this("", store);
  }

  /**
   * Creates an instance with a label and initialized with a store
   *
   * @param label String
   * @param store {@link SuggestBoxStore}
   */
  public AbstractSuggestBox(String label, SuggestBoxStore store) {
    this(TEXT, label, store);
  }

  /**
   * Creates an instance with a label and initialized with the input type and a store
   *
   * @param type String input element type
   * @param label String
   * @param store {@link SuggestBoxStore}
   */
  public AbstractSuggestBox(String type, String label, SuggestBoxStore store) {
    super(type, label);
    this.store = store;
    if (isNull(selectionHandlers)) {
      selectionHandlers = new ArrayList<>();
    }
    suggestionsMenu = DropDownMenu.create(fieldContainer);
    suggestionsMenu.setAppendTarget(document.body);
    suggestionsMenu.setAppendStrategy(AppendStrategy.FIRST);
    suggestionsMenu.setPosition(
        DominoUIConfig.INSTANCE.getDefaultSuggestPopupPosition().createPosition(this));
    suggestionsMenu.addCloseHandler(
        () -> {
          if (focusOnClose) {
            focus();
          }
        });
    Element element = document.querySelector(".content");
    if (nonNull(element)) {
      EventListener eventListener =
          evt -> {
            suggestionsMenu
                .style()
                .setMinWidth(element().offsetWidth + "px")
                .setMaxWidth(
                    nonNull(dropdownMaxWidth) ? dropdownMaxWidth : element().offsetWidth + "px");
          };
      element.addEventListener("transitionend", eventListener);
      onDetached(mutationRecord -> element.removeEventListener("transitionend", eventListener));
    }
    onAttached(
        mutationRecord -> {
          suggestionsMenu
              .style()
              .setMinWidth(element().offsetWidth + "px")
              .setMaxWidth(
                  nonNull(dropdownMaxWidth) ? dropdownMaxWidth : element().offsetWidth + "px");
        });
    getFieldInputContainer().insertFirst(loaderContainer);
    setLoaderEffect(LoaderEffect.IOS);

    delayedTextInput =
        DelayedTextInput.create(getInputElement(), typeAheadDelay).setDelayedAction(delayedAction);
    KeyboardEvents.listenOnKeyDown(getInputElement())
        .onArrowDown(
            evt -> {
              suggestionsMenu.focus();
              evt.preventDefault();
            })
        .onArrowUp(
            evt -> {
              suggestionsMenu.focus();
              evt.preventDefault();
            })
        .onEscape(
            evt -> {
              focus();
              evt.preventDefault();
            })
        .onEnter(
            evt -> {
              if (suggestionsMenu.isOpened() && !suggestionsMenu.getFilteredAction().isEmpty()) {
                evt.stopPropagation();
                evt.preventDefault();
                if (isAutoSelect()) {
                  List> filteredActions = suggestionsMenu.getFilteredAction();
                  suggestionsMenu.selectAt(
                      suggestionsMenu.getActions().indexOf(filteredActions.get(0)));
                  filteredActions.get(0).select();
                  suggestionsMenu.close();
                } else {
                  suggestionsMenu.focus();
                }
              }
            })
        .onTab(
            evt -> {
              if (suggestionsMenu.isOpened()) {
                evt.stopPropagation();
                evt.preventDefault();
                suggestionsMenu.focus();
              }
            });
  }

  /** Filter the items based on the currently typed text in the AbstractSuggestBox */
  public final void search() {
    if (store != null) {
      loader.start();
      suggestionsMenu.clearActions();
      suggestionsMenu.close();
      store.filter(
          getStringValue(),
          suggestions -> {
            selectedItem = null;
            suggestionsMenu.clearActions();

            if (suggestions.isEmpty()) {
              applyMissingEntry(getStringValue());
            }

            suggestions.forEach(
                suggestion -> {
                  suggestion.highlight(AbstractSuggestBox.this.getStringValue(), highlightColor);
                  suggestionsMenu.appendChild(dropdownAction(suggestion));
                });
            suggestionsMenu.open(false);
            loader.stop();
          });
    }
  }

  /** {@inheritDoc} */
  @Override
  protected HTMLInputElement createInputElement(String type) {
    return Elements.input(type).element();
  }

  /** @return int delay in milliseconds before triggering the search after the user stops typing */
  public int getTypeAheadDelay() {
    return typeAheadDelay;
  }

  /**
   * @param delayMilliseconds int delay in milliseconds before triggering the search after the user
   *     stops typing
   * @return same AbstractSuggestBox instance
   */
  public T setTypeAheadDelay(int delayMilliseconds) {
    this.typeAheadDelay = delayMilliseconds;
    this.delayedTextInput.setDelay(delayMilliseconds);
    return (T) this;
  }

  /** @return the {@link org.dominokit.domino.ui.utils.DelayedTextInput.DelayedAction} */
  public DelayedTextInput.DelayedAction getDelayedAction() {
    return delayedAction;
  }

  /**
   * Set a custom action to be executed after the user stops typing that override the default search
   * action
   *
   * @param delayedAction {@link org.dominokit.domino.ui.utils.DelayedTextInput.DelayedAction}
   * @return same AbstractSuggestBox instance
   */
  public T setDelayedAction(DelayedTextInput.DelayedAction delayedAction) {
    this.delayedAction = delayedAction;
    this.delayedTextInput.setDelayedAction(delayedAction);
    return (T) this;
  }

  /**
   * Sets the action to be executed when the user press Enter to override the default search action
   *
   * @param onEnterAction {@link org.dominokit.domino.ui.utils.DelayedTextInput.DelayedAction}
   * @return same AbstractSuggestBox instance
   */
  public T setOnEnterAction(DelayedTextInput.DelayedAction onEnterAction) {
    this.delayedTextInput.setOnEnterAction(onEnterAction);
    return (T) this;
  }

  /** {@inheritDoc} */
  @Override
  protected void clearValue(boolean silent) {
    value(null, silent);
  }

  /** {@inheritDoc} */
  @Override
  protected void doSetValue(V value) {
    if (nonNull(store)) {
      store.find(
          value,
          suggestItem -> {
            if (nonNull(suggestItem)) {
              this.value = value;
              getInputElement().element().value = suggestItem.getDisplayValue();
            } else {
              if (!applyMissingValue(value)) {
                this.value = null;
                getInputElement().element().value = "";
              }
            }
          });
    }
  }

  private boolean applyMissingValue(V value) {
    MissingSuggestProvider messingSuggestionProvider = store.getMessingSuggestionProvider();
    Optional> messingSuggestion =
        messingSuggestionProvider.getMessingSuggestion(value);
    return applyMissing(messingSuggestion);
  }

  private boolean applyMissingEntry(String value) {
    MissingEntryProvider messingEntryProvider = store.getMessingEntryProvider();
    Optional> messingSuggestion = messingEntryProvider.getMessingSuggestion(value);
    return applyMissing(messingSuggestion);
  }

  private boolean applyMissing(Optional> messingSuggestion) {
    if (messingSuggestion.isPresent()) {
      SuggestItem messingSuggestItem = messingSuggestion.get();
      this.value = messingSuggestItem.getValue();
      getInputElement().element().value = messingSuggestItem.getDisplayValue();
      return true;
    }
    return false;
  }

  /** {@inheritDoc} */
  @Override
  public V getValue() {
    if (isNull(selectedItem)) {
      applyMissingEntry(getStringValue());
    }

    return this.value;
  }

  /**
   * @param store {@link SuggestBoxStore}
   * @return same AbstractSuggestBox instance
   */
  public T setSuggestBoxStore(SuggestBoxStore store) {
    this.store = store;
    return (T) this;
  }

  /**
   * @param type String type of the htmml input element
   * @return same AbstractSuggestBox instance
   */
  public T setType(String type) {
    getInputElement().element().type = type;
    return (T) this;
  }

  /** {@inheritDoc} */
  @Override
  public String getStringValue() {
    String stringValue = getInputElement().element().value;
    if (stringValue.isEmpty() && isEmptyAsNull()) {
      return null;
    }
    return stringValue;
  }

  protected final DropdownAction dropdownAction(SuggestItem suggestItem) {
    DropdownAction dropdownAction = suggestItem.asDropDownAction();
    dropdownAction.addSelectionHandler(
        value -> {
          selectedItem = suggestItem;
          setValue(value);
          selectionHandlers.forEach(handler -> handler.onSelection(suggestItem));
          suggestionsMenu.close();
        });
    return dropdownAction;
  }

  /** {@inheritDoc} */
  @Override
  public T addSelectionHandler(SelectionHandler> selectionHandler) {
    if (isNull(selectionHandlers)) {
      selectionHandlers = new ArrayList<>();
    }
    selectionHandlers.add(selectionHandler);
    return (T) this;
  }

  /** {@inheritDoc} */
  @Override
  public T removeSelectionHandler(SelectionHandler> selectionHandler) {
    selectionHandlers.remove(selectionHandler);
    return (T) this;
  }

  /**
   * Sets a custom loader effect to be visible while the store is retrieving the suggestions
   *
   * @param loaderEffect {@link LoaderEffect}
   * @return same AbstractSuggestBox instance
   */
  public T setLoaderEffect(LoaderEffect loaderEffect) {
    loader =
        Loader.create(loaderContainer, loaderEffect)
            .setSize("20px", "20px")
            .setRemoveLoadingText(true);
    return (T) this;
  }

  /** @return the {@link Loader} used by the AbstractSuggestBox */
  public Loader getLoader() {
    return loader;
  }

  /**
   * @param emptyAsNull boolean, if ture empty value will be considered null otherwise it is an
   *     empty String
   * @return same AbstractSuggestBox instance
   */
  public T setEmptyAsNull(boolean emptyAsNull) {
    this.emptyAsNull = emptyAsNull;
    return (T) this;
  }

  /** @return boolean, true if empty value will be considered null otherwise false */
  public boolean isEmptyAsNull() {
    return emptyAsNull;
  }

  /** @return the {@link SuggestBoxStore} of this AbstractSuggestBox */
  public SuggestBoxStore getStore() {
    return store;
  }

  /** @return the {@link DelayedTextInput} of this AbstractSuggestBox */
  public DelayedTextInput getDelayedTextInput() {
    return delayedTextInput;
  }

  /** @return the {@link DropDownMenu} of the AbstractSuggestBox */
  public DropDownMenu getSuggestionsMenu() {
    return suggestionsMenu;
  }

  /**
   * @return color to be used to highlight parts of the SuggestItems that matches the typed string
   */
  public Color getHighlightColor() {
    return highlightColor;
  }

  /**
   * Set the color to be used to highlight parts of the SuggestItems that matches the typed String
   * in the text input
   *
   * @param highlightColor {@link Color}
   * @return same AbstractSuggestBox instance
   */
  public T setHighlightColor(Color highlightColor) {
    this.highlightColor = highlightColor;
    return (T) this;
  }

  /** {@inheritDoc} */
  @Override
  protected AutoValidator createAutoValidator(AutoValidate autoValidate) {
    return new SuggestAutoValidator<>(this, autoValidate);
  }

  /** @return boolean, true if autoSelect is enabled */
  public boolean isAutoSelect() {
    return autoSelect;
  }

  /**
   * @param autoSelect boolean, if true pressing enter will automatically select the first entry
   *     from the Suggestions menu
   * @return same AbstractSuggestBox instance
   */
  public T setAutoSelect(boolean autoSelect) {
    this.autoSelect = autoSelect;
    return (T) this;
  }

  /** @return maximal width of dropdown */
  public String getDropdownMaxWidth() {
    return dropdownMaxWidth;
  }

  /**
   * @param dropdownMaxWidth maximal width of dropdown. if null - equals width of control
   * @return same AbstractSuggestBox instance
   */
  public T setDropdownMaxWidth(String dropdownMaxWidth) {
    this.dropdownMaxWidth = dropdownMaxWidth;
    return (T) this;
  }

  /** @return boolean, true if the focusOnClose is enabled */
  public boolean isFocusOnClose() {
    return focusOnClose;
  }

  /**
   * @param focusOnClose boolean, if true after closing the suggestions menu the focus will go back
   *     to the suggest box input
   * @return same AbstractSuggestBox instance
   */
  public T setFocusOnClose(boolean focusOnClose) {
    this.focusOnClose = focusOnClose;
    return (T) this;
  }

  /**
   * A {@link DropDownPosition} that opens the suggestion dropdown menu up or down based on the
   * largest space available, the menu will show where the is more space
   */
  public static class PopupPositionTopDown implements DropDownPosition {

    private DropDownPositionUp up = new DropDownPositionUp();
    private DropDownPositionDown down = new DropDownPositionDown();
    private AbstractSuggestBox suggestBox;

    public PopupPositionTopDown(AbstractSuggestBox suggestBox) {
      this.suggestBox = suggestBox;
    }

    /** {@inheritDoc} */
    @Override
    public void position(HTMLElement popup, HTMLElement target) {
      DOMRect targetRect = target.getBoundingClientRect();

      double distanceToMiddle = targetRect.top + (targetRect.height / 2);
      double windowMiddle = window.innerHeight / 2;
      double popupHeight = popup.getBoundingClientRect().height;
      double distanceToBottom = window.innerHeight - targetRect.bottom;
      double distanceToTop = targetRect.top;

      boolean hasSpaceBelow = distanceToBottom > popupHeight;
      boolean hasSpaceUp = distanceToTop > popupHeight;

      if ((distanceToMiddle >= windowMiddle) && hasSpaceUp) {
        up.position(popup, target);
        popup.setAttribute("popup-direction", "top");
      } else {
        down.position(popup, target);
        popup.setAttribute("popup-direction", "down");
      }

      popup.style.setProperty("min-width", targetRect.width + "px");
      popup.style.setProperty(
          "max-width",
          nonNull(suggestBox.dropdownMaxWidth)
              ? suggestBox.dropdownMaxWidth
              : targetRect.width + "px");
    }
  }

  /** A {@link DropDownPosition} that opens the suggestion dropdown menu up */
  public static class DropDownPositionUp implements DropDownPosition {
    /** {@inheritDoc} */
    @Override
    public void position(HTMLElement actionsMenu, HTMLElement target) {

      DOMRect targetRect = target.getBoundingClientRect();

      actionsMenu.style.setProperty(
          "bottom", px.of(window.innerHeight - targetRect.top - window.pageYOffset));
      actionsMenu.style.setProperty("left", px.of(targetRect.left + window.pageXOffset));
      actionsMenu.style.removeProperty("top");
    }
  }

  /** A {@link DropDownPosition} that opens the suggestion dropdown menu down */
  public static class DropDownPositionDown implements DropDownPosition {

    /** {@inheritDoc} */
    @Override
    public void position(HTMLElement actionsMenu, HTMLElement target) {

      DOMRect targetRect = target.getBoundingClientRect();

      actionsMenu.style.setProperty("top", px.of(targetRect.bottom + window.pageYOffset));
      actionsMenu.style.setProperty("left", px.of(targetRect.left + window.pageXOffset));
      actionsMenu.style.removeProperty("bottom");
    }
  }

  private static class SuggestAutoValidator extends AutoValidator {

    private AbstractSuggestBox suggestBox;
    private SelectionHandler> selectionHandler;

    public SuggestAutoValidator(AbstractSuggestBox suggestBox, AutoValidate autoValidate) {
      super(autoValidate);
      this.suggestBox = suggestBox;
    }

    @Override
    public void attach() {
      selectionHandler = option -> autoValidate.apply();
      suggestBox.addSelectionHandler(selectionHandler);
    }

    @Override
    public void remove() {
      suggestBox.removeSelectionHandler(selectionHandler);
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy