tobago-tree-listbox: custom elements

* add custom html tag
* adjust style
* fix encoding/decoding state
* refactor tobago-tree-listbox.ts

Issue: TOBAGO-1633
diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/renderkit/renderer/TreeListboxRenderer.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/renderkit/renderer/TreeListboxRenderer.java
index 98e7b6b..dc4dc8b 100644
--- a/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/renderkit/renderer/TreeListboxRenderer.java
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/internal/renderkit/renderer/TreeListboxRenderer.java
@@ -28,7 +28,8 @@
 import org.apache.myfaces.tobago.internal.component.AbstractUITreeSelect;
 import org.apache.myfaces.tobago.internal.util.HtmlRendererUtils;
 import org.apache.myfaces.tobago.internal.util.JsonUtils;
-import org.apache.myfaces.tobago.internal.util.RenderUtils;
+import org.apache.myfaces.tobago.model.SelectedState;
+import org.apache.myfaces.tobago.model.TreePath;
 import org.apache.myfaces.tobago.renderkit.RendererBase;
 import org.apache.myfaces.tobago.renderkit.css.TobagoClass;
 import org.apache.myfaces.tobago.renderkit.html.DataAttributes;
@@ -55,7 +56,7 @@
   @Override
   public void decode(final FacesContext facesContext, final UIComponent component) {
     final AbstractUITree tree = (AbstractUITree) component;
-    RenderUtils.decodedStateOfTreeData(facesContext, tree);
+    decodeState(facesContext, tree);
   }
 
   @Override
@@ -71,7 +72,7 @@
     final String clientId = tree.getClientId(facesContext);
     final Markup markup = tree.getMarkup();
 
-    writer.startElement(HtmlElements.DIV);
+    writer.startElement(HtmlElements.TOBAGO_TREE_LISTBOX);
     writer.writeIdAttribute(clientId);
     writer.writeClassAttribute(
         TobagoClass.TREE_LISTBOX,
@@ -83,7 +84,7 @@
     writer.writeAttribute(HtmlAttributes.TYPE, HtmlInputTypes.HIDDEN);
     writer.writeNameAttribute(clientId + SUB_SEPARATOR + AbstractUIData.SUFFIX_SELECTED);
     writer.writeIdAttribute(clientId + SUB_SEPARATOR + AbstractUIData.SUFFIX_SELECTED);
-    writer.writeAttribute(HtmlAttributes.VALUE, JsonUtils.encodeEmptyArray(), false);
+    writer.writeAttribute(HtmlAttributes.VALUE, encodeState(tree), false);
     writer.endElement(HtmlElements.INPUT);
 
     List<Integer> thisLevel = new ArrayList<>();
@@ -129,7 +130,7 @@
       writer.endElement(HtmlElements.DIV);
     }
 
-    writer.endElement(HtmlElements.DIV);
+    writer.endElement(HtmlElements.TOBAGO_TREE_LISTBOX);
 
     tree.setRowIndex(-1);
   }
@@ -188,4 +189,49 @@
 
     writer.endElement(HtmlElements.SELECT);
   }
+
+  private void decodeState(final FacesContext facesContext, final AbstractUITree tree) {
+    final String hiddenInputId = tree.getClientId(facesContext) + ComponentUtils.SUB_SEPARATOR + AbstractUIData.SUFFIX_SELECTED;
+    final String selectedIndicesString = facesContext.getExternalContext().getRequestParameterMap().get(hiddenInputId);
+    final List<Integer> selectedIndices = JsonUtils.decodeIntegerArray(selectedIndicesString);
+    final SelectedState selectedState = tree.getSelectedState();
+
+    final int last = tree.isRowsUnlimited() ? Integer.MAX_VALUE : tree.getFirst() + tree.getRows();
+    for (int rowIndex = tree.getFirst(); rowIndex < last; rowIndex++) {
+      tree.setRowIndex(rowIndex);
+      if (!tree.isRowAvailable()) {
+        break;
+      }
+
+      final TreePath path = tree.getPath();
+
+      if (selectedIndices != null && selectedIndices.equals(JsonUtils.decodeIntegerArray(path.toString()))) {
+        selectedState.select(path);
+      } else {
+        selectedState.unselect(path);
+      }
+    }
+    tree.setRowIndex(-1);
+  }
+
+  private String encodeState(final AbstractUITreeListbox tree) {
+    final SelectedState selectedState = tree.getSelectedState();
+
+    final int last = tree.isRowsUnlimited() ? Integer.MAX_VALUE : tree.getFirst() + tree.getRows();
+    for (int rowIndex = tree.getFirst(); rowIndex < last; rowIndex++) {
+      tree.setRowIndex(rowIndex);
+      if (!tree.isRowAvailable()) {
+        break;
+      }
+
+      final TreePath path = tree.getPath();
+
+      if (selectedState.isSelected(path)) {
+        return path.toString();
+      }
+    }
+    tree.setRowIndex(-1);
+
+    return JsonUtils.encodeEmptyArray();
+  }
 }
diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/html/HtmlElements.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/html/HtmlElements.java
index ede3059..0cf0900 100644
--- a/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/html/HtmlElements.java
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/html/HtmlElements.java
@@ -161,6 +161,7 @@
   TOBAGO_TAB_CONTENT("tobago-tab-content"),
   TOBAGO_TAB_GROUP("tobago-tab-group"),
   TOBAGO_TREE("tobago-tree"),
+  TOBAGO_TREE_LISTBOX("tobago-tree-listbox"),
   TOBAGO_TREE_NODE("tobago-tree-node"),
   TOBAGO_TREE_SELECT("tobago-tree-select");
 
diff --git a/tobago-core/src/main/resources/scss/_tobago.scss b/tobago-core/src/main/resources/scss/_tobago.scss
index ff14401..96c4f6c 100644
--- a/tobago-core/src/main/resources/scss/_tobago.scss
+++ b/tobago-core/src/main/resources/scss/_tobago.scss
@@ -1520,16 +1520,17 @@
 }
 
 /* treeListbox ---------------------------------------------------------------------- */
-.tobago-treeListbox {
-}
+tobago-tree-listbox, .tobago-treeListbox {
+  display: block;
 
-.tobago-treeListbox-level {
-  display: inline-block;
-  min-width: 10rem;
-}
+  .tobago-treeListbox-level {
+    display: inline-block;
+    min-width: 10rem;
+  }
 
-.tobago-treeListbox-select {
-  width: 100%;
+  .tobago-treeListbox-select {
+    width: 100%;
+  }
 }
 
 /* textarea --------------------------------------------------------- */
diff --git a/tobago-theme/tobago-theme-standard/src/main/npm/ts/tobago-tree-listbox.ts b/tobago-theme/tobago-theme-standard/src/main/npm/ts/tobago-tree-listbox.ts
index 4513241..0eb5659 100644
--- a/tobago-theme/tobago-theme-standard/src/main/npm/ts/tobago-tree-listbox.ts
+++ b/tobago-theme/tobago-theme-standard/src/main/npm/ts/tobago-tree-listbox.ts
@@ -15,90 +15,122 @@
  * limitations under the License.
  */
 
-import {Listener, Phase} from "./tobago-listener";
 import {DomUtils} from "./tobago-utils";
 
-class TreeListbox {
+class TreeListbox extends HTMLElement {
 
-  id: string;
+  constructor() {
+    super();
+  }
 
-  static init = function (element: HTMLElement): void {
-    for (const treeListbox of DomUtils.selfOrElementsByClassName(element, "tobago-treeListbox")) {
-      new TreeListbox(treeListbox);
-    }
-  };
+  connectedCallback(): void {
+    this.applySelected();
 
-  constructor(element: HTMLElement) {
-
-    this.id = element.id;
-
-    const selects = element.getElementsByTagName("select");
-    for (let i = 0; i < selects.length; i++) {
-      const listbox = selects.item(i) as HTMLSelectElement;
-      // hide select tags for level > root
-      if (listbox.previousElementSibling) {
-        listbox.classList.add("d-none");
-      }
-
-      // add on change on all select tag, all options that are not selected hide there dedicated
-      // select tag, and the selected option show its dedicated select tag.
+    for (const listbox of this.listboxes) {
       if (!listbox.disabled) {
-        listbox.addEventListener("change", this.onChange.bind(this));
+        listbox.addEventListener("change", this.select.bind(this));
       }
     }
   }
 
-  onChange(event: TextEvent): void {
-    let listbox = event.currentTarget as HTMLSelectElement;
-    for (const child of listbox.children) {
-      const option = child as HTMLOptionElement;
-      if (option.tagName === "OPTION") {
-        if (option.selected) {
-          this.setSelected(option);
-          let select = document.getElementById(option.id + DomUtils.SUB_COMPONENT_SEP + "parent") as HTMLSelectElement;
-          if (!select) {
-            select = listbox.parentElement.nextElementSibling.children[0] as HTMLSelectElement; // dummy
-          }
-          select.classList.remove("d-none");
-          for (const sibling of listbox.parentElement.nextElementSibling.children) {
-            if (sibling === select) {
-              (sibling as HTMLElement).classList.remove("d-none");
-            } else {
-              (sibling as HTMLElement).classList.add("d-none");
-            }
-          }
+  private select(event: Event): void {
+    const listbox = event.currentTarget as HTMLSelectElement;
+    this.unselectDescendants(listbox);
+    this.setSelected();
+    this.applySelected();
+  }
+
+  private unselectDescendants(select: HTMLSelectElement): void {
+    let unselect: boolean = false;
+    for (const listbox of this.listboxes) {
+      if (unselect) {
+        const checkedOption = listbox.querySelector<HTMLOptionElement>("option:checked");
+        if (checkedOption) {
+          checkedOption.selected = false;
         }
+      } else if (listbox.id === select.id) {
+        unselect = true;
       }
     }
+  }
 
-    // Deeper level (2nd and later) should only show the empty select tag.
-    // The first child is the empty selection.
+  private setSelected(): void {
+    const selected: number[] = [];
+    for (const level of this.levelElements) {
+      const checkedOption: HTMLOptionElement = level
+          .querySelector(".tobago-treeListbox-select:not(.d-none) option:checked");
+      if (checkedOption) {
+        selected.push(checkedOption.index);
+      }
+    }
+    this.hiddenInput.value = JSON.stringify(selected);
+  }
 
-    let next = listbox.parentElement.nextElementSibling;
-    if (next) {
-      for (next = next.nextElementSibling; next; next = next.nextElementSibling) {
-        for (const child of next.children) {
-          const select = child as HTMLSelectElement;
-          if (select.previousElementSibling) { // is not the first
-            select.classList.add("d-none");
-          } else { // is the first
-            select.classList.remove("d-none");
-          }
+  private applySelected(): void {
+    const selected: number[] = JSON.parse(this.hiddenInput.value);
+    let nextActiveSelectId: string = this.querySelector(".tobago-treeListbox-select").id;
+
+    const levelElements = this.levelElements;
+    for (let i = 0; i < levelElements.length; i++) {
+      const level = levelElements[i];
+
+      for (const select of this.getSelectElements(level)) {
+        if (select.id === nextActiveSelectId || (nextActiveSelectId === null && select.disabled)) {
+          const check: number = i < selected.length ? selected[i] : null;
+          this.show(select, check);
+          nextActiveSelectId = this.getNextActiveSelectId(select, check);
+        } else {
+          this.hide(select);
         }
       }
     }
   }
 
-  setSelected(option: HTMLOptionElement): void {
-    const hidden = document.getElementById(this.id + DomUtils.SUB_COMPONENT_SEP + "selected") as HTMLInputElement;
-    if (hidden) {
-      let value = <number[]>JSON.parse(hidden.value);
-      value = []; // todo: multi-select
-      value.push(parseInt(option.dataset.tobagoRowIndex));
-      hidden.value = JSON.stringify(value);
+  private getSelectElements(level: HTMLDivElement): NodeListOf<HTMLSelectElement> {
+    return level.querySelectorAll<HTMLSelectElement>(".tobago-treeListbox-select");
+  }
+
+  private getNextActiveSelectId(select: HTMLSelectElement, check: number): string {
+    if (check !== null) {
+      const option = select.querySelectorAll("option")[check];
+      return option.id + DomUtils.SUB_COMPONENT_SEP + "parent";
+    } else {
+      return null;
     }
   }
+
+  private show(select: HTMLSelectElement, check: number): void {
+    select.classList.remove("d-none");
+    const checkedOption = select.querySelector<HTMLOptionElement>("option:checked");
+    if (checkedOption && checkedOption.index !== check) {
+      checkedOption.selected = false;
+    }
+    if (check !== null && checkedOption.index !== check) {
+      select.querySelectorAll("option")[check].selected = true;
+    }
+  }
+
+  private hide(select: HTMLSelectElement): void {
+    select.classList.add("d-none");
+    const checkedOption = select.querySelector<HTMLOptionElement>("option:checked");
+    if (checkedOption) {
+      checkedOption.selected = false;
+    }
+  }
+
+  private get listboxes(): NodeListOf<HTMLSelectElement> {
+    return this.querySelectorAll(".tobago-treeListbox-select");
+  }
+
+  private get levelElements(): NodeListOf<HTMLDivElement> {
+    return this.querySelectorAll(".tobago-treeListbox-level");
+  }
+
+  private get hiddenInput(): HTMLInputElement {
+    return this.querySelector(DomUtils.escapeClientId(this.id + DomUtils.SUB_COMPONENT_SEP + "selected"));
+  }
 }
 
-Listener.register(TreeListbox.init, Phase.DOCUMENT_READY);
-Listener.register(TreeListbox.init, Phase.AFTER_UPDATE);
+document.addEventListener("DOMContentLoaded", function (event: Event): void {
+  window.customElements.define("tobago-tree-listbox", TreeListbox);
+});