« get me outta code hell

client, css, content: sticky collapse - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2025-03-30 06:04:18 -0300
committer(quasar) nebula <qznebula@protonmail.com>2025-03-30 06:04:18 -0300
commitf8184692746087d433a84fd6a7df7a1890d92480 (patch)
tree1831033108497e56f2c48b9e14514ec17e4aadc2
parentf639caa459925192dccd7a84a85abe8f249974f0 (diff)
client, css, content: sticky collapse
Sorry this is a mega-commit - this was rapid iteration and
separate commits would have needed to be committed on the go,
which we didn't do!
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js58
-rw-r--r--src/static/css/site.css88
-rw-r--r--src/static/js/client/additional-names-box.js42
-rw-r--r--src/static/js/client/sticky-heading.js91
4 files changed, 248 insertions, 31 deletions
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index 7cfbcf50..f58b0cd8 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -13,27 +13,45 @@ export default {
     },
   },
 
-  generate: (slots, {html}) =>
-    html.tag('div', {class: 'content-sticky-heading-container'},
+  generate: (slots, {html}) => html.tags([
+    html.tag('div', {class: 'content-sticky-heading-root'},
       !html.isBlank(slots.cover) &&
         {class: 'has-cover'},
 
-      [
-        html.tag('div', {class: 'content-sticky-heading-row'}, [
-          html.tag('h1', slots.title),
-
-          html.tag('div', {class: 'content-sticky-heading-cover-container'},
-            {[html.onlyIfContent]: true},
-
-            html.tag('div', {class: 'content-sticky-heading-cover'},
-              {[html.onlyIfContent]: true},
-
-              (html.isBlank(slots.cover)
-                ? html.blank()
-                : slots.cover.slot('mode', 'thumbnail')))),
-        ]),
-
-        html.tag('div', {class: 'content-sticky-subheading-row'},
-          html.tag('h2', {class: 'content-sticky-subheading'})),
-      ]),
+      html.tag('div', {class: 'content-sticky-heading-anchor'},
+        html.tag('div', {class: 'content-sticky-heading-container'},
+          !html.isBlank(slots.cover) &&
+            {class: 'has-cover'},
+
+          [
+            html.tag('div', {class: 'content-sticky-heading-row'}, [
+              html.tag('h1', [
+                slots.title,
+
+                // Placement after generally keeps the contents from being
+                // the first, when matched by .querySelector() calls.
+                html.tag('span', {class: 'reference-collapsed-heading'},
+                  slots.title.clone()),
+              ]),
+
+              html.tag('div', {class: 'content-sticky-heading-cover-container'},
+                {[html.onlyIfContent]: true},
+
+                html.tag('div', {class: 'content-sticky-heading-cover'},
+                  {[html.onlyIfContent]: true},
+
+                  (html.isBlank(slots.cover)
+                    ? html.blank()
+                    : slots.cover.slot('mode', 'thumbnail')))),
+            ]),
+
+            html.tag('div', {class: 'content-sticky-subheading-row'},
+              html.tag('h2', {class: 'content-sticky-subheading'})),
+          ]))),
+
+    html.tag('h1', {class: 'imaginary-static-heading-root'},
+      html.tag('span', {class: 'imaginary-static-heading-row'},
+        html.tag('span', {class: 'imaginary-static-heading-title'},
+          slots.title.clone()))),
+  ]),
 };
diff --git a/src/static/css/site.css b/src/static/css/site.css
index 643fcae4..86a4663f 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -2903,14 +2903,36 @@ h3.content-heading {
   );
 }
 
-.content-sticky-heading-container {
+.content-sticky-heading-root {
   position: sticky;
   top: 0;
+  width: 100%;
+}
+
+.content-sticky-heading-anchor {
+  position: relative;
+  width: 100%;
+}
 
+.content-sticky-heading-container {
+  position: absolute;
+  width: 100%;
+}
+
+.imaginary-static-heading-root,
+.imaginary-static-heading-row,
+.imaginary-static-heading-title {
+  display: block;
+}
+
+.content-sticky-heading-root,
+.imaginary-static-heading-root {
+  width: calc(100% + 2 * var(--content-padding));
   margin: calc(-1 * var(--content-padding));
-  margin-bottom: calc(0.5 * var(--content-padding));
+}
 
-  transform: translateY(-5px);
+.imaginary-static-heading-root {
+  margin-bottom: calc(0.5 * var(--content-padding));
 }
 
 main.long-content .content-sticky-heading-container {
@@ -2924,7 +2946,12 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   padding-right: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding));
 }
 
-.content-sticky-heading-row {
+.imaginary-static-heading-root {
+  visibility: hidden;
+}
+
+.content-sticky-heading-row,
+.imaginary-static-heading-row {
   box-sizing: border-box;
   padding:
     calc(1.25 * var(--content-padding) + 5px)
@@ -2934,7 +2961,9 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
   width: 100%;
   margin: 0;
+}
 
+.content-sticky-heading-row {
   background: var(--bg-black-color);
   border-bottom: 1px dotted rgba(220, 220, 220, 0.4);
 
@@ -2950,11 +2979,58 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   grid-template-columns: 1fr min(40%, 400px);
 }
 
+.content-sticky-heading-container.cover-visible .content-sticky-heading-row {
+  grid-template-columns: 1fr min(40%, 90px);
+}
+
+.content-sticky-heading-root.has-cover +
+.imaginary-static-heading-root .imaginary-static-heading-title {
+  padding-right: min(40%, 400px);
+}
+
 .content-sticky-heading-row h1 {
+  position: relative;
   margin: 0;
   padding-right: 20px;
 }
 
+.content-sticky-heading-row h1 .reference-collapsed-heading {
+  position: absolute;
+  white-space: nowrap;
+  visibility: hidden;
+}
+
+.content-sticky-heading-container.collapse h1 {
+  white-space: nowrap;
+  overflow-wrap: normal;
+
+  animation: collapse-sticky-heading 0.35s forwards;
+  text-overflow: ellipsis;
+  overflow-x: hidden;
+}
+
+@keyframes collapse-sticky-heading {
+  from {
+    height: var(--uncollapsed-heading-height);
+  }
+
+  to {
+    height: var(--collapsed-heading-height);
+  }
+}
+
+.content-sticky-heading-container h1 a {
+  transition: text-decoration-color 0.35s;
+}
+
+.content-sticky-heading-container h1 a:not([href]) {
+  color: inherit;
+  cursor: text;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+  text-decoration-color: transparent;
+}
+
 .content-sticky-heading-cover-container {
   position: relative;
   height: 0;
@@ -3297,7 +3373,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 /* Layout - Wide or Medium */
 
 @media (min-width: 600px) {
-  .content-sticky-heading-container {
+  .content-sticky-heading-root {
     /* Safari doesn't always play nicely with position: sticky,
      * this seems to fix images sometimes displaying above the
      * position: absolute subheading (h2) child
@@ -3448,7 +3524,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
   /* Show sticky heading above cover art */
 
-  .content-sticky-heading-container {
+  .content-sticky-heading-root {
     z-index: 2;
   }
 
diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js
index 558ef06f..da1467ec 100644
--- a/src/static/js/client/additional-names-box.js
+++ b/src/static/js/client/additional-names-box.js
@@ -3,12 +3,17 @@
 import {cssProp} from '../client-util.js';
 
 import {info as hashLinkInfo} from './hash-link.js';
+import {info as stickyHeadingInfo} from './sticky-heading.js';
 
 export const info = {
   id: 'additionalNamesBoxInfo',
 
   box: null,
+
   links: null,
+  stickyHeadingLink: null,
+
+  contentContainer: null,
   mainContentContainer: null,
 
   state: {
@@ -23,6 +28,14 @@ export function getPageReferences() {
   info.links =
     document.querySelectorAll('a[href="#additional-names-box"]');
 
+  info.stickyHeadingLink =
+    document.querySelector(
+      '.content-sticky-heading-container ' +
+      'a[href="#additional-names-box"]');
+
+  info.contentContainer =
+    document.querySelector('#content');
+
   info.mainContentContainer =
     document.querySelector('#content .main-content-container');
 }
@@ -33,6 +46,34 @@ export function addInternalListeners() {
       return false;
     }
   });
+
+  stickyHeadingInfo.event.whenStuckStatusChanges.push((index, stuck) => {
+    const {state} = info;
+
+    if (!info.stickyHeadingLink) return;
+
+    const container = stickyHeadingInfo.contentContainers[index];
+    if (container !== info.contentContainer) return;
+
+    if (stuck) {
+      if (!state.visible) {
+        info.stickyHeadingLink.removeAttribute('href');
+
+        if (info.stickyHeadingLink.hasAttribute('title')) {
+          info.stickyHeadingLink.dataset.restoreTitle = info.stickyHeadingLink.getAttribute('title');
+          info.stickyHeadingLink.removeAttribute('title');
+        }
+      }
+    } else {
+      info.stickyHeadingLink.setAttribute('href', '#additional-names-box');
+
+      const {restoreTitle} = info.stickyHeadingLink.dataset;
+      if (restoreTitle) {
+        info.stickyHeadingLink.setAttribute('title', restoreTitle);
+        delete info.stickyHeadingLink.dataset.restoreTitle;
+      }
+    }
+  });
 }
 
 export function addPageListeners() {
@@ -48,6 +89,7 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) {
 
   domEvent.preventDefault();
 
+  if (!domEvent.target.hasAttribute('href')) return;
   if (!info.box || !info.mainContentContainer) return;
 
   const margin =
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js
index ae63eab5..9d38c150 100644
--- a/src/static/js/client/sticky-heading.js
+++ b/src/static/js/client/sticky-heading.js
@@ -1,13 +1,15 @@
 /* eslint-env browser */
 
 import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
-import {dispatchInternalEvent, templateContent} from '../client-util.js';
+import {cssProp, dispatchInternalEvent, templateContent}
+  from '../client-util.js';
 
 export const info = {
   id: 'stickyHeadingInfo',
 
   stickyContainers: null,
 
+  stickyHeadings: null,
   stickySubheadingRows: null,
   stickySubheadings: null,
 
@@ -20,12 +22,16 @@ export const info = {
   contentCovers: null,
   contentCoversReveal: null,
 
+  imaginaryStaticHeadings: null,
+  referenceCollapsedHeading: null,
+
   state: {
     displayedHeading: null,
   },
 
   event: {
     whenDisplayedHeadingChanges: [],
+    whenStuckStatusChanges: [],
   },
 };
 
@@ -45,6 +51,10 @@ export function getPageReferences() {
     info.stickyCovers
       .map(el => el?.querySelector('.image-text-area'));
 
+  info.stickyHeadings =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-row h1'));
+
   info.stickySubheadingRows =
     info.stickyContainers
       .map(el => el.querySelector('.content-sticky-subheading-row'));
@@ -55,7 +65,7 @@ export function getPageReferences() {
 
   info.contentContainers =
     info.stickyContainers
-      .map(el => el.parentElement);
+      .map(el => el.closest('.content-sticky-heading-root').parentElement);
 
   info.contentCovers =
     info.contentContainers
@@ -68,6 +78,14 @@ export function getPageReferences() {
   info.contentHeadings =
     info.contentContainers
       .map(el => Array.from(el.querySelectorAll('.content-heading')));
+
+  info.imaginaryStaticHeadings =
+    info.contentContainers
+      .map(el => el.querySelector('.imaginary-static-heading-root'));
+
+  info.referenceCollapsedHeading =
+    info.stickyHeadings
+      .map(el => el.querySelector('.reference-collapsed-heading'));
 }
 
 export function mutatePageContent() {
@@ -137,15 +155,61 @@ function topOfViewInside(el, scroll = window.scrollY) {
     scroll < el.offsetTop + el.offsetHeight);
 }
 
+function updateStuckStatus(index) {
+  const {event} = info;
+
+  const contentContainer = info.contentContainers[index];
+  const stickyContainer = info.stickyContainers[index];
+
+  const wasStuck = stickyContainer.classList.contains('stuck');
+  const stuck = topOfViewInside(contentContainer);
+
+  if (stuck === wasStuck) return;
+
+  if (stuck) {
+    stickyContainer.classList.add('stuck');
+  } else {
+    stickyContainer.classList.remove('stuck');
+  }
+
+  dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck);
+}
+
+function updateCollapseStatus(index) {
+  const stickyContainer = info.stickyContainers[index];
+  const stickyHeading = info.stickyHeadings[index];
+  const imaginaryStaticHeading = info.imaginaryStaticHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+
+  const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect();
+  const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect();
+
+  if (
+    imaginaryStaticHeading.getBoundingClientRect().bottom < 4 ||
+    imaginaryStaticHeading.getBoundingClientRect().top < -80
+  ) {
+    if (!stickyContainer.classList.contains('collapse')) {
+      stickyContainer.classList.add('collapse');
+      cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px');
+      cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px');
+    }
+  } else {
+    stickyContainer.classList.remove('collapse');
+  }
+}
+
 function updateStickyCoverVisibility(index) {
   const stickyCoverContainer = info.stickyCoverContainers[index];
+  const stickyContainer = info.stickyContainers[index];
   const contentCover = info.contentCovers[index];
 
   if (contentCover && stickyCoverContainer) {
     if (contentCover.getBoundingClientRect().bottom < 4) {
       stickyCoverContainer.classList.add('visible');
+      stickyContainer.classList.add('cover-visible');
     } else {
       stickyCoverContainer.classList.remove('visible');
+      stickyContainer.classList.remove('cover-visible');
     }
   }
 }
@@ -167,10 +231,20 @@ function getContentHeadingClosestToStickySubheading(index) {
   const stickyContainer = info.stickyContainers[index];
   const stickyRect = stickyContainer.getBoundingClientRect();
 
-  // TODO: Should this compute with the subheading row instead of h2?
+  // Subheadings only appear when the sticky heading is collapsed,
+  // so the used bottom edge should always be *as though* it's only
+  // displaying one line of text. Subtract the current discrepancy.
+  const stickyHeading = info.stickyHeadings[index];
+  const correctBottomEdge =
+    stickyHeading.getBoundingClientRect().height -
+    parseFloat(getComputedStyle(stickyHeading).fontSize);
+
   const subheadingRect = stickySubheading.getBoundingClientRect();
 
-  const stickyBottom = stickyRect.bottom + subheadingRect.height;
+  const stickyBottom =
+    (stickyRect.bottom
+   + subheadingRect.height
+   - correctBottomEdge);
 
   // Iterate from bottom to top of the content area.
   const contentHeadings = info.contentHeadings[index];
@@ -187,7 +261,12 @@ function getContentHeadingClosestToStickySubheading(index) {
 function updateStickySubheadingContent(index) {
   const {event, state} = info;
 
-  const closestHeading = getContentHeadingClosestToStickySubheading(index);
+  const stickyContainer = info.stickyContainers[index];
+
+  const closestHeading =
+    (stickyContainer.classList.contains('collapse')
+      ? getContentHeadingClosestToStickySubheading(index)
+      : null);
 
   if (state.displayedHeading === closestHeading) return;
 
@@ -233,6 +312,8 @@ function updateStickySubheadingContent(index) {
 }
 
 export function updateStickyHeadings(index) {
+  updateStuckStatus(index);
+  updateCollapseStatus(index);
   updateStickyCoverVisibility(index);
   updateStickySubheadingContent(index);
 }