Skip to content

Commit 1d2ac0c

Browse files
ericyangpanclaude
andcommitted
feat: add PageHeader component and sticky breadcrumb navigation
- Add new PageHeader component for consistent page headers across the site - Implement sticky breadcrumb navigation with dynamic offset calculation - Update ComparisonTable to auto-calculate sticky offsets based on header and breadcrumb heights - Enhance breadcrumb with sticky behavior when scrolling - Add data-breadcrumb attribute for better component identification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0f82f78 commit 1d2ac0c

File tree

3 files changed

+131
-54
lines changed

3 files changed

+131
-54
lines changed

src/components/ComparisonTable.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default function ComparisonTable({
2626
itemLinkPrefix,
2727
itemNameKey = 'name',
2828
itemIdKey = 'id',
29-
stickyTopOffset = 60,
29+
stickyTopOffset,
3030
}: ComparisonTableProps) {
3131
const containerRef = useRef<HTMLDivElement>(null)
3232
const tableRef = useRef<HTMLTableElement>(null)
@@ -36,8 +36,35 @@ export default function ComparisonTable({
3636
const [theadHeight, setTheadHeight] = useState<number>(0)
3737
const [columnWidths, setColumnWidths] = useState<number[]>([])
3838
const [scrollLeft, setScrollLeft] = useState<number>(0)
39+
const [calculatedOffset, setCalculatedOffset] = useState<number>(0)
3940
const columnWidthsMeasured = useRef(false)
4041

42+
useEffect(() => {
43+
// Calculate sticky offset based on header and breadcrumb heights
44+
const calculateOffset = () => {
45+
if (stickyTopOffset !== undefined) {
46+
setCalculatedOffset(stickyTopOffset)
47+
return
48+
}
49+
50+
const header = document.querySelector('header')
51+
const breadcrumb = document.querySelector('[data-breadcrumb]')
52+
const headerHeight = header?.offsetHeight || 0
53+
const breadcrumbHeight = breadcrumb?.offsetHeight || 0
54+
setCalculatedOffset(headerHeight + breadcrumbHeight)
55+
}
56+
57+
calculateOffset()
58+
window.addEventListener('resize', calculateOffset)
59+
// Also recalculate on scroll to handle dynamic changes
60+
window.addEventListener('scroll', calculateOffset)
61+
62+
return () => {
63+
window.removeEventListener('resize', calculateOffset)
64+
window.removeEventListener('scroll', calculateOffset)
65+
}
66+
}, [stickyTopOffset])
67+
4168
useEffect(() => {
4269
const handleScroll = () => {
4370
if (!tableRef.current || !theadRef.current || !containerRef.current) return
@@ -47,7 +74,7 @@ export default function ComparisonTable({
4774
const theadHeight = theadRef.current.offsetHeight
4875

4976
// Check if table has scrolled past the sticky offset
50-
if (tableRect.top <= stickyTopOffset && tableRect.bottom > stickyTopOffset + theadHeight) {
77+
if (tableRect.top <= calculatedOffset && tableRect.bottom > calculatedOffset + theadHeight) {
5178
// Only measure widths once, before first fixing
5279
if (!columnWidthsMeasured.current) {
5380
const ths = theadRef.current.querySelectorAll('th')
@@ -79,16 +106,16 @@ export default function ComparisonTable({
79106
window.removeEventListener('resize', handleScroll)
80107
container?.removeEventListener('scroll', handleContainerScroll)
81108
}
82-
}, [stickyTopOffset])
109+
}, [calculatedOffset])
83110

84111
return (
85112
<>
86113
{/* Fixed header wrapper with clipping */}
87114
{isFixed && containerRef.current && (
88115
<div
89-
className="fixed z-40 overflow-hidden"
116+
className="fixed z-30 overflow-hidden"
90117
style={{
91-
top: `${stickyTopOffset}px`,
118+
top: `${calculatedOffset}px`,
92119
width: `${theadWidth}px`,
93120
left: `${containerRef.current.getBoundingClientRect().left}px`,
94121
pointerEvents: 'none',

src/components/PageHeader.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ReactNode } from 'react'
2+
3+
type PageHeaderProps = {
4+
title: string
5+
subtitle?: string
6+
action?: ReactNode
7+
}
8+
9+
export default function PageHeader({ title, subtitle, action }: PageHeaderProps) {
10+
return (
11+
<div className="mb-[var(--spacing-lg)]">
12+
<div className="flex items-start justify-between mb-[var(--spacing-sm)]">
13+
<h1 className="text-3xl font-semibold tracking-[-0.03em]">{title}</h1>
14+
{action}
15+
</div>
16+
{subtitle && (
17+
<p className="text-base text-[var(--color-text-secondary)] font-light">{subtitle}</p>
18+
)}
19+
</div>
20+
)
21+
}
Lines changed: 78 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
14
import { JsonLd } from '@/components/JsonLd'
25
import { Link } from '@/i18n/navigation'
36
import { SITE_CONFIG } from '@/lib/metadata/config'
@@ -10,17 +13,33 @@ export interface BreadcrumbItem {
1013
/**
1114
* Renders a breadcrumb navigation bar and injects a Schema.org BreadcrumbList via JsonLd.
1215
* - Visual trail is rendered from provided items; the last item is shown as plain text.
13-
* - Structured data includes an optional "Home" at the first position for better SEO.
16+
* - Structured data includes "Home" at the first position for better SEO.
17+
* - Sticky behavior is enabled when scrolling.
1418
*/
15-
export function Breadcrumb({
16-
items,
17-
siteOrigin = SITE_CONFIG.url,
18-
includeHome = true,
19-
}: {
20-
items: BreadcrumbItem[]
21-
siteOrigin?: string
22-
includeHome?: boolean
23-
}) {
19+
export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
20+
const breadcrumbRef = useRef<HTMLDivElement>(null)
21+
const [isBreadcrumbFixed, setIsBreadcrumbFixed] = useState(false)
22+
const [headerHeight, setHeaderHeight] = useState(0)
23+
24+
useEffect(() => {
25+
const handleScroll = () => {
26+
if (!breadcrumbRef.current) return
27+
const header = document.querySelector('header')
28+
const currentHeaderHeight = header?.offsetHeight || 0
29+
setHeaderHeight(currentHeaderHeight)
30+
const breadcrumbTop = breadcrumbRef.current.offsetTop
31+
setIsBreadcrumbFixed(window.scrollY > breadcrumbTop - currentHeaderHeight)
32+
}
33+
34+
window.addEventListener('scroll', handleScroll)
35+
window.addEventListener('resize', handleScroll)
36+
handleScroll()
37+
38+
return () => {
39+
window.removeEventListener('scroll', handleScroll)
40+
window.removeEventListener('resize', handleScroll)
41+
}
42+
}, [])
2443
// Normalize href to ensure it starts with '/' (unless it's already an absolute URL)
2544
const normalizeHref = (href: string): string => {
2645
// If it's already an absolute URL or starts with '/', return as is
@@ -32,23 +51,19 @@ export function Breadcrumb({
3251
}
3352

3453
const schemaItems = [
35-
...(includeHome
36-
? [
37-
{
38-
'@type': 'ListItem',
39-
position: 1,
40-
name: 'Home',
41-
item: `${siteOrigin}`,
42-
},
43-
]
44-
: []),
54+
{
55+
'@type': 'ListItem',
56+
position: 1,
57+
name: 'Home',
58+
item: SITE_CONFIG.url,
59+
},
4560
...items.map((item, index) => {
4661
const normalizedHref = normalizeHref(item.href)
4762
return {
4863
'@type': 'ListItem',
49-
position: (includeHome ? 2 : 1) + index,
64+
position: 2 + index,
5065
name: item.name,
51-
item: `${siteOrigin}${normalizedHref}`,
66+
item: `${SITE_CONFIG.url}${normalizedHref}`,
5267
}
5368
}),
5469
]
@@ -59,37 +74,51 @@ export function Breadcrumb({
5974
itemListElement: schemaItems,
6075
} as const
6176

77+
const BreadcrumbContent = () => (
78+
<section
79+
className="py-[var(--spacing-sm)] bg-[var(--color-hover)] border-b border-[var(--color-border)]"
80+
data-breadcrumb
81+
>
82+
<div className="max-w-8xl mx-auto px-[var(--spacing-md)]">
83+
<nav className="flex items-center gap-[var(--spacing-xs)] text-sm pl-[var(--spacing-xs)]">
84+
{items.map((item, index) => {
85+
const isLast = index === items.length - 1
86+
const normalizedHref = normalizeHref(item.href)
87+
return (
88+
<span
89+
key={`${item.href}-${index}`}
90+
className="inline-flex items-center gap-[var(--spacing-xs)]"
91+
>
92+
{isLast ? (
93+
<span className="text-[var(--color-text)] font-medium">{item.name}</span>
94+
) : (
95+
<Link
96+
href={normalizedHref}
97+
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
98+
>
99+
{item.name}
100+
</Link>
101+
)}
102+
{!isLast && <span className="text-[var(--color-text-muted)]">/</span>}
103+
</span>
104+
)
105+
})}
106+
</nav>
107+
</div>
108+
</section>
109+
)
110+
62111
return (
63112
<>
64113
<JsonLd data={breadcrumbListSchema} />
65-
<section className="py-[var(--spacing-sm)] bg-[var(--color-hover)] border-b border-[var(--color-border)]">
66-
<div className="max-w-8xl mx-auto px-[var(--spacing-md)]">
67-
<nav className="flex items-center gap-[var(--spacing-xs)] text-[0.8125rem]">
68-
{items.map((item, index) => {
69-
const isLast = index === items.length - 1
70-
const normalizedHref = normalizeHref(item.href)
71-
return (
72-
<span
73-
key={`${item.href}-${index}`}
74-
className="inline-flex items-center gap-[var(--spacing-xs)]"
75-
>
76-
{isLast ? (
77-
<span className="text-[var(--color-text)] font-medium">{item.name}</span>
78-
) : (
79-
<Link
80-
href={normalizedHref}
81-
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
82-
>
83-
{item.name}
84-
</Link>
85-
)}
86-
{!isLast && <span className="text-[var(--color-text-muted)]">/</span>}
87-
</span>
88-
)
89-
})}
90-
</nav>
114+
{isBreadcrumbFixed && (
115+
<div className="fixed left-0 right-0 z-40 shadow-sm" style={{ top: `${headerHeight}px` }}>
116+
<BreadcrumbContent />
91117
</div>
92-
</section>
118+
)}
119+
<div ref={breadcrumbRef} className={isBreadcrumbFixed ? 'invisible' : ''}>
120+
<BreadcrumbContent />
121+
</div>
93122
</>
94123
)
95124
}

0 commit comments

Comments
 (0)