You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@myfaces.apache.org by hn...@apache.org on 2020/03/02 08:23:22 UTC

[myfaces-tobago] 01/06: tobago-dropdown: custom elements, remove jQuery

This is an automated email from the ASF dual-hosted git repository.

hnoeth pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/myfaces-tobago.git

commit 9f6eb87e36722a9a7b08d719887b3fc59d2895a2
Author: Henning Noeth <hn...@apache.org>
AuthorDate: Wed Feb 26 13:21:43 2020 +0100

    tobago-dropdown: custom elements, remove jQuery
    
    * implement key navigation
    
    issue: TOBAGO-1633: TS refactoring
---
 .../myfaces/tobago/renderkit/css/TobagoClass.java  |   2 +
 tobago-core/src/main/resources/scss/_tobago.scss   |  19 +-
 .../src/main/npm/ts/tobago-dropdown.ts             | 205 +++++++++++++++++++--
 3 files changed, 202 insertions(+), 24 deletions(-)

diff --git a/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/css/TobagoClass.java b/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/css/TobagoClass.java
index bc2ab9a..b0b4d16 100644
--- a/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/css/TobagoClass.java
+++ b/tobago-core/src/main/java/org/apache/myfaces/tobago/renderkit/css/TobagoClass.java
@@ -121,6 +121,8 @@ public enum TobagoClass implements CssItem {
   BUTTONS("tobago-buttons"),
   COLLAPSED("tobago-collapsed"),
   DATE("tobago-date"),
+  DROPDOWN__OPEN("tobago-dropdown-open"),
+  DROPDOWN__SELECTED("tobago-dropdown-selected"),
   FILE("tobago-file"),
   FILE__PLACEHOLDER("tobago-file-placeholder"),
   FLEX_LAYOUT("tobago-flexLayout"),
diff --git a/tobago-core/src/main/resources/scss/_tobago.scss b/tobago-core/src/main/resources/scss/_tobago.scss
index ef9638a..dd11466 100644
--- a/tobago-core/src/main/resources/scss/_tobago.scss
+++ b/tobago-core/src/main/resources/scss/_tobago.scss
@@ -74,6 +74,7 @@ $input-focus-box-shadow: $input-btn-focus-box-shadow !default;
 $form-check-inline-input-margin-x: 0.75rem;
 $input-placeholder-color: $gray-600 !default;
 $grid-gutter-width: 30px !default;
+$dropdown-link-hover-color: darken($gray-900, 5%) !default;
 
 $page-padding-top: 1rem;
 
@@ -600,6 +601,14 @@ tobago-dropdown {
   display: inline-block;
 }
 
+tobago-dropdown, .tobago-page-menuStore {
+  .tobago-dropdown-selected {
+    color: $dropdown-link-hover-color;
+    text-decoration: none;
+    @include gradient-bg($dropdown-link-hover-bg);
+  }
+}
+
 ul > tobago-dropdown {
   display: list-item;
 }
@@ -647,11 +656,13 @@ ul > tobago-dropdown {
     margin-right: -10px;
   }
 
-  &:hover > .dropdown-menu {
-    display: block;
+  &:hover, &.tobago-dropdown-open {
+    > .dropdown-menu {
+      display: block;
 
-    > a:after {
-      border-left-color: $dropdown-bg;
+      > a:after {
+        border-left-color: $dropdown-bg;
+      }
     }
   }
 
diff --git a/tobago-theme/tobago-theme-standard/src/main/npm/ts/tobago-dropdown.ts b/tobago-theme/tobago-theme-standard/src/main/npm/ts/tobago-dropdown.ts
index 61e0898..f026821 100644
--- a/tobago-theme/tobago-theme-standard/src/main/npm/ts/tobago-dropdown.ts
+++ b/tobago-theme/tobago-theme-standard/src/main/npm/ts/tobago-dropdown.ts
@@ -19,39 +19,90 @@ import Popper from "popper.js";
 
 class Dropdown extends HTMLElement {
 
-  private closeFlag: boolean = false;
+  private dropdownEntries: DropdownEntry[] = [];
+  private activeDropdownEntry: DropdownEntry;
 
   constructor() {
     super();
+    if (!this.classList.contains("tobago-dropdown-submenu")) { // ignore submenus
+      const root = this.getRootNode() as ShadowRoot | Document;
 
-    this.toggleButton.addEventListener("mouseup", this.toggleDropdown.bind(this));
-    this.toggleButton.addEventListener("blur", this.setCloseFlag.bind(this));
-    window.addEventListener("mouseup", this.deselectComponent.bind(this));
+      this.createDropdownEntries(this.dropdownMenu, null);
+
+      this.toggleButton.addEventListener("click", this.toggleDropdown.bind(this));
+      root.addEventListener("mouseup", this.mouseupOnDocument.bind(this));
+      root.addEventListener("keydown", this.keydownOnDocument.bind(this));
+      root.addEventListener("keyup", this.keyupOnDocument.bind(this));
+    }
   }
 
   connectedCallback(): void {
-    //TODO add keyboard support
   }
 
   toggleDropdown(event: Event): void {
-    this.resetCloseFlag();
-
-    const visible: boolean = this.dropdownMenu.classList.contains("show");
-    if (visible) {
+    event.preventDefault();
+    event.stopPropagation();
+    if (this.dropdownVisible()) {
       this.closeDropdown();
     } else {
       this.openDropdown();
     }
   }
 
-  deselectComponent(event: Event): void {
-    const visible: boolean = this.dropdownMenu.classList.contains("show");
-    if (this.closeFlag && visible) {
-      this.resetCloseFlag();
+  mouseupOnDocument(event: MouseEvent): void {
+    if (!this.toggleButtonSelected() && this.dropdownVisible()) {
+      this.closeDropdown();
+    }
+  }
 
-      const target: HTMLElement = event.target as HTMLElement;
+  keydownOnDocument(event: KeyboardEvent): void {
+    if (this.dropdownVisible() && event.code === "Escape") {
+      event.preventDefault();
+      event.stopPropagation();
       this.closeDropdown();
-      target.dispatchEvent(new MouseEvent("click", {bubbles: true}));
+    } else if ((this.toggleButtonSelected() || this.dropdownVisible())
+        && (event.code === "ArrowUp" || event.code === "ArrowDown"
+            || event.code === "ArrowLeft" || event.code === "ArrowRight")) {
+      // prevent scrolling with arrow keys
+      event.preventDefault();
+      event.stopPropagation();
+    }
+  }
+
+  keyupOnDocument(event: KeyboardEvent): void {
+    const root = this.getRootNode() as ShadowRoot | Document;
+
+    if (this.toggleButtonSelected() && !this.dropdownVisible()
+        && (event.code === "ArrowUp" || event.code === "ArrowDown")) {
+      event.preventDefault();
+      event.stopPropagation();
+      this.openDropdown();
+      this.activeDropdownEntry = this.dropdownEntries[0];
+
+      const interval = setInterval(() => {
+        if (this.dropdownVisible()) {
+          this.activeDropdownEntry.focus();
+          clearInterval(interval);
+        }
+      }, 0);
+    } else if (this.activeDropdownEntry && this.dropdownVisible()) {
+      if (event.code === "ArrowUp" && this.activeDropdownEntry.previous) {
+        this.activeDropdownEntry.clearCss();
+        this.activeDropdownEntry = this.activeDropdownEntry.previous;
+        this.activeDropdownEntry.focus();
+      } else if (event.code === "ArrowDown" && this.activeDropdownEntry.next) {
+        this.activeDropdownEntry.clearCss();
+        this.activeDropdownEntry = this.activeDropdownEntry.next;
+        this.activeDropdownEntry.focus();
+      } else if (event.code === "ArrowRight" && this.activeDropdownEntry.children.length > 0) {
+        this.activeDropdownEntry = this.activeDropdownEntry.children[0];
+        this.activeDropdownEntry.focus();
+      } else if (event.code === "ArrowLeft" && this.activeDropdownEntry.parent) {
+        this.activeDropdownEntry.clearCss();
+        this.activeDropdownEntry = this.activeDropdownEntry.parent;
+        this.activeDropdownEntry.clearCss();
+        this.activeDropdownEntry.focus();
+      }
     }
   }
 
@@ -63,6 +114,10 @@ class Dropdown extends HTMLElement {
       });
     }
 
+    for (const dropdownEntry of this.dropdownEntries) {
+      dropdownEntry.clearCss();
+    }
+
     this.dropdownMenu.classList.add("show");
   }
 
@@ -75,9 +130,14 @@ class Dropdown extends HTMLElement {
     return this.querySelector(":scope > button[data-toggle='dropdown']");
   }
 
+  private toggleButtonSelected(): boolean {
+    const root = this.getRootNode() as ShadowRoot | Document;
+    return root.activeElement === this.toggleButton;
+  }
+
   private inStickyHeader(): boolean {
     const root = this.getRootNode() as ShadowRoot | Document;
-    return root.querySelector("header.tobago-header.sticky-top tobago-dropdown[id='" + this.id + "']") !== null;
+    return Boolean(root.querySelector("header.tobago-header.sticky-top tobago-dropdown[id='" + this.id + "']"));
   }
 
   private get dropdownMenu(): HTMLDivElement {
@@ -85,17 +145,122 @@ class Dropdown extends HTMLElement {
     return root.querySelector(".dropdown-menu[name='" + this.id + "']");
   }
 
+  private dropdownVisible(): boolean {
+    return this.dropdownMenu.classList.contains("show");
+  }
+
   private get menuStore(): HTMLDivElement {
     const root = this.getRootNode() as ShadowRoot | Document;
     return root.querySelector(".tobago-page-menuStore");
   }
 
-  setCloseFlag(event: Event): void {
-    this.closeFlag = true;
+  private createDropdownEntries(dropdownMenu: HTMLDivElement, parent: DropdownEntry): void {
+    let lastDropdownEntry: DropdownEntry = null;
+
+    for (const dropdownItem of dropdownMenu.children) {
+      if (dropdownItem.classList.contains("dropdown-item")) {
+        const entry = this.createDropdownEntry(dropdownItem as HTMLElement, parent, lastDropdownEntry);
+
+        lastDropdownEntry = entry;
+        this.dropdownEntries.push(entry);
+
+        if (dropdownItem.classList.contains("tobago-dropdown-submenu")) {
+          this.createDropdownEntries(dropdownItem.querySelector(".dropdown-menu"), entry);
+        }
+      } else {
+        const dropdownItems: NodeListOf<HTMLElement> = dropdownItem.querySelectorAll(".dropdown-item");
+        for (const dropdownItem of dropdownItems) {
+          const entry = this.createDropdownEntry(dropdownItem, parent, lastDropdownEntry);
+
+          lastDropdownEntry = entry;
+          this.dropdownEntries.push(entry);
+        }
+      }
+    }
+  }
+
+  private createDropdownEntry(
+      dropdownItem: HTMLElement, parent: DropdownEntry, previous: DropdownEntry): DropdownEntry {
+
+    const entry = new DropdownEntry(dropdownItem);
+    if (parent) {
+      entry.parent = parent;
+      parent.children.push(entry);
+    }
+
+    if (previous) {
+      previous.next = entry;
+      entry.previous = previous;
+    }
+
+    return entry;
+  }
+}
+
+class DropdownEntry {
+
+  private _previous: DropdownEntry;
+  private _next: DropdownEntry;
+  private _parent: DropdownEntry;
+  private _children: DropdownEntry[] = [];
+  private readonly _baseElement: HTMLElement;
+  private readonly focusElement: HTMLElement;
+
+  constructor(dropdownItem: HTMLElement) {
+    this._baseElement = dropdownItem;
+    if (dropdownItem.classList.contains("tobago-dropdown-submenu")) {
+      this.focusElement = dropdownItem.querySelector(".tobago-link");
+    } else if (dropdownItem.tagName === "LABEL") {
+      this.focusElement = dropdownItem.querySelector("input");
+    } else {
+      this.focusElement = dropdownItem;
+    }
+  }
+
+  get previous(): DropdownEntry {
+    return this._previous;
+  }
+
+  set previous(value: DropdownEntry) {
+    this._previous = value;
+  }
+
+  get next(): DropdownEntry {
+    return this._next;
+  }
+
+  set next(value: DropdownEntry) {
+    this._next = value;
+  }
+
+  get parent(): DropdownEntry {
+    return this._parent;
+  }
+
+  set parent(value: DropdownEntry) {
+    this._parent = value;
+  }
+
+  get children(): DropdownEntry[] {
+    return this._children;
+  }
+
+  set children(value: DropdownEntry[]) {
+    this._children = value;
+  }
+
+  public focus(): void {
+    if (this.parent) {
+      this.parent._baseElement.classList.add("tobago-dropdown-open");
+    }
+
+    this._baseElement.classList.add("tobago-dropdown-selected");
+    this.focusElement.focus();
   }
 
-  resetCloseFlag(): void {
-    this.closeFlag = false;
+  public clearCss(): void {
+    this._baseElement.classList.remove("tobago-dropdown-open");
+    this._baseElement.classList.remove("tobago-dropdown-selected");
   }
 }