diff options
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 |
commit | f8184692746087d433a84fd6a7df7a1890d92480 (patch) | |
tree | 1831033108497e56f2c48b9e14514ec17e4aadc2 | |
parent | f639caa459925192dccd7a84a85abe8f249974f0 (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.js | 58 | ||||
-rw-r--r-- | src/static/css/site.css | 88 | ||||
-rw-r--r-- | src/static/js/client/additional-names-box.js | 42 | ||||
-rw-r--r-- | src/static/js/client/sticky-heading.js | 91 |
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); } |