Skip to content

Commit ae688fb

Browse files
committed
Fix theme picker, add progress bar to articles, fix vertical scroll bar, add sticky behavior to ToC, improve squishy effect on Header
1 parent 97caadd commit ae688fb

31 files changed

Lines changed: 1731 additions & 103 deletions

File tree

‎TROUBLESHOOTING_HEADER_SPACING.md‎

Lines changed: 195 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* Unit tests for ReadingProgressBar web component
4+
*/
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { ReadingProgressBar, registerProgressBarComponent } from '../index'
7+
8+
vi.mock('@components/scripts/errors/handler', () => ({
9+
handleScriptError: vi.fn(),
10+
}))
11+
12+
vi.mock('@components/scripts/utils', () => ({
13+
defineCustomElement: vi.fn((tagName: string, ctor: CustomElementConstructor) => {
14+
if (!customElements.get(tagName)) {
15+
customElements.define(tagName, ctor)
16+
}
17+
}),
18+
}))
19+
20+
// ============================================================================
21+
// HELPERS
22+
// ============================================================================
23+
24+
function createDOM(contentHeight = 2000) {
25+
const component = document.createElement('reading-progress-bar') as ReadingProgressBar
26+
component.setAttribute('data-progress-bar', '')
27+
28+
const progress = document.createElement('progress')
29+
progress.max = 100
30+
progress.value = 0
31+
component.appendChild(progress)
32+
document.body.appendChild(component)
33+
34+
const content = document.createElement('div')
35+
content.id = 'content'
36+
// Mock getBoundingClientRect for content
37+
content.getBoundingClientRect = vi.fn().mockReturnValue({
38+
top: 0,
39+
height: contentHeight,
40+
bottom: contentHeight,
41+
left: 0,
42+
right: 800,
43+
width: 800,
44+
x: 0,
45+
y: 0,
46+
toJSON: vi.fn(),
47+
})
48+
document.body.appendChild(content)
49+
50+
return { component, progress, content }
51+
}
52+
53+
// ============================================================================
54+
// TESTS
55+
// ============================================================================
56+
57+
describe('ReadingProgressBar', () => {
58+
let rafCallback: FrameRequestCallback | null = null
59+
60+
beforeEach(async () => {
61+
rafCallback = null
62+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
63+
rafCallback = cb
64+
return 1
65+
})
66+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
67+
68+
// Ensure the custom element is registered
69+
await registerProgressBarComponent()
70+
})
71+
72+
afterEach(() => {
73+
document.body.innerHTML = ''
74+
vi.restoreAllMocks()
75+
})
76+
77+
describe('registration', () => {
78+
it('should call defineCustomElement with the correct tag name', async () => {
79+
const { defineCustomElement } = await import('@components/scripts/utils')
80+
await registerProgressBarComponent()
81+
expect(defineCustomElement).toHaveBeenCalledWith('reading-progress-bar', ReadingProgressBar)
82+
})
83+
})
84+
85+
describe('component', () => {
86+
it('should set registeredName to reading-progress-bar', () => {
87+
expect(ReadingProgressBar.registeredName).toBe('reading-progress-bar')
88+
})
89+
90+
it('should use light DOM', () => {
91+
const el = new ReadingProgressBar()
92+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93+
expect((el as any).createRenderRoot()).toBe(el)
94+
})
95+
96+
it('should start progress at 0', () => {
97+
const { progress } = createDOM()
98+
expect(progress.value).toBe(0)
99+
})
100+
})
101+
102+
describe('progress calculation', () => {
103+
it('should compute progress based on content position', () => {
104+
const { progress, content } = createDOM(2000)
105+
106+
const instance = document.createElement('reading-progress-bar')
107+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
108+
const priv = instance as any
109+
priv.progressEl = progress
110+
priv.contentEl = content
111+
112+
vi.spyOn(content, 'getBoundingClientRect').mockReturnValue({
113+
top: -500,
114+
height: 2000,
115+
bottom: 1500,
116+
left: 0,
117+
right: 800,
118+
width: 800,
119+
x: 0,
120+
y: -500,
121+
toJSON: vi.fn(),
122+
})
123+
124+
priv.updateProgress()
125+
126+
expect(progress.value).toBeGreaterThan(0)
127+
expect(progress.value).toBeLessThanOrEqual(100)
128+
})
129+
130+
it('should clamp progress to 0-100 range', () => {
131+
const { progress, content } = createDOM(2000)
132+
133+
const instance = document.createElement('reading-progress-bar')
134+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
135+
const priv = instance as any
136+
priv.progressEl = progress
137+
priv.contentEl = content
138+
139+
vi.spyOn(content, 'getBoundingClientRect').mockReturnValue({
140+
top: -5000,
141+
height: 2000,
142+
bottom: -3000,
143+
left: 0,
144+
right: 800,
145+
width: 800,
146+
x: 0,
147+
y: -5000,
148+
toJSON: vi.fn(),
149+
})
150+
151+
priv.updateProgress()
152+
153+
expect(progress.value).toBe(100)
154+
})
155+
156+
it('should set 100% when content fits within one viewport', () => {
157+
const { progress, content } = createDOM(500)
158+
159+
const instance = document.createElement('reading-progress-bar')
160+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
161+
const priv = instance as any
162+
priv.progressEl = progress
163+
priv.contentEl = content
164+
165+
vi.spyOn(content, 'getBoundingClientRect').mockReturnValue({
166+
top: 100,
167+
height: 500,
168+
bottom: 600,
169+
left: 0,
170+
right: 800,
171+
width: 800,
172+
x: 0,
173+
y: 100,
174+
toJSON: vi.fn(),
175+
})
176+
177+
priv.updateProgress()
178+
179+
expect(progress.value).toBe(100)
180+
})
181+
})
182+
})
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Article reading progress bar – Lit web component
3+
*
4+
* Renders a fixed `<progress>` element below the header that fills as
5+
* the visitor scrolls through the article `#content` region. Uses the
6+
* layout-position store's `--layout-top-offset` to position itself
7+
* directly beneath the header.
8+
*/
9+
import { LitElement } from 'lit'
10+
import { defineCustomElement } from '@components/scripts/utils'
11+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
12+
import { handleScriptError } from '@components/scripts/errors/handler'
13+
14+
export class ReadingProgressBar extends LitElement {
15+
static registeredName = 'reading-progress-bar'
16+
17+
/** Keep the element in light DOM so Tailwind / CSS variables work. */
18+
protected override createRenderRoot() {
19+
return this
20+
}
21+
22+
// ── Internal state ──────────────────────────────────────────────────
23+
private progressEl: HTMLProgressElement | null = null
24+
private contentEl: HTMLElement | null = null
25+
private rafId: number | null = null
26+
private scrollHandler: (() => void) | null = null
27+
private resizeHandler: (() => void) | null = null
28+
29+
// ── Lifecycle ───────────────────────────────────────────────────────
30+
31+
override connectedCallback(): void {
32+
super.connectedCallback()
33+
this.cacheElements()
34+
this.attachListeners()
35+
this.updateProgress()
36+
}
37+
38+
override disconnectedCallback(): void {
39+
this.detachListeners()
40+
super.disconnectedCallback()
41+
}
42+
43+
// ── DOM ─────────────────────────────────────────────────────────────
44+
45+
private cacheElements(): void {
46+
this.progressEl = this.querySelector<HTMLProgressElement>('progress')
47+
this.contentEl = document.querySelector<HTMLElement>('#content')
48+
}
49+
50+
// ── Listeners ───────────────────────────────────────────────────────
51+
52+
private attachListeners(): void {
53+
this.scrollHandler = () => this.scheduleUpdate()
54+
this.resizeHandler = () => this.scheduleUpdate()
55+
56+
window.addEventListener('scroll', this.scrollHandler, { passive: true })
57+
document.addEventListener('scroll', this.scrollHandler, { passive: true, capture: true })
58+
window.addEventListener('resize', this.resizeHandler, { passive: true })
59+
}
60+
61+
private detachListeners(): void {
62+
if (this.scrollHandler) {
63+
window.removeEventListener('scroll', this.scrollHandler)
64+
document.removeEventListener('scroll', this.scrollHandler, { capture: true })
65+
}
66+
if (this.resizeHandler) {
67+
window.removeEventListener('resize', this.resizeHandler)
68+
}
69+
if (this.rafId !== null) {
70+
cancelAnimationFrame(this.rafId)
71+
this.rafId = null
72+
}
73+
}
74+
75+
// ── Progress calculation ────────────────────────────────────────────
76+
77+
private scheduleUpdate(): void {
78+
if (this.rafId !== null) return
79+
this.rafId = requestAnimationFrame(() => {
80+
this.rafId = null
81+
this.updateProgress()
82+
})
83+
}
84+
85+
/** Compute scroll progress through the `#content` region as 0–100. */
86+
private updateProgress(): void {
87+
try {
88+
if (!this.progressEl || !this.contentEl) return
89+
90+
const rect = this.contentEl.getBoundingClientRect()
91+
const viewportHeight = window.innerHeight
92+
93+
// Total scrollable distance for the content region
94+
const totalHeight = rect.height
95+
if (totalHeight <= 0) {
96+
this.progressEl.value = 0
97+
return
98+
}
99+
100+
// How far past the top of the viewport has the content scrolled?
101+
// rect.top starts positive (below viewport top) and becomes negative.
102+
const scrolled = -rect.top
103+
const scrollableDistance = totalHeight - viewportHeight
104+
105+
if (scrollableDistance <= 0) {
106+
// Content fits within one screen
107+
this.progressEl.value = 100
108+
return
109+
}
110+
111+
const progress = Math.min(100, Math.max(0, (scrolled / scrollableDistance) * 100))
112+
this.progressEl.value = progress
113+
} catch (error) {
114+
handleScriptError(error, {
115+
scriptName: 'ReadingProgressBar',
116+
operation: 'updateProgress',
117+
})
118+
}
119+
}
120+
}
121+
122+
export const registerProgressBarComponent = async (
123+
tagName = ReadingProgressBar.registeredName,
124+
): Promise<void> => {
125+
defineCustomElement(tagName, ReadingProgressBar)
126+
}
127+
128+
export const webComponentModule: WebComponentModule<ReadingProgressBar> = {
129+
registeredName: ReadingProgressBar.registeredName,
130+
componentCtor: ReadingProgressBar,
131+
registerWebComponent: registerProgressBarComponent,
132+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
/**
3+
* Article reading progress bar.
4+
*
5+
* Fixed below the header, shows scroll progress through the article.
6+
* Rendered as a Lit web component wrapping a native <progress> element.
7+
* Uses --layout-top-offset from the layout-position store for vertical
8+
* positioning. The data-progress-bar attribute lets the store measure it.
9+
*/
10+
---
11+
12+
<reading-progress-bar
13+
class="fixed left-0 right-0 block h-1 sm:h-2"
14+
style="top: var(--layout-top-offset, 0px); z-index: var(--z-nav);"
15+
data-progress-bar
16+
aria-hidden="true"
17+
>
18+
<progress
19+
max="100"
20+
value="0"
21+
class="block h-full w-full appearance-none [&::-webkit-progress-bar]:bg-transparent [&::-webkit-progress-value]:bg-success [&::-moz-progress-bar]:bg-success"
22+
></progress>
23+
</reading-progress-bar>
24+
25+
<script>
26+
import { registerProgressBarComponent } from '@components/Content/ProgressBar/client'
27+
registerProgressBarComponent()
28+
</script>

‎src/components/Content/Switcher/client/__tests__/index.spec.ts‎

Whitespace-only changes.

‎src/components/Content/Switcher/client/index.ts‎

Whitespace-only changes.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
import './index.css'
3+
4+
export type Props = {
5+
/** The currently active variant, used to determine the position of the switcher thumb */
6+
currentVariant: 'overview' | 'deep-dive'
7+
/** The slug of the current article, used to construct links to the overview and deep dive pages */
8+
slug: string
9+
}
10+
11+
const { currentVariant, slug } = Astro.props
12+
---
13+
14+
<div class="flex items-center gap-3" role="radiogroup" aria-label="Content Variant">
15+
<span
16+
class="text-sm font-medium text-content-active content-switcher-label content-switcher-label--overview"
17+
id="v1-overview-label"
18+
>
19+
Overview
20+
</span>
21+
22+
<button
23+
type="button"
24+
role="switch"
25+
aria-checked={currentVariant === 'deep-dive' ? 'true' : 'false'}
26+
aria-label="Toggle between Overview and Deep Dive"
27+
class="content-switcher-track group relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-trim-offset transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary"
28+
>
29+
<span class="content-switcher-thumb pointer-events-none inline-block size-5 translate-x-0.5 rounded-full bg-page-base shadow-sm ring-0 transition-transform" />
30+
</button>
31+
32+
<span
33+
class="text-sm text-content-offset content-switcher-label content-switcher-label--deep-dive"
34+
id="v1-deep-dive-label"
35+
>
36+
Deep Dive
37+
</span>
38+
</div>

‎src/components/Content/Switcher/index.css‎

Whitespace-only changes.

0 commit comments

Comments
 (0)