From b6f5b236b38d5ef00a9c1d12b39c584ba6f3b544 Mon Sep 17 00:00:00 2001
From: Kris <kris.aubuchon@discourse.org>
Date: Mon, 24 Oct 2022 19:22:02 -0400
Subject: [PATCH] UX: drag new user menus, scroll primary user nav (#18690)

---
 .../app/components/horizontal-overflow-nav.js | 72 +++++++++++-----
 .../discourse/app/components/user-nav.hbs     |  4 +-
 .../components/horizontal-overflow-nav.hbs    |  8 +-
 .../stylesheets/common/base/new-user.scss     | 84 +++----------------
 .../stylesheets/common/components/_index.scss |  1 +
 .../components/horizontal-overflow-nav.scss   | 78 +++++++++++++++++
 6 files changed, 150 insertions(+), 97 deletions(-)
 create mode 100644 app/assets/stylesheets/common/components/horizontal-overflow-nav.scss

diff --git a/app/assets/javascripts/discourse/app/components/horizontal-overflow-nav.js b/app/assets/javascripts/discourse/app/components/horizontal-overflow-nav.js
index 79d0a065190..24d2f4292f9 100644
--- a/app/assets/javascripts/discourse/app/components/horizontal-overflow-nav.js
+++ b/app/assets/javascripts/discourse/app/components/horizontal-overflow-nav.js
@@ -12,10 +12,8 @@ export default class HorizontalOverflowNav extends Component {
   scrollInterval;
 
   @bind
-  scrollToActive() {
-    const activeElement = document.querySelector(
-      ".user-navigation-secondary a.active"
-    );
+  scrollToActive(element) {
+    const activeElement = element.querySelector("a.active");
 
     activeElement?.scrollIntoView({
       block: "nearest",
@@ -24,14 +22,14 @@ export default class HorizontalOverflowNav extends Component {
   }
 
   @bind
-  checkScroll(element) {
+  checkScroll(event) {
     if (this.site.mobileView) {
       return;
     }
 
-    this.watchScroll(element);
+    this.watchScroll(event);
     return (this.hasScroll =
-      element.target.scrollWidth > element.target.offsetWidth);
+      event.target.scrollWidth > event.target.offsetWidth);
   }
 
   @bind
@@ -40,14 +38,14 @@ export default class HorizontalOverflowNav extends Component {
   }
 
   @bind
-  watchScroll(element) {
+  watchScroll(event) {
     if (this.site.mobileView) {
       return;
     }
 
     if (
-      element.target.offsetWidth + element.target.scrollLeft ===
-      element.target.scrollWidth
+      event.target.offsetWidth + event.target.scrollLeft ===
+      event.target.scrollWidth
     ) {
       this.hideRightScroll = true;
       clearInterval(this.scrollInterval);
@@ -55,7 +53,7 @@ export default class HorizontalOverflowNav extends Component {
       this.hideRightScroll = false;
     }
 
-    if (element.target.scrollLeft === 0) {
+    if (event.target.scrollLeft === 0) {
       this.hideLeftScroll = true;
       clearInterval(this.scrollInterval);
     } else {
@@ -63,21 +61,57 @@ export default class HorizontalOverflowNav extends Component {
     }
   }
 
-  @action
-  horizScroll(element) {
-    let scrollSpeed = 100;
-    let siblingTarget = element.target.previousElementSibling;
+  @bind
+  scrollDrag(event) {
+    if (this.site.mobileView) {
+      return;
+    }
 
-    if (element.target.dataset.direction === "left") {
+    event.preventDefault();
+
+    const navPills = event.target.closest(".nav-pills");
+
+    const position = {
+      left: navPills.scrollLeft, // current scroll
+      x: event.clientX, // mouse position
+    };
+
+    const mouseDragScroll = function (e) {
+      let mouseChange = e.clientX - position.x;
+      navPills.scrollLeft = position.left - mouseChange;
+
+      navPills.querySelectorAll("a").forEach((a) => {
+        a.style.cursor = "grabbing";
+      });
+    };
+
+    const removeDragScroll = function () {
+      document.removeEventListener("mousemove", mouseDragScroll);
+
+      navPills.querySelectorAll("a").forEach((a) => {
+        a.style.cursor = "pointer";
+      });
+    };
+
+    document.addEventListener("mousemove", mouseDragScroll);
+    document.addEventListener("mouseup", removeDragScroll);
+  }
+
+  @action
+  horizScroll(event) {
+    let scrollSpeed = 175;
+    let siblingTarget = event.target.previousElementSibling;
+
+    if (event.target.dataset.direction === "left") {
       scrollSpeed = scrollSpeed * -1;
-      siblingTarget = element.target.nextElementSibling;
+      siblingTarget = event.target.nextElementSibling;
     }
 
     this.scrollInterval = setInterval(function () {
       siblingTarget.scrollLeft += scrollSpeed;
     }, 50);
 
-    element.target.addEventListener("mouseup", this.stopScroll);
-    element.target.addEventListener("mouseleave", this.stopScroll);
+    event.target.addEventListener("mouseup", this.stopScroll);
+    event.target.addEventListener("mouseleave", this.stopScroll);
   }
 }
diff --git a/app/assets/javascripts/discourse/app/components/user-nav.hbs b/app/assets/javascripts/discourse/app/components/user-nav.hbs
index 6a832c2d89f..4caa7d2e7a6 100644
--- a/app/assets/javascripts/discourse/app/components/user-nav.hbs
+++ b/app/assets/javascripts/discourse/app/components/user-nav.hbs
@@ -1,5 +1,5 @@
 <section class="user-navigation user-navigation-primary">
-  <ul class="main-nav nav nav-pills user-nav">
+  <HorizontalOverflowNav @className="main-nav nav user-nav">
     {{#unless @user.profile_hidden}}
       <li class="summary">
         <LinkTo @route="user.summary">
@@ -74,5 +74,5 @@
         </a>
       </li>
     {{/if}}
-  </ul>
+  </HorizontalOverflowNav>
 </section>
diff --git a/app/assets/javascripts/discourse/app/templates/components/horizontal-overflow-nav.hbs b/app/assets/javascripts/discourse/app/templates/components/horizontal-overflow-nav.hbs
index d726eb8eefc..e3963b966e4 100644
--- a/app/assets/javascripts/discourse/app/templates/components/horizontal-overflow-nav.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/horizontal-overflow-nav.hbs
@@ -1,4 +1,5 @@
 {{!-- template-lint-disable no-down-event-binding --}}
+{{!-- template-lint-disable no-invalid-interactive --}}
 
 <div class="horizontal-overflow-nav {{if this.hasScroll "has-scroll"}}">
   {{#if this.hasScroll}}
@@ -6,7 +7,7 @@
       role="button"
       {{on "mousedown" this.horizScroll}}
       data-direction="left"
-      class="nav-overflow__scroll-left btn-flat {{if this.hideLeftScroll "transparent"}}"
+      class="horizontal-overflow-nav__scroll-left {{if this.hideLeftScroll "disabled"}}"
     >
       {{d-icon "chevron-left"}}
     </a>
@@ -16,7 +17,8 @@
     {{on-resize this.checkScroll}}
     {{on "scroll" this.watchScroll}}
     {{did-insert this.scrollToActive}}
-    class="nav-pills action-list"
+    {{on "mousedown" this.scrollDrag}}
+    class="nav-pills action-list {{@className}}"
   >
     {{yield}}
   </ul>
@@ -25,7 +27,7 @@
     <a
       role="button"
       {{on "mousedown" this.horizScroll}}
-      class="nav-overflow__scroll-right btn-flat {{if this.hideRightScroll "transparent"}}"
+      class="horizontal-overflow-nav__scroll-right {{if this.hideRightScroll "disabled"}}"
     >
       {{d-icon "chevron-right"}}
     </a>
diff --git a/app/assets/stylesheets/common/base/new-user.scss b/app/assets/stylesheets/common/base/new-user.scss
index c666ce84612..85772e3fe3e 100644
--- a/app/assets/stylesheets/common/base/new-user.scss
+++ b/app/assets/stylesheets/common/base/new-user.scss
@@ -2,9 +2,8 @@
   margin-top: -15px; // temp, can remove margin from sibling element after nav finalized
   .user-navigation {
     --user-navigation__border-width: 4px;
-    &.user-navigation-primary {
-      border-bottom: 1px solid var(--primary-low);
-    }
+    border-bottom: 1px solid var(--primary-low);
+
     .nav-pills {
       width: 100%;
       margin: 0;
@@ -37,7 +36,6 @@
       li {
         flex: 1 1 auto;
         margin: 0;
-        overflow: hidden;
         display: flex;
 
         a {
@@ -80,6 +78,15 @@
     }
   }
 
+  .user-navigation-primary {
+    [class*="horizontal-overflow-nav__scroll"] {
+      font-size: var(--font-up-1);
+      .d-icon {
+        margin-top: 0.15em; // minor alignment
+      }
+    }
+  }
+
   .user-navigation-secondary {
     --user-navigation__border-width: 2px;
     position: relative;
@@ -89,12 +96,6 @@
     gap: 0 0.5em;
     border-bottom: 1px solid var(--primary-low);
 
-    .horizontal-overflow-nav {
-      position: relative;
-      min-width: 0;
-      width: 100%;
-    }
-
     .select-kit .select-kit-header {
       height: 100%;
       padding: 0.5em 1em;
@@ -116,73 +117,10 @@
       }
     }
 
-    .nav-overflow__scroll-right,
-    .nav-overflow__scroll-left {
-      --fade-width: 20px;
-      opacity: 1;
-      position: absolute;
-      z-index: 2;
-      background-color: var(--secondary);
-      top: 0;
-      bottom: 0;
-      display: flex;
-      align-items: center;
-      transition: opacity 0.25s;
-      .d-icon {
-        pointer-events: none;
-        margin-bottom: 0.2em;
-        color: var(--quaternary);
-      }
-      &.transparent {
-        // hiding with opacity so we can transition visibility
-        opacity: 0;
-        pointer-events: none;
-      }
-    }
-
-    .nav-overflow__scroll-right {
-      right: 0;
-      &:before {
-        content: "";
-        margin-left: -1.5em;
-        height: 100%;
-        width: 1.5em;
-        background: linear-gradient(
-          to left,
-          rgba(var(--secondary-rgb), 1),
-          rgba(var(--secondary-rgb), 0)
-        );
-      }
-    }
-
-    .nav-overflow__scroll-left {
-      left: 0;
-      &:after {
-        content: "";
-        margin-right: -1.5em;
-        height: 100%;
-        width: 1.5em;
-        background: linear-gradient(
-          to right,
-          rgba(var(--secondary-rgb), 1),
-          rgba(var(--secondary-rgb), 0)
-        );
-      }
-    }
-
     .nav-pills {
       flex: 1 1 auto;
       font-size: var(--font-down-1);
       justify-content: flex-start;
-      overflow: auto;
-      position: relative;
-      scroll-behavior: smooth;
-
-      // hides scrollbars, but allows mouse scrolling
-      scrollbar-width: none;
-      &::-webkit-scrollbar {
-        height: 0;
-      }
 
       li {
         flex: 1 0 auto;
diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss
index a054cd44783..361546e0532 100644
--- a/app/assets/stylesheets/common/components/_index.scss
+++ b/app/assets/stylesheets/common/components/_index.scss
@@ -15,6 +15,7 @@
 @import "group-member-dropdown";
 @import "groups-form-membership-fields";
 @import "hashtag";
+@import "horizontal-overflow-nav";
 @import "iframed-html";
 @import "ignored-user-list";
 @import "keyboard_shortcuts";
diff --git a/app/assets/stylesheets/common/components/horizontal-overflow-nav.scss b/app/assets/stylesheets/common/components/horizontal-overflow-nav.scss
new file mode 100644
index 00000000000..9f2f817c02e
--- /dev/null
+++ b/app/assets/stylesheets/common/components/horizontal-overflow-nav.scss
@@ -0,0 +1,78 @@
+.horizontal-overflow-nav {
+  position: relative;
+  min-width: 0;
+  width: 100%;
+  .nav-pills {
+    overflow: auto;
+    min-width: 0;
+    position: relative;
+
+    // avoids auto-scroll on initial load if active nav item is overflowed
+    scroll-behavior: auto;
+
+    // hides scrollbars, but allows mouse scrolling
+    scrollbar-width: none;
+    &::-webkit-scrollbar {
+      height: 0;
+    }
+  }
+  &.has-scroll {
+    .nav-pills {
+      scroll-behavior: smooth; // smooth scrolling on user interaction
+    }
+  }
+}
+
+.horizontal-overflow-nav__scroll-right,
+.horizontal-overflow-nav__scroll-left {
+  --fade-width: 1.5em;
+  opacity: 1;
+  position: absolute;
+  z-index: 2;
+  background-color: var(--secondary);
+  top: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  transition: opacity 0.25s;
+  .d-icon {
+    pointer-events: none;
+    margin-bottom: 0.2em;
+    color: var(--quaternary);
+  }
+  &.disabled {
+    // hiding with opacity so we can transition visibility
+    opacity: 0;
+    pointer-events: none;
+  }
+}
+
+.horizontal-overflow-nav__scroll-right {
+  right: 0;
+  &:before {
+    content: "";
+    margin-left: calc(var(--fade-width) * -1);
+    height: 100%;
+    width: var(--fade-width);
+    background: linear-gradient(
+      to left,
+      rgba(var(--secondary-rgb), 1),
+      rgba(var(--secondary-rgb), 0)
+    );
+  }
+}
+
+.horizontal-overflow-nav__scroll-left {
+  left: 0;
+  &:after {
+    content: "";
+    margin-right: calc(var(--fade-width) * -1);
+    height: 100%;
+    width: var(--fade-width);
+    background: linear-gradient(
+      to right,
+      rgba(var(--secondary-rgb), 1),
+      rgba(var(--secondary-rgb), 0)
+    );
+  }
+}