Skip to content

Commit bc4c812

Browse files
committed
WIP fixes to ToC scrolling
1 parent 4898c49 commit bc4c812

7 files changed

Lines changed: 218 additions & 11 deletions

File tree

‎src/components/Pages/Contact/client/index.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import { LitElement } from 'lit'
88
import { addScriptBreadcrumb } from '@components/scripts/errors'
99
import { handleScriptError } from '@components/scripts/errors/handler'
10-
import { getContactFormElements, queryContactProjectTypeSelect } from './selectors'
10+
import { initStickySidebar } from '@components/scripts/stickySidebar'
11+
import { getContactFormElements, queryContactProjectTypeSelect, queryContactStickySidebar } from './selectors'
1112
import type { ContactFormConfig } from './@types'
1213
import { initCharacterCounter, initUploadPlaceholder } from './utils'
1314
import { initLabelHandlers, type LabelController } from './feedback'
@@ -23,6 +24,7 @@ const COMPONENT_TAG_NAME = 'contact-form' as const
2324
export class ContactFormElement extends LitElement {
2425
private labelController: LabelController | null = null
2526
private uploadController: UploadController | null = null
27+
private destroyStickySidebar: (() => void) | null = null
2628
private viewTransitionHandlersRegistered = false
2729
private beforePreparationHandler: (() => void) | null = null
2830
private afterSwapHandler: (() => void) | null = null
@@ -46,6 +48,8 @@ export class ContactFormElement extends LitElement {
4648
super.disconnectedCallback()
4749
this.uploadController?.destroy()
4850
this.uploadController = null
51+
this.destroyStickySidebar?.()
52+
this.destroyStickySidebar = null
4953
this.removeViewTransitionsHandlers()
5054
}
5155

@@ -72,6 +76,7 @@ export class ContactFormElement extends LitElement {
7276
labelController: this.labelController,
7377
uploadController: this.uploadController,
7478
})
79+
this.initializeStickySidebar()
7580
this.setViewTransitionsHandlers()
7681
} catch (error) {
7782
handleScriptError(error, context)
@@ -101,6 +106,18 @@ export class ContactFormElement extends LitElement {
101106
projectTypeSelect.value = projectType
102107
}
103108

109+
/**
110+
* Set up JS-driven sticky sidebar for the contact info card.
111+
* Container = the grid parent (data-sticky-container); sidebar = the card (data-sticky-sidebar).
112+
*/
113+
private initializeStickySidebar(): void {
114+
this.destroyStickySidebar?.()
115+
this.destroyStickySidebar = null
116+
const elements = queryContactStickySidebar(this)
117+
if (!elements) return
118+
this.destroyStickySidebar = initStickySidebar(elements.sidebar, elements.container)
119+
}
120+
104121
private setViewTransitionsHandlers(): void {
105122
if (this.viewTransitionHandlersRegistered) {
106123
return

‎src/components/Pages/Contact/client/selectors.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,17 @@ export const queryContactProjectTypeSelect = (root: ParentNode = document): HTML
159159
const projectTypeSelect = root.querySelector('#project_type')
160160
return projectTypeSelect instanceof HTMLSelectElement ? projectTypeSelect : null
161161
}
162+
163+
/**
164+
* Query the sticky sidebar element and its constraining container
165+
* within the contact form component.
166+
*/
167+
export function queryContactStickySidebar(context: Element): {
168+
sidebar: HTMLElement
169+
container: HTMLElement
170+
} | null {
171+
const sidebar = context.querySelector('[data-sticky-sidebar]')
172+
const container = context.querySelector('[data-sticky-container]')
173+
if (!(sidebar instanceof HTMLElement) || !(container instanceof HTMLElement)) return null
174+
return { sidebar, container }
175+
}

‎src/components/Pages/Contact/index.astro‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const { content, prefilledProjectType = '' } = Astro.props
100100
{/** Contact Form Section */}
101101
<section class="pt-5 pb-8 bg-page-offset" aria-label="Contact form">
102102
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
103-
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 lg:gap-12 items-start">
103+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 lg:gap-12 items-start" data-sticky-container>
104104
{/** Contact Form */}
105105
<div class="lg:col-span-2 bg-content-inverse p-6 md:p-8 rounded-2xl shadow-lg">
106106
{/** Contact FormHeader */}
@@ -405,8 +405,8 @@ const { content, prefilledProjectType = '' } = Astro.props
405405

406406
{/** Contact Information */}
407407
<div
408-
class="bg-content-inverse p-6 md:p-8 rounded-2xl shadow-lg h-fit sticky"
409-
style="top: 1rem;"
408+
class="bg-content-inverse p-6 md:p-8 rounded-2xl shadow-lg h-fit"
409+
data-sticky-sidebar
410410
>
411411
<div class="space-y-8">
412412
<div>

‎src/components/Toc/client/index.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
showTableOfContents,
1313
type VisibilityListener,
1414
} from '@components/scripts/store/tableOfContents'
15-
import { getTableOfContentsElements } from './selectors'
15+
import { initStickySidebar } from '@components/scripts/stickySidebar'
16+
import { getTableOfContentsElements, queryTocStickySidebar } from './selectors'
1617

1718
export class TableOfContentsElement extends LitElement {
1819
static registeredName = 'table-of-contents'
@@ -36,6 +37,7 @@ export class TableOfContentsElement extends LitElement {
3637
private previousOpen = false
3738
private activeSlug: string | null = null
3839
private headingObserver: IntersectionObserver | null = null
40+
private destroyStickySidebar: (() => void) | null = null
3941

4042
constructor() {
4143
super()
@@ -52,6 +54,7 @@ export class TableOfContentsElement extends LitElement {
5254
this.cacheElements()
5355
this.attachListeners()
5456
this.initializeScrollSpy()
57+
this.initializeStickySidebar()
5558
this.visibilityListener = state => {
5659
this.open = state.tableOfContentsVisible
5760
this.disabled = !state.tableOfContentsEnabled
@@ -65,6 +68,8 @@ export class TableOfContentsElement extends LitElement {
6568
this.unsubscribe = null
6669
this.headingObserver?.disconnect()
6770
this.headingObserver = null
71+
this.destroyStickySidebar?.()
72+
this.destroyStickySidebar = null
6873
super.disconnectedCallback()
6974
}
7075

@@ -239,6 +244,17 @@ export class TableOfContentsElement extends LitElement {
239244
hideTableOfContents()
240245
}
241246

247+
/**
248+
* Set up JS-driven sticky sidebar for the desktop layout.
249+
* Container = this custom element (the grid cell); sidebar = the <aside>.
250+
*/
251+
private initializeStickySidebar(): void {
252+
this.destroyStickySidebar?.()
253+
const aside = queryTocStickySidebar(this)
254+
if (!aside) return
255+
this.destroyStickySidebar = initStickySidebar(aside, this)
256+
}
257+
242258
private initializeScrollSpy(): void {
243259
if (typeof window === 'undefined') {
244260
return

‎src/components/Toc/client/selectors.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,12 @@ export function getTableOfContentsElements(context: Element) {
7171
tocLinks,
7272
}
7373
}
74+
75+
/**
76+
* Query the sticky sidebar element (the <aside>) within the ToC component.
77+
* Returns null when absent (e.g. on pages without a ToC).
78+
*/
79+
export function queryTocStickySidebar(context: Element): HTMLElement | null {
80+
const el = context.querySelector('[data-sticky-sidebar]')
81+
return el instanceof HTMLElement ? el : null
82+
}

‎src/components/Toc/index.astro‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,18 @@ const tocTree = buildTocTree(items)
6363
</span>
6464
</button>
6565

66-
{/* Scrollable inner wrapper (overflow moved here from outer panel) */}
67-
<div class="h-full overflow-y-auto">
66+
{/* Scrollable inner wrapper (mobile drawer only) */}
67+
<div class="h-full overflow-y-auto lg:h-auto lg:overflow-visible">
6868
{/* Drawer heading — mobile only */}
6969
<div class="flex items-center gap-3 px-5 pt-5 lg:hidden">
7070
<h2 class="text-base font-semibold">Table of Contents</h2>
7171
</div>
7272

7373
<aside
74-
class="px-5 py-6 lg:sticky lg:rounded-lg lg:border lg:border-trim lg:bg-page-base lg:shadow-none"
75-
style="top: 1rem;"
76-
aria-labelledby="toc-heading"
77-
>
74+
class="px-5 py-6 lg:rounded-lg lg:border lg:border-trim lg:bg-page-base lg:shadow-none"
75+
data-sticky-sidebar
76+
aria-labelledby="toc-heading"
77+
>
7878
{/* Desktop heading — hidden on mobile where the drawer header shows instead */}
7979
<div class="hidden lg:flex items-center justify-between gap-3">
8080
<h2 id="toc-heading" class="text-content text-base font-semibold">
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Sticky Sidebar Controller
3+
*
4+
* Provides direction-aware sticky sidebar behavior via JavaScript.
5+
* Handles both short sidebars (smaller than viewport) and tall sidebars
6+
* (taller than viewport) with different sticking strategies:
7+
*
8+
* SHORT SIDEBAR: Pins the sidebar's top edge below the scroll viewport's
9+
* top edge (with padding) as the user scrolls down. Returns to natural
10+
* position when the container scrolls back into view.
11+
*
12+
* TALL SIDEBAR: When scrolling down, allows the sidebar to scroll naturally
13+
* until its bottom edge reaches the viewport bottom (with padding), then
14+
* pins it there. When scrolling up, releases and scrolls naturally until
15+
* the top edge reaches the viewport top (with padding), then pins at top.
16+
*
17+
* The scroll container is always `#scroll-viewport`, which sits below the
18+
* header/progress bar in the flex layout. Its BCR top naturally accounts
19+
* for all fixed chrome above content.
20+
*/
21+
import { handleScriptError } from '@components/scripts/errors/handler'
22+
23+
export interface StickySidebarOptions {
24+
/** Extra space below the scroll viewport's visible top edge (px). Default: 16 */
25+
topPadding?: number
26+
/** Extra space above the scroll viewport's visible bottom edge (px). Default: 16 */
27+
bottomPadding?: number
28+
/** Minimum window width to enable sticky behavior (px). Default: 1024 (lg breakpoint) */
29+
minWidth?: number
30+
}
31+
32+
/**
33+
* Initialize sticky sidebar behavior on a sidebar element within a container.
34+
*
35+
* @param sidebar - The element that should stick (e.g. `<aside>`, contact card)
36+
* @param container - The element whose height constrains the sidebar's vertical travel
37+
* @param options - Configuration for padding and breakpoint
38+
* @returns A cleanup function that removes listeners and resets positioning
39+
*/
40+
export function initStickySidebar(
41+
sidebar: HTMLElement,
42+
container: HTMLElement,
43+
options: StickySidebarOptions = {},
44+
): () => void {
45+
const {
46+
topPadding = 16,
47+
bottomPadding = 16,
48+
minWidth = 1024,
49+
} = options
50+
51+
const scrollContainer = document.getElementById('scroll-viewport')
52+
if (!scrollContainer) {
53+
return () => {}
54+
}
55+
56+
let currentTranslateY = 0
57+
let rafId: number | null = null
58+
// Capture scrollContainer in a const to satisfy strict null checks inside closures
59+
const scroller = scrollContainer
60+
61+
/**
62+
* Compute and apply the correct translateY for the sidebar based on
63+
* the current scroll position and viewport dimensions.
64+
*/
65+
function update(): void {
66+
try {
67+
// Disable on narrow viewports (mobile drawer handles layout)
68+
if (window.innerWidth < minWidth) {
69+
if (currentTranslateY !== 0) {
70+
sidebar.style.transform = ''
71+
currentTranslateY = 0
72+
}
73+
return
74+
}
75+
76+
const sidebarRect = sidebar.getBoundingClientRect()
77+
const sidebarHeight = sidebarRect.height
78+
if (sidebarHeight === 0) return
79+
80+
const containerRect = container.getBoundingClientRect()
81+
const viewportRect = scroller.getBoundingClientRect()
82+
83+
// Sidebar's natural top position (without current transform applied)
84+
const naturalTop = sidebarRect.top - currentTranslateY
85+
86+
// Maximum translateY: sidebar bottom must not exceed container bottom
87+
const maxTranslateY = Math.max(0, containerRect.bottom - (naturalTop + sidebarHeight))
88+
89+
// Visible bounds within the scroll viewport
90+
const visibleTop = viewportRect.top + topPadding
91+
const visibleBottom = viewportRect.bottom - bottomPadding
92+
const availableHeight = visibleBottom - visibleTop
93+
94+
let newTranslateY: number
95+
96+
if (sidebarHeight <= availableHeight) {
97+
// SHORT SIDEBAR: pin top edge at the visible top boundary
98+
newTranslateY = Math.max(0, visibleTop - naturalTop)
99+
} else {
100+
// TALL SIDEBAR: direction-aware pinning.
101+
// Top pin: sidebar top aligns with visibleTop
102+
const topPin = visibleTop - naturalTop
103+
// Bottom pin: sidebar bottom aligns with visibleBottom
104+
const bottomPin = visibleBottom - sidebarHeight - naturalTop
105+
106+
// When the sidebar is taller than available space, topPin > bottomPin.
107+
// Clamping currentTranslateY between them gives us direction-aware
108+
// behavior: scrolling down hits the bottom pin, scrolling up hits the
109+
// top pin, and in between the sidebar scrolls naturally.
110+
newTranslateY = Math.max(bottomPin, Math.min(currentTranslateY, topPin))
111+
}
112+
113+
// Global clamp: never go above natural position or below container bottom
114+
newTranslateY = Math.max(0, Math.min(newTranslateY, maxTranslateY))
115+
116+
// Apply only when value changes meaningfully (avoid sub-pixel jitter)
117+
if (Math.abs(newTranslateY - currentTranslateY) > 0.5) {
118+
sidebar.style.transform = `translateY(${Math.round(newTranslateY)}px)`
119+
currentTranslateY = newTranslateY
120+
}
121+
} catch (error) {
122+
handleScriptError(error, {
123+
scriptName: 'stickySidebar',
124+
operation: 'update',
125+
})
126+
}
127+
}
128+
129+
/** Debounced scroll handler using requestAnimationFrame */
130+
function onScroll(): void {
131+
if (rafId !== null) cancelAnimationFrame(rafId)
132+
rafId = requestAnimationFrame(() => {
133+
rafId = null
134+
update()
135+
})
136+
}
137+
138+
scroller.addEventListener('scroll', onScroll, { passive: true })
139+
window.addEventListener('resize', onScroll, { passive: true })
140+
141+
// Initial positioning
142+
update()
143+
144+
return () => {
145+
scroller.removeEventListener('scroll', onScroll)
146+
window.removeEventListener('resize', onScroll)
147+
if (rafId !== null) cancelAnimationFrame(rafId)
148+
sidebar.style.transform = ''
149+
currentTranslateY = 0
150+
}
151+
}

0 commit comments

Comments
 (0)