Просмотр исходного кода

Closes #16392: Allow collapsing the navigation sidebar to icons (#23487)

Fold the sidebar to an icon rail with a menu button in the top bar.
Hover or keyboard focus unfolds it, and the choice is kept per browser.
The logo stays pinned while the menu scrolls, and the unused pin state
left over from v3.x is removed.
Martin Hauser 5 часов назад
Родитель
Сommit
356500ac4d

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 13 - 0
netbox/project-static/js/setmode.js

@@ -39,3 +39,16 @@ function initMode() {
     // Default to light mode
     return setMode("light", true);
 }
+
+/**
+ * Fold the sidebar before rendering if the user folded it.
+ */
+function initSidebar() {
+    try {
+        if (localStorage.getItem("netbox-sidebar") === "folded") {
+            document.documentElement.setAttribute("data-bs-sidebar", "folded-hover");
+        }
+    } catch {
+        // Blocked storage leaves the sidebar expanded
+    }
+}

+ 116 - 172
netbox/project-static/src/sidenav.ts

@@ -1,15 +1,14 @@
-import { StateManager } from './state';
 import { getElements, isElement } from './util';
 
-type NavState = { pinned: boolean };
-type BodyAttr = 'show' | 'hide' | 'hidden' | 'pinned';
-
 // Keep in sync with Bootstrap's `lg` breakpoint and `navbar-expand-lg` in base/layout.html.
 const SIDENAV_DESKTOP_MEDIA = '(min-width: 992px)';
 
-// Session-scoped, unlike the localStorage `netbox-sidenav` pin state.
+// Session-scoped, unlike the localStorage fold state.
 const SCROLL_STATE_KEY = 'netbox-sidenav-scroll';
 
+// Read before the first paint by initSidebar() in js/setmode.js.
+const FOLD_STATE_KEY = 'netbox-sidebar';
+
 class SideNav {
   /**
    * Sidenav container element.
@@ -17,145 +16,133 @@ class SideNav {
   private base: HTMLElement;
 
   /**
-   * SideNav internal state manager.
+   * The logo and the menu, which scroll together in the desktop layout.
+   */
+  private scrollContainer: HTMLElement;
+
+  /**
+   * The desktop layout, the only one where the sidebar folds.
    */
-  private state: StateManager<NavState>;
+  private desktopMedia: MediaQueryList;
 
   /**
    * First nav item matching the current page, cached at construction.
    */
   private activePageLink: Nullable<HTMLDivElement> = null;
 
+  /**
+   * Menu offset of the unfolded layout, the expanded sidebar or the hover overlay. The compact
+   * rail hides the open groups, so it cannot hold this offset.
+   */
+  private unfoldedScrollTop = 0;
+
+  /**
+   * A folded rail reveals the active item on its first hover, unless the unfolded menu was
+   * scrolled before.
+   */
+  private revealPending = false;
+
   constructor(base: HTMLElement) {
     this.base = base;
-    this.state = new StateManager<NavState>(
-      { pinned: true },
-      { persist: true, key: 'netbox-sidenav' },
-    );
+    this.scrollContainer = base.querySelector<HTMLElement>(':scope > .container-fluid') ?? base;
+    this.desktopMedia = window.matchMedia(SIDENAV_DESKTOP_MEDIA);
 
     this.init();
     this.initLinks();
     this.initScrollPosition();
+    this.initMenuScroll();
   }
 
   /**
-   * Determine if `document.body` has a sidenav attribute.
-   */
-  private bodyHas(attr: BodyAttr): boolean {
-    return document.body.hasAttribute(`data-sidenav-${attr}`);
-  }
-
-  /**
-   * Remove sidenav attributes from `document.body`.
+   * Whether the sidebar is set to fold on desktop.
    */
-  private bodyRemove(...attrs: BodyAttr[]): void {
-    for (const attr of attrs) {
-      document.body.removeAttribute(`data-sidenav-${attr}`);
-    }
+  private isFolded(): boolean {
+    return document.documentElement.getAttribute('data-bs-sidebar') === 'folded-hover';
   }
 
   /**
-   * Add sidenav attributes to `document.body`.
+   * Whether the menu shows its unfolded layout, expanded or opened by hover or keyboard focus.
    */
-  private bodyAdd(...attrs: BodyAttr[]): void {
-    for (const attr of attrs) {
-      document.body.setAttribute(`data-sidenav-${attr}`, '');
-    }
+  private isUnfolded(): boolean {
+    return !this.isFolded() || this.base.matches(':hover, :has(:focus-visible)');
   }
 
   /**
    * Set initial values & add event listeners.
    */
-  private init() {
-    for (const toggler of this.base.querySelectorAll('.sidenav-toggle')) {
-      toggler.addEventListener('click', event => this.onToggle(event));
-    }
-
-    for (const toggler of getElements<HTMLButtonElement>('.sidenav-toggle-mobile')) {
-      toggler.addEventListener('click', event => this.onMobileToggle(event));
+  private init(): void {
+    for (const toggle of getElements<HTMLButtonElement>('.sidebar-toggle')) {
+      toggle.addEventListener('click', () => this.setFolded(!this.isFolded()));
     }
+    this.syncToggles();
 
-    const desktopMedia = window.matchMedia(SIDENAV_DESKTOP_MEDIA);
-    this.setResponsiveState(desktopMedia.matches);
-    desktopMedia.addEventListener('change', event => {
-      this.setResponsiveState(event.matches);
+    // Below lg the menu does not scroll and loses its offset.
+    this.desktopMedia.addEventListener('change', () => {
       this.initLinks();
+      if (!this.desktopMedia.matches) return;
+      if (this.isUnfolded()) this.scrollContainer.scrollTop = this.unfoldedScrollTop;
     });
 
-    window.addEventListener('resize', () => this.onResize());
-
-    this.base.addEventListener('mouseenter', () => this.onEnter());
-    this.base.addEventListener('mouseleave', () => this.onLeave());
-  }
-
-  /**
-   * Apply the appropriate sidenav state for the current responsive layout.
-   */
-  private setResponsiveState(isDesktop: boolean): void {
-    this.bodyRemove('hide');
+    // The hover overlay returns to where the unfolded menu was, unless keyboard focus opened it.
+    this.base.addEventListener('mouseenter', () => {
+      if (this.isFolded() && !this.base.matches(':has(:focus-visible)')) {
+        this.scrollContainer.scrollTop = this.unfoldedScrollTop;
+        if (this.revealPending && this.desktopMedia.matches) {
+          this.revealPending = false;
+          this.revealActiveLink();
+        }
+      }
+    });
 
-    if (isDesktop && this.state.get('pinned')) {
-      this.bodyRemove('hidden');
-      this.bodyAdd('show', 'pinned');
-    } else {
-      this.bodyRemove('show', 'pinned');
-      this.bodyAdd('hidden');
-    }
+    // A mouse unpin would leave focus on the pin that the folded rail clips away.
+    this.base.addEventListener('mouseleave', () => {
+      if (!this.isFolded()) return;
+      for (const toggle of getElements<HTMLButtonElement>('.sidebar-toggle')) {
+        if (toggle === document.activeElement && !toggle.matches(':focus-visible')) {
+          toggle.blur();
+        }
+      }
+    });
   }
 
   /**
-   * If the sidenav is shown, expand active nav links. Otherwise, collapse them.
+   * Expand the active menu group on desktop, folded or not, and collapse it on mobile.
    */
   private initLinks(): void {
     for (const link of this.getActiveLinks()) {
       this.activePageLink ??= link;
-
-      if (this.bodyHas('show')) {
-        this.activateLink(link, 'expand');
-      } else if (this.bodyHas('hidden')) {
-        this.activateLink(link, 'collapse');
-      }
+      this.activateLink(link, this.desktopMedia.matches ? 'expand' : 'collapse');
     }
   }
 
   /**
-   * Show the sidenav.
-   */
-  private show(): void {
-    this.bodyAdd('show');
-    this.bodyRemove('hidden', 'hide');
-  }
-
-  /**
-   * Hide the sidenav and close any nested collapse elements.
+   * Fold or unfold the desktop sidebar and remember the choice. Open menu groups stay open.
    */
-  private hide(): void {
-    this.bodyAdd('hidden');
-    this.bodyRemove('pinned', 'show');
-    for (const collapse of this.base.querySelectorAll('.collapse')) {
-      collapse.classList.remove('show');
+  private setFolded(folded: boolean): void {
+    if (folded) {
+      document.documentElement.setAttribute('data-bs-sidebar', 'folded-hover');
+    } else {
+      document.documentElement.removeAttribute('data-bs-sidebar');
+      // Keyboard focus keeps its place, since the pin scrolls with the menu.
+      if (!this.base.matches(':has(:focus-visible)')) {
+        this.scrollContainer.scrollTop = this.unfoldedScrollTop;
+      }
     }
+    try {
+      localStorage.setItem(FOLD_STATE_KEY, folded ? 'folded' : 'expanded');
+    } catch {
+      // A failed write still applies to the current page.
+    }
+    this.syncToggles();
   }
 
   /**
-   * Pin the sidenav.
-   */
-  private pin(): void {
-    this.bodyAdd('show', 'pinned');
-    this.bodyRemove('hidden');
-    this.state.set('pinned', true);
-  }
-
-  /**
-   * Unpin the sidenav.
+   * Mark the pin buttons pressed while the sidebar is pinned.
    */
-  private unpin(): void {
-    this.bodyRemove('pinned', 'show');
-    this.bodyAdd('hidden');
-    for (const collapse of this.base.querySelectorAll('.collapse')) {
-      collapse.classList.remove('show');
+  private syncToggles(): void {
+    for (const toggle of getElements<HTMLButtonElement>('.sidebar-toggle')) {
+      toggle.setAttribute('aria-pressed', String(!this.isFolded()));
     }
-    this.state.set('pinned', false);
   }
 
   /**
@@ -212,106 +199,63 @@ class SideNav {
   }
 
   /**
-   * Whether the aside currently overflows, as opposed to whether the layout lets it scroll at all.
-   */
-  private isScrollable(): boolean {
-    return this.base.scrollHeight > this.base.clientHeight;
-  }
-
-  /**
-   * Restore the offset saved for this browser tab, reveal the active item when it falls outside
-   * the sidebar viewport, and save the offset again on the way out. One offset per tab, so a
-   * shorter menu can clamp a deeper value.
+   * Restore this tab's saved offset, reveal the active item when it falls outside the viewport,
+   * and save the unfolded offset on pagehide. A folded sidebar restores on every pointer hover
+   * and reveals on the first. One offset per tab, so a shorter menu can clamp a deeper value.
    */
   private initScrollPosition(): void {
-    const initiallyScrollable = this.isScrollable();
     const storedScrollTop = sessionStorage.getItem(SCROLL_STATE_KEY);
 
     // Leave a missing offset alone so the browser's own restoration survives.
     if (storedScrollTop !== null) {
-      this.base.scrollTop = Number(storedScrollTop);
+      this.unfoldedScrollTop = Number(storedScrollTop);
+      // The pointer can already rest on a folded rail.
+      if (this.isUnfolded()) {
+        this.scrollContainer.scrollTop = this.unfoldedScrollTop;
+      }
     }
 
     // Reveal only in the fixed desktop layout. In the mobile flow layout, scrollIntoView would scroll the page.
-    if (window.matchMedia(SIDENAV_DESKTOP_MEDIA).matches) {
-      const menu = this.activePageLink?.closest<HTMLElement>('.nav-item.dropdown');
-      // A closed dropdown has no box to measure, so reveal its heading instead.
-      const target =
-        menu && !menu.querySelector('.dropdown-menu.show') ? menu : this.activePageLink;
-
-      target?.scrollIntoView({ block: 'nearest' });
+    if (this.desktopMedia.matches) {
+      if (this.isUnfolded()) {
+        this.revealActiveLink();
+      } else {
+        this.revealPending = true;
+      }
     }
 
+    // Only scrolling changes the offset, so a menu that cannot scroll keeps the stored one.
     window.addEventListener('pagehide', () => {
-      // Zero counts only from a sidebar that was already scrollable at load, not one that grew on hover.
-      if (this.isScrollable() && (initiallyScrollable || this.base.scrollTop > 0)) {
-        sessionStorage.setItem(SCROLL_STATE_KEY, String(this.base.scrollTop));
-      }
+      sessionStorage.setItem(SCROLL_STATE_KEY, String(this.unfoldedScrollTop));
     });
   }
 
   /**
-   * Show the sidenav and expand any active menu groups.
-   */
-  private onEnter(): void {
-    if (!this.bodyHas('pinned')) {
-      this.bodyRemove('hide', 'hidden');
-      this.bodyAdd('show');
-      for (const link of this.getActiveLinks()) {
-        this.activateLink(link, 'expand');
-      }
-    }
-  }
-
-  /**
-   * Hide the sidenav and collapse any active menu groups.
+   * Scroll the active item into the menu viewport when it falls outside.
    */
-  private onLeave(): void {
-    if (!this.bodyHas('pinned')) {
-      this.bodyRemove('show');
-      this.bodyAdd('hide');
-      for (const link of this.getActiveLinks()) {
-        this.activateLink(link, 'collapse');
-      }
-      this.bodyRemove('hide');
-      this.bodyAdd('hidden');
-    }
-  }
+  private revealActiveLink(): void {
+    const menu = this.activePageLink?.closest<HTMLElement>('.nav-item.dropdown');
+    // A closed dropdown has no box to measure, so reveal its heading instead.
+    const target = menu && !menu.querySelector('.dropdown-menu.show') ? menu : this.activePageLink;
 
-  /**
-   * Close the (unpinned) sidenav when the window is resized.
-   */
-  private onResize(): void {
-    if (this.bodyHas('show') && !this.bodyHas('pinned')) {
-      this.bodyRemove('show');
-      this.bodyAdd('hidden');
-    }
+    target?.scrollIntoView({ block: 'nearest' });
   }
 
   /**
-   * Pin & unpin the sidenav when the pin button is toggled.
+   * Track the unfolded menu's offset.
    */
-  private onToggle(event: Event): void {
-    event.preventDefault();
-
-    if (this.state.get('pinned')) {
-      this.unpin();
-    } else {
-      this.pin();
-    }
-  }
-
-  /**
-   * Handle sidenav visibility state for small screens. On small screens, there is no pinned state,
-   * only open/closed.
-   */
-  private onMobileToggle(event: Event): void {
-    event.preventDefault();
-    if (this.bodyHas('hidden')) {
-      this.show();
-    } else {
-      this.hide();
-    }
+  private initMenuScroll(): void {
+    this.scrollContainer.addEventListener(
+      'scroll',
+      () => {
+        // Neither the compact rail nor the mobile menu holds the unfolded desktop offset.
+        if (this.desktopMedia.matches && this.isUnfolded()) {
+          this.unfoldedScrollTop = this.scrollContainer.scrollTop;
+          this.revealPending = false;
+        }
+      },
+      { passive: true },
+    );
   }
 }
 

+ 1 - 0
netbox/project-static/styles/_tabler-config.scss

@@ -7,6 +7,7 @@
   $font-family-sans-serif: netbox.$font-family-sans-serif,
   $font-family-monospace: netbox.$font-family-monospace,
   $sidebar-width: netbox.$sidebar-width,
+  $sidebar-folded-width: netbox.$sidebar-folded-width,
   $btn-padding-x: netbox.$btn-padding-x,
   $btn-padding-y: netbox.$btn-padding-y,
   $table-cell-padding-x: netbox.$table-cell-padding-x,

+ 3 - 0
netbox/project-static/styles/_variables.scss

@@ -7,6 +7,9 @@ $font-family-monospace: 'Roboto Mono Variable', monospace;
 // Set the navigation sidebar width
 $sidebar-width: 18rem;
 
+// Folded sidebar width, centering the 1.25rem menu icons between their 1rem link paddings
+$sidebar-folded-width: 3.25rem;
+
 // Reduce the default button padding
 $btn-padding-x: 0.5rem;
 $btn-padding-y: 0.25rem;

+ 98 - 6
netbox/project-static/styles/transitional/_navigation.scss

@@ -1,21 +1,45 @@
 // Navbar and light theme styling
 .navbar-vertical.navbar-expand-lg {
-  // Scroll the whole sidebar as Tabler 1.4 did. sidenav.ts saves and restores its offset.
   @include media-breakpoint-up(lg) {
-    overflow-y: auto;
+    // Clip the wide logo while the hover unfold grows the width, without making the aside scroll
+    overflow-x: clip;
 
+    // Scroll the logo and the menu together. sidenav.ts saves and restores the offset.
     > [class^='container'] {
-      height: auto;
-      min-height: 100%;
+      // The pin's containing block, so it scrolls with the logo
+      position: relative;
+      overflow-y: auto;
     }
     .navbar-collapse {
       min-height: auto;
       overflow-y: visible;
     }
     .navbar-brand {
+      // Tabler's folded overflow: hidden would let a short window shrink the logo row
+      flex-shrink: 0;
       justify-content: center;
     }
 
+    // The top bar buttons' box, which Tabler sets on .navbar .navbar-nav .nav-link
+    $pin-box: 2.5rem;
+
+    .sidebar-toggle {
+      position: absolute;
+      // The logo's middle, from Tabler's brand padding
+      top: $navbar-height * 0.5;
+      // From the end, clear of a scrollbar. The box centers on an icon at the menu icons' inset.
+      inset-inline-end: calc(
+        var(--tblr-page-padding) - #{($pin-box - $sidebar-nav-icon-width) * 0.5}
+      );
+      justify-content: center;
+      min-width: $pin-box;
+      min-height: $pin-box;
+      border-radius: var(--tblr-border-radius);
+      // .nav-link's hover tint, which only .navbar-nav defines
+      --tblr-nav-link-hover-bg: var(--tblr-hover-bg);
+      translate: 0 -50%;
+    }
+
     // Tabler 1.6 drops the bar that marks the active menu group
     .navbar-collapse .nav-item.active:after {
       content: '';
@@ -114,7 +138,8 @@
     left: 0;
     mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.3) 100%);
     opacity: 0.5;
-    position: fixed;
+    // The aside clips it while the hover unfold grows
+    position: absolute;
     user-drag: none;
     user-select: none;
     -moz-user-select: none;
@@ -129,6 +154,72 @@
   }
 }
 
+// Tabler hides submenus opened by class on a folded sidebar, keep them for the hover unfold
+html[data-bs-sidebar='folded-hover'] .navbar-vertical.navbar-expand-lg {
+  @include media-breakpoint-up(lg) {
+    .navbar-collapse .navbar-nav .dropdown-menu.show {
+      display: block;
+    }
+
+    // Keep the expanded width against the aside's flex row, so the unfold reveals the logo and
+    // the menu instead of rewrapping them
+    > [class^='container'] {
+      flex-shrink: 0;
+      width: $sidebar-width;
+    }
+  }
+}
+
+// initSidebar() sets the attribute before the first paint
+html:not([data-bs-sidebar='folded-hover']) .sidebar-toggle .mdi-pin-off,
+html[data-bs-sidebar='folded-hover'] .sidebar-toggle .mdi-pin {
+  display: none;
+}
+
+// Folded rail, unfolded again by hover or keyboard focus (Tabler's folded-hover)
+html[data-bs-sidebar='folded-hover']
+  .navbar-vertical.navbar-expand-lg:not(:hover):not(:has(:focus-visible)) {
+  @include media-breakpoint-up(lg) {
+    // Clip the expanded-width content to the rail, since NetBox opens no flyouts
+    overflow-x: clip;
+
+    // Crop the logo to its leading square mark, centered over the icons
+    .navbar-brand {
+      --tblr-sidebar-brand-padding-x: #{($sidebar-folded-width - $navbar-brand-image-height) * 0.5};
+      justify-content: flex-start;
+    }
+    .navbar-brand-image {
+      width: $navbar-brand-image-height;
+      object-fit: cover;
+      object-position: left center;
+    }
+
+    // Collapse the edition label like the titles. Its line stays, so nothing shifts on unfold.
+    .netbox-edition {
+      width: 0;
+      overflow: hidden;
+      opacity: 0;
+    }
+
+    // The release info stays as an invisible spacer, so the menu keeps its height
+    .netbox-release {
+      visibility: hidden;
+    }
+    // The 18rem motif does not fit the rail
+    img.motif {
+      display: none;
+    }
+
+    // Open submenus stay open, out of view but in the accessibility tree, until the rail unfolds.
+    // Without Tabler's flyout offset and min-width, the hidden box stays inside the menu.
+    .dropdown-menu.show {
+      inset-inline-start: 0 !important;
+      min-width: 0 !important;
+      @include visually-hidden();
+    }
+  }
+}
+
 // Light theme styling
 html[data-bs-theme='light'] .navbar-vertical.navbar-expand-lg {
   // Background Gradient
@@ -161,7 +252,8 @@ html[data-bs-theme='dark'] .navbar-vertical.navbar-expand-lg {
       background-color: $navbar-dark-active-bg !important;
     }
   }
-  .text-secondary {
+  // The pin keeps the top bar's button color
+  .text-secondary:not(.sidebar-toggle) {
     color: $bright-teal !important;
   }
 

+ 4 - 1
netbox/templates/base/base.html

@@ -24,7 +24,7 @@
     {# Page title #}
     <title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title>
 
-    {# Initialize color mode #}
+    {# Initialize color mode and sidebar folding #}
     <script
       type="text/javascript"
       src="{% static_with_params 'setmode.js' v=settings.RELEASE.version %}"
@@ -36,6 +36,9 @@
       })();
       window.CSRF_TOKEN = "{{ csrf_token }}";
     </script>
+    <script type="text/javascript">
+      initSidebar();
+    </script>
 
     {# Static resources #}
     <link

+ 7 - 1
netbox/templates/base/layout.html

@@ -41,6 +41,12 @@ Blocks:
           </a>
         </h1>
 
+        {# Sidebar pin (desktop view) #}
+        <button type="button" class="nav-link sidebar-toggle fs-2 p-0 text-secondary d-none d-lg-flex" title="{% trans "Pin sidebar" %}" aria-label="{% trans "Pin sidebar" %}" aria-pressed="true" data-bs-toggle="tooltip" data-bs-trigger="hover" data-bs-placement="bottom">
+          <i class="mdi mdi-pin" aria-hidden="true"></i>
+          <i class="mdi mdi-pin-off" aria-hidden="true"></i>
+        </button>
+
         {# User menu (mobile view) #}
         <div class="navbar-nav flex-row align-items-center d-lg-none">
           {% plugin_navbar %}
@@ -53,7 +59,7 @@ Blocks:
           {% nav %}
 
           {# Release info #}
-          <div class="text-muted text-center fs-5 my-3 px-3">
+          <div class="netbox-release text-muted text-center fs-5 my-3 px-3">
             {{ settings.RELEASE.name }}
             {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
               <div>

Некоторые файлы не были показаны из-за большого количества измененных файлов