]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #5685 from BookStackApp/sidebar_rejig
authorDan Brown <redacted>
Wed, 9 Jul 2025 17:00:56 +0000 (18:00 +0100)
committerGitHub <redacted>
Wed, 9 Jul 2025 17:00:56 +0000 (18:00 +0100)
Tri-layout sidebar enhancements

resources/js/components/dropdown.js
resources/js/components/tri-layout.ts [moved from resources/js/components/tri-layout.js with 57% similarity]
resources/js/services/dom.ts
resources/sass/_layout.scss
resources/views/layouts/tri.blade.php

index 5dd5dd93b013023ebf466ef021e9237dd1b57ce7..d2b044ee1ca871a96136415442b8e74add02c817 100644 (file)
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom.ts';
+import {findClosestScrollContainer, onSelect} from '../services/dom.ts';
 import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 import {Component} from './component';
 
@@ -33,7 +33,8 @@ export class Dropdown extends Component {
         const menuOriginalRect = this.menu.getBoundingClientRect();
         let heightOffset = 0;
         const toggleHeight = this.toggle.getBoundingClientRect().height;
-        const dropUpwards = menuOriginalRect.bottom > window.innerHeight;
+        const containerBounds = findClosestScrollContainer(this.menu).getBoundingClientRect();
+        const dropUpwards = menuOriginalRect.bottom > containerBounds.bottom;
         const containerRect = this.container.getBoundingClientRect();
 
         // If enabled, Move to body to prevent being trapped within scrollable sections
similarity index 57%
rename from resources/js/components/tri-layout.js
rename to resources/js/components/tri-layout.ts
index be9388e8d4615f5919a81068ff57915bc9d52ae9..40a2d36910437efa98fde9edd1410ab0e438e88f 100644 (file)
@@ -1,18 +1,22 @@
 import {Component} from './component';
 
 export class TriLayout extends Component {
-
-    setup() {
+    private container!: HTMLElement;
+    private tabs!: HTMLElement[];
+    private sidebarScrollContainers!: HTMLElement[];
+
+    private lastLayoutType = 'none';
+    private onDestroy: (()=>void)|null = null;
+    private scrollCache: Record<string, number> = {
+        content: 0,
+        info: 0,
+    };
+    private lastTabShown = 'content';
+
+    setup(): void {
         this.container = this.$refs.container;
         this.tabs = this.$manyRefs.tab;
-
-        this.lastLayoutType = 'none';
-        this.onDestroy = null;
-        this.scrollCache = {
-            content: 0,
-            info: 0,
-        };
-        this.lastTabShown = 'content';
+        this.sidebarScrollContainers = this.$manyRefs.sidebarScrollContainer;
 
         // Bind any listeners
         this.mobileTabClick = this.mobileTabClick.bind(this);
@@ -22,9 +26,11 @@ export class TriLayout extends Component {
         window.addEventListener('resize', () => {
             this.updateLayout();
         }, {passive: true});
+
+        this.setupSidebarScrollHandlers();
     }
 
-    updateLayout() {
+    updateLayout(): void {
         let newLayout = 'tablet';
         if (window.innerWidth <= 1000) newLayout = 'mobile';
         if (window.innerWidth > 1400) newLayout = 'desktop';
@@ -56,16 +62,15 @@ export class TriLayout extends Component {
         };
     }
 
-    setupDesktop() {
+    setupDesktop(): void {
         //
     }
 
     /**
      * Action to run when the mobile info toggle bar is clicked/tapped
-     * @param event
      */
-    mobileTabClick(event) {
-        const {tab} = event.target.dataset;
+    mobileTabClick(event: MouseEvent): void {
+        const tab = (event.target as HTMLElement).dataset.tab || '';
         this.showTab(tab);
     }
 
@@ -73,16 +78,14 @@ export class TriLayout extends Component {
      * Show the content tab.
      * Used by the page-display component.
      */
-    showContent() {
+    showContent(): void {
         this.showTab('content', false);
     }
 
     /**
      * Show the given tab
-     * @param {String} tabName
-     * @param {Boolean }scroll
      */
-    showTab(tabName, scroll = true) {
+    showTab(tabName: string, scroll: boolean = true): void {
         this.scrollCache[this.lastTabShown] = document.documentElement.scrollTop;
 
         // Set tab status
@@ -97,7 +100,7 @@ export class TriLayout extends Component {
 
         // Set the scroll position from cache
         if (scroll) {
-            const pageHeader = document.querySelector('header');
+            const pageHeader = document.querySelector('header') as HTMLElement;
             const defaultScrollTop = pageHeader.getBoundingClientRect().bottom;
             document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop;
             setTimeout(() => {
@@ -108,4 +111,30 @@ export class TriLayout extends Component {
         this.lastTabShown = tabName;
     }
 
+    setupSidebarScrollHandlers(): void {
+        for (const sidebar of this.sidebarScrollContainers) {
+            sidebar.addEventListener('scroll', () => this.handleSidebarScroll(sidebar), {
+                passive: true,
+            });
+            this.handleSidebarScroll(sidebar);
+        }
+
+        window.addEventListener('resize', () => {
+            for (const sidebar of this.sidebarScrollContainers) {
+                this.handleSidebarScroll(sidebar);
+            }
+        });
+    }
+
+    handleSidebarScroll(sidebar: HTMLElement): void {
+        const scrollable = sidebar.clientHeight !== sidebar.scrollHeight;
+        const atTop = sidebar.scrollTop === 0;
+        const atBottom = (sidebar.scrollTop + sidebar.clientHeight) === sidebar.scrollHeight;
+
+        if (sidebar.parentElement) {
+            sidebar.parentElement.classList.toggle('scroll-away-from-top', !atTop && scrollable);
+            sidebar.parentElement.classList.toggle('scroll-away-from-bottom', !atBottom && scrollable);
+        }
+    }
+
 }
index c3817536c85422c8d0e480cbd05f267be3f6633f..8696fe81639c84aea40294dc9b3c1db376ca129f 100644 (file)
@@ -256,4 +256,22 @@ export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number)
 export function hashElement(element: HTMLElement): string {
     const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
     return cyrb53(normalisedElemText);
+}
+
+/**
+ * Find the closest scroll container parent for the given element
+ * otherwise will default to the body element.
+ */
+export function findClosestScrollContainer(start: HTMLElement): HTMLElement {
+    let el: HTMLElement|null = start;
+    do {
+        const computed = window.getComputedStyle(el);
+        if (computed.overflowY === 'scroll') {
+            return el;
+        }
+
+        el = el.parentElement;
+    } while (el);
+
+    return document.body;
 }
\ No newline at end of file
index 8175db948a5c6617522d57c980a26706503b00d8..48b4b0ca22ef5d28430140049b817c6fbba2e217 100644 (file)
@@ -389,10 +389,12 @@ body.flexbox {
 .tri-layout-right {
   grid-area: c;
   min-width: 0;
+  position: relative;
 }
 .tri-layout-left {
   grid-area: a;
   min-width: 0;
+  position: relative;
 }
 
 @include mixins.larger-than(vars.$bp-xxl) {
@@ -431,7 +433,8 @@ body.flexbox {
     grid-template-areas:  "a b b";
     grid-template-columns: 1fr 3fr;
     grid-template-rows: min-content min-content 1fr;
-    padding-inline-end: vars.$l;
+    margin-inline-start: (vars.$m + vars.$xxs);
+    margin-inline-end: (vars.$m + vars.$xxs);
   }
   .tri-layout-sides {
     grid-column-start: a;
@@ -452,6 +455,8 @@ body.flexbox {
     height: 100%;
     scrollbar-width: none;
     -ms-overflow-style: none;
+    padding-inline: vars.$m;
+    margin-inline: -(vars.$m);
     &::-webkit-scrollbar {
       display: none;
     }
@@ -520,4 +525,26 @@ body.flexbox {
     margin-inline-start: 0;
     margin-inline-end: 0;
   }
+}
+
+/**
+ * Scroll Indicators
+ */
+.scroll-away-from-top:before,
+.scroll-away-from-bottom:after {
+  content: '';
+  display: block;
+  position: absolute;
+  @include mixins.lightDark(color, #F2F2F2, #111);
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 50px;
+  background: linear-gradient(to bottom, currentColor, transparent);
+  z-index: 2;
+}
+.scroll-away-from-bottom:after {
+  top: auto;
+  bottom: 0;
+  background: linear-gradient(to top, currentColor, transparent);
 }
\ No newline at end of file
index c3cedf0fbc2a2106c91f954b3a8142cb16db53c2..061cc69945c3dcace59ea8bed5401a21f3ed3773 100644 (file)
     <div refs="tri-layout@container" class="tri-layout-container" @yield('container-attrs') >
 
         <div class="tri-layout-sides print-hidden">
-            <div class="tri-layout-sides-content">
+            <div refs="tri-layout@sidebar-scroll-container" class="tri-layout-sides-content">
                 <div class="tri-layout-right print-hidden">
-                    <aside class="tri-layout-right-contents">
+                    <aside refs="tri-layout@sidebar-scroll-container" class="tri-layout-right-contents">
                         @yield('right')
                     </aside>
                 </div>
 
                 <div class="tri-layout-left print-hidden" id="sidebar">
-                    <aside class="tri-layout-left-contents">
+                    <aside refs="tri-layout@sidebar-scroll-container" class="tri-layout-left-contents">
                         @yield('left')
                     </aside>
                 </div>