@fullstackdatasolutions/articles 0.2.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ import Image from 'next/image'
2
+ import Link from 'next/link'
3
+ import { Calendar, Clock, User } from 'lucide-react'
4
+ import type { Article } from './articleTypes'
5
+
6
+ type ArticleDetailHeroProps = Readonly<{
7
+ article: Article
8
+ categoryBasePath?: string
9
+ showDate?: boolean
10
+ }>
11
+
12
+ function toSlug(cat: string): string {
13
+ return cat
14
+ .toLowerCase()
15
+ .replaceAll(/\s+/g, '-')
16
+ .replaceAll(/[^a-z0-9-]/g, '')
17
+ }
18
+
19
+ export function ArticleDetailHero({
20
+ article,
21
+ categoryBasePath = '/articles/category',
22
+ showDate = false,
23
+ }: ArticleDetailHeroProps) {
24
+ return (
25
+ <section
26
+ style={{
27
+ position: 'relative',
28
+ height: '18rem',
29
+ display: 'flex',
30
+ alignItems: 'center',
31
+ justifyContent: 'center',
32
+ overflow: 'hidden',
33
+ }}
34
+ >
35
+ <Image
36
+ src={article.featuredImage}
37
+ alt={article.title}
38
+ fill
39
+ sizes="100vw"
40
+ className="object-cover"
41
+ style={{ objectFit: 'cover' }}
42
+ priority
43
+ />
44
+ <div
45
+ style={{
46
+ position: 'absolute',
47
+ inset: 0,
48
+ backgroundColor: 'rgba(0,0,0,0.6)',
49
+ }}
50
+ />
51
+ <div
52
+ style={{
53
+ position: 'relative',
54
+ zIndex: 10,
55
+ textAlign: 'center',
56
+ color: 'white',
57
+ padding: '0 1rem',
58
+ maxWidth: '56rem',
59
+ margin: '0 auto',
60
+ width: '100%',
61
+ }}
62
+ >
63
+ {article.categories && article.categories.length > 0 && (
64
+ <div
65
+ style={{
66
+ display: 'flex',
67
+ flexWrap: 'wrap',
68
+ justifyContent: 'center',
69
+ gap: '0.5rem',
70
+ marginBottom: '1rem',
71
+ }}
72
+ >
73
+ {article.categories.slice(0, 4).map((cat) =>
74
+ categoryBasePath ? (
75
+ <Link
76
+ key={cat}
77
+ href={`${categoryBasePath}/${toSlug(cat)}`}
78
+ className="inline-block bg-white/20 text-white text-sm font-medium px-3 py-1 rounded-full hover:bg-white/30 transition-colors"
79
+ >
80
+ {cat}
81
+ </Link>
82
+ ) : (
83
+ <span
84
+ key={cat}
85
+ className="inline-block bg-white/20 text-white text-sm font-medium px-3 py-1 rounded-full"
86
+ >
87
+ {cat}
88
+ </span>
89
+ )
90
+ )}
91
+ </div>
92
+ )}
93
+ <h1 className="text-4xl md:text-5xl font-bold mb-4">{article.title}</h1>
94
+ <div
95
+ style={{
96
+ display: 'flex',
97
+ alignItems: 'center',
98
+ justifyContent: 'center',
99
+ gap: '1.5rem',
100
+ fontSize: '0.875rem',
101
+ color: 'rgba(255,255,255,0.8)',
102
+ }}
103
+ >
104
+ {showDate && article.date && (
105
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
106
+ <Calendar className="h-4 w-4" />
107
+ <span>{article.date}</span>
108
+ </div>
109
+ )}
110
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
111
+ <Clock className="h-4 w-4" />
112
+ <span>{article.readTime}</span>
113
+ </div>
114
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
115
+ <User className="h-4 w-4" />
116
+ <span>By {article.author}</span>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ </section>
121
+ )
122
+ }
@@ -0,0 +1,56 @@
1
+ import Link from 'next/link'
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
3
+
4
+ interface ArticleNavigationProps {
5
+ readonly previous: { slug: string; title: string } | null
6
+ readonly next: { slug: string; title: string } | null
7
+ readonly basePath: string
8
+ }
9
+
10
+ function truncateTitle(title: string, maxLength = 60): string {
11
+ return title.length > maxLength ? `${title.substring(0, maxLength)}...` : title
12
+ }
13
+
14
+ export function ArticleNavigation({ previous, next, basePath }: ArticleNavigationProps) {
15
+ if (!previous && !next) return null
16
+
17
+ return (
18
+ <nav className="mt-12 pt-8 border-t border-border" aria-label="Article navigation">
19
+ <div className="flex justify-between gap-4">
20
+ <div className="flex-1 min-w-0">
21
+ {previous && (
22
+ <Link
23
+ href={`${basePath}/${previous.slug}`}
24
+ className="group flex items-center gap-3 p-4 rounded-lg border border-border hover:border-primary/20 hover:bg-muted/50 transition-colors"
25
+ >
26
+ <ChevronLeft className="h-5 w-5 text-muted-foreground group-hover:text-primary shrink-0" />
27
+ <div className="min-w-0 flex-1">
28
+ <div className="text-sm text-muted-foreground mb-1">Previous Article</div>
29
+ <div className="font-medium text-foreground truncate" title={previous.title}>
30
+ {truncateTitle(previous.title)}
31
+ </div>
32
+ </div>
33
+ </Link>
34
+ )}
35
+ </div>
36
+
37
+ <div className="flex-1 min-w-0">
38
+ {next && (
39
+ <Link
40
+ href={`${basePath}/${next.slug}`}
41
+ className="group flex items-center justify-end gap-3 p-4 rounded-lg border border-border hover:border-primary/20 hover:bg-muted/50 transition-colors"
42
+ >
43
+ <div className="min-w-0 flex-1 text-right">
44
+ <div className="text-sm text-muted-foreground mb-1">Next Article</div>
45
+ <div className="font-medium text-foreground truncate" title={next.title}>
46
+ {truncateTitle(next.title)}
47
+ </div>
48
+ </div>
49
+ <ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary shrink-0" />
50
+ </Link>
51
+ )}
52
+ </div>
53
+ </div>
54
+ </nav>
55
+ )
56
+ }
@@ -0,0 +1,96 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Share2, Copy, Check, Mail, MessageCircle, Users, FileText } from 'lucide-react'
5
+
6
+ interface ArticleSocialShareProps {
7
+ readonly title: string
8
+ readonly url: string
9
+ readonly excerpt?: string
10
+ readonly shareMessage?: string
11
+ }
12
+
13
+ export function ArticleSocialShare({ title, url, excerpt, shareMessage }: ArticleSocialShareProps) {
14
+ const [copied, setCopied] = useState(false)
15
+
16
+ const encodedTitle = encodeURIComponent(title)
17
+ const encodedUrl = encodeURIComponent(url)
18
+ const encodedExcerpt = encodeURIComponent(excerpt || '')
19
+
20
+ const shareLinks = {
21
+ linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
22
+ facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
23
+ twitter: `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`,
24
+ reddit: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
25
+ email: `mailto:?subject=${encodedTitle}&body=${encodedExcerpt}%0A%0A${encodedUrl}`,
26
+ whatsapp: `https://wa.me/?text=${encodedTitle}%20${encodedUrl}`,
27
+ telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
28
+ }
29
+
30
+ const copyToClipboard = async () => {
31
+ try {
32
+ await navigator.clipboard.writeText(url)
33
+ setCopied(true)
34
+ setTimeout(() => setCopied(false), 2000)
35
+ } catch {
36
+ // clipboard unavailable
37
+ }
38
+ }
39
+
40
+ const openShareWindow = (shareUrl: string) => {
41
+ window.open(shareUrl, '_blank', 'width=600,height=400,scrollbars=yes,resizable=yes')
42
+ }
43
+
44
+ const btnClass =
45
+ 'inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground hover:bg-primary/10 hover:border-primary hover:text-primary transition-colors cursor-pointer'
46
+
47
+ return (
48
+ <div className="border-t border-border pt-8 mt-8">
49
+ <div className="flex items-center gap-2 mb-4">
50
+ <Share2 className="h-5 w-5 text-muted-foreground" />
51
+ <h3 className="text-lg font-semibold text-foreground">Share this article</h3>
52
+ </div>
53
+
54
+ <div className="flex flex-wrap gap-2">
55
+ <button className={btnClass} onClick={() => openShareWindow(shareLinks.linkedin)}>
56
+ <Users className="h-4 w-4" />
57
+ LinkedIn
58
+ </button>
59
+ <button className={btnClass} onClick={() => openShareWindow(shareLinks.facebook)}>
60
+ <span className="text-xs font-bold">f</span> Facebook
61
+ </button>
62
+ <button className={btnClass} onClick={() => openShareWindow(shareLinks.twitter)}>
63
+ <MessageCircle className="h-4 w-4" />
64
+ Twitter
65
+ </button>
66
+ <button className={btnClass} onClick={() => openShareWindow(shareLinks.reddit)}>
67
+ <FileText className="h-4 w-4" />
68
+ Reddit
69
+ </button>
70
+ <button className={btnClass} onClick={() => openShareWindow(shareLinks.whatsapp)}>
71
+ <MessageCircle className="h-4 w-4" />
72
+ WhatsApp
73
+ </button>
74
+ <button className={btnClass} onClick={() => openShareWindow(shareLinks.telegram)}>
75
+ <MessageCircle className="h-4 w-4" />
76
+ Telegram
77
+ </button>
78
+ <button
79
+ className={btnClass}
80
+ onClick={() => {
81
+ globalThis.location.href = shareLinks.email
82
+ }}
83
+ >
84
+ <Mail className="h-4 w-4" />
85
+ Email
86
+ </button>
87
+ <button className={btnClass} onClick={copyToClipboard}>
88
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
89
+ {copied ? 'Copied!' : 'Copy Link'}
90
+ </button>
91
+ </div>
92
+
93
+ {shareMessage && <p className="text-sm text-muted-foreground mt-3">{shareMessage}</p>}
94
+ </div>
95
+ )
96
+ }
@@ -43,17 +43,34 @@ export function CategoryArticlesPage({ category, articles, config }: CategoryArt
43
43
  ]}
44
44
  />
45
45
  {/* Hero */}
46
- <section className="relative h-72 md:h-96 flex items-center justify-center overflow-hidden">
46
+ <section
47
+ style={{
48
+ position: 'relative',
49
+ height: '20rem',
50
+ display: 'flex',
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ overflow: 'hidden',
54
+ }}
55
+ >
47
56
  <Image
48
57
  src={heroImage}
49
58
  alt={categoryName}
50
59
  fill
51
60
  sizes="100vw"
52
- className="object-cover"
61
+ style={{ objectFit: 'cover' }}
53
62
  priority
54
63
  />
55
- <div className="absolute inset-0 bg-black/60" />
56
- <div className="relative z-10 text-center text-white px-4">
64
+ <div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)' }} />
65
+ <div
66
+ style={{
67
+ position: 'relative',
68
+ zIndex: 10,
69
+ textAlign: 'center',
70
+ color: 'white',
71
+ padding: '0 1rem',
72
+ }}
73
+ >
57
74
  <p className="text-sm font-semibold uppercase tracking-widest mb-3 opacity-80">
58
75
  Category
59
76
  </p>
@@ -2,7 +2,7 @@
2
2
  * @jest-environment jsdom
3
3
  */
4
4
  import React from 'react'
5
- import { render, screen } from '@testing-library/react'
5
+ import { render, screen, fireEvent } from '@testing-library/react'
6
6
  import type { CategoryInfo } from '../articleTypes'
7
7
  import { ArticleCategoryGrid } from '../ArticleCategoryGrid'
8
8
 
@@ -50,6 +50,12 @@ describe('ArticleCategoryGrid', () => {
50
50
  expect(screen.getByText('Browse by Category')).toBeInTheDocument()
51
51
  })
52
52
 
53
+ it('grid container has responsive layout classes matching LatestArticles', () => {
54
+ const { container } = render(<ArticleCategoryGrid categories={mockCategories} />)
55
+ const grid = container.querySelector(String.raw`.grid.md\:grid-cols-2.lg\:grid-cols-3.gap-6`)
56
+ expect(grid).toBeInTheDocument()
57
+ })
58
+
53
59
  it('renders a card for each category', () => {
54
60
  render(<ArticleCategoryGrid categories={mockCategories} />)
55
61
  expect(screen.getByText('Campaigns')).toBeInTheDocument()
@@ -95,4 +101,37 @@ describe('ArticleCategoryGrid', () => {
95
101
  expect(screen.getByText(label)).toBeInTheDocument()
96
102
  })
97
103
  })
104
+
105
+ describe('Load more', () => {
106
+ it('shows Load more button when categories exceed pageSize', () => {
107
+ const cats: CategoryInfo[] = Array.from({ length: 5 }, (_, i) => ({
108
+ name: `Cat ${i + 1}`,
109
+ slug: `cat-${i + 1}`,
110
+ count: i + 1,
111
+ featuredImage: '/image.jpg',
112
+ }))
113
+ render(<ArticleCategoryGrid categories={cats} pageSize={3} />)
114
+ expect(screen.getByRole('button', { name: /load more categories/i })).toBeInTheDocument()
115
+ // only first 3 visible
116
+ expect(screen.getByText('Cat 1')).toBeInTheDocument()
117
+ expect(screen.queryByText('Cat 4')).not.toBeInTheDocument()
118
+ })
119
+
120
+ it('clicking Load more reveals more categories', () => {
121
+ const cats: CategoryInfo[] = Array.from({ length: 5 }, (_, i) => ({
122
+ name: `Cat ${i + 1}`,
123
+ slug: `cat-${i + 1}`,
124
+ count: i + 1,
125
+ featuredImage: '/image.jpg',
126
+ }))
127
+ render(<ArticleCategoryGrid categories={cats} pageSize={3} />)
128
+ fireEvent.click(screen.getByRole('button', { name: /load more categories/i }))
129
+ expect(screen.getByText('Cat 4')).toBeInTheDocument()
130
+ expect(screen.getByText('Cat 5')).toBeInTheDocument()
131
+ // button disappears when all are shown
132
+ expect(
133
+ screen.queryByRole('button', { name: /load more categories/i })
134
+ ).not.toBeInTheDocument()
135
+ })
136
+ })
98
137
  })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen } from '@testing-library/react'
6
+ import type { Article } from '../articleTypes'
7
+ import { ArticleDetailHero } from '../ArticleDetailHero'
8
+
9
+ jest.mock('next/link', () => ({
10
+ __esModule: true,
11
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
12
+ <a href={href}>{children}</a>
13
+ ),
14
+ }))
15
+
16
+ jest.mock('next/image', () => ({
17
+ __esModule: true,
18
+ default: (props: React.ComponentProps<'img'>) => <img alt="test" {...props} />,
19
+ }))
20
+
21
+ const baseArticle: Article = {
22
+ slug: 'test-article',
23
+ title: 'Test Article Title',
24
+ excerpt: 'Test excerpt',
25
+ author: 'Jane Doe',
26
+ category: 'Politics',
27
+ categories: [],
28
+ readTime: '5 min read',
29
+ featuredImage: '/images/test.jpg',
30
+ }
31
+
32
+ const articleWith6Categories: Article = {
33
+ ...baseArticle,
34
+ categories: ['Politics', 'Elections', 'Voting', 'Campaigns', 'Democracy', 'Civic Engagement'],
35
+ }
36
+
37
+ const articleWith3Categories: Article = {
38
+ ...baseArticle,
39
+ categories: ['Politics', 'Elections', 'Voting'],
40
+ }
41
+
42
+ describe('ArticleDetailHero', () => {
43
+ describe('category cap at 4', () => {
44
+ it('renders at most 4 category tags when the article has more than 4 categories', () => {
45
+ render(<ArticleDetailHero article={articleWith6Categories} />)
46
+ const rendered = [
47
+ screen.queryByText('Politics'),
48
+ screen.queryByText('Elections'),
49
+ screen.queryByText('Voting'),
50
+ screen.queryByText('Campaigns'),
51
+ ]
52
+ rendered.forEach((el) => expect(el).toBeInTheDocument())
53
+ expect(screen.queryByText('Democracy')).not.toBeInTheDocument()
54
+ expect(screen.queryByText('Civic Engagement')).not.toBeInTheDocument()
55
+ })
56
+
57
+ it('renders all categories when the article has 4 or fewer categories', () => {
58
+ render(<ArticleDetailHero article={articleWith3Categories} />)
59
+ expect(screen.getByText('Politics')).toBeInTheDocument()
60
+ expect(screen.getByText('Elections')).toBeInTheDocument()
61
+ expect(screen.getByText('Voting')).toBeInTheDocument()
62
+ })
63
+ })
64
+
65
+ describe('category rendering with default categoryBasePath', () => {
66
+ it('renders category tags as links to /articles/category/[slug] when categoryBasePath is not provided', () => {
67
+ render(<ArticleDetailHero article={articleWith3Categories} />)
68
+ const politicsLink = screen.getByRole('link', { name: 'Politics' })
69
+ expect(politicsLink).toHaveAttribute('href', '/articles/category/politics')
70
+ })
71
+
72
+ it('renders category tags as spans when categoryBasePath is empty string', () => {
73
+ render(<ArticleDetailHero article={articleWith3Categories} categoryBasePath="" />)
74
+ const links = screen.queryAllByRole('link')
75
+ const categoryLinks = links.filter((el) =>
76
+ ['Politics', 'Elections', 'Voting'].includes(el.textContent ?? '')
77
+ )
78
+ expect(categoryLinks).toHaveLength(0)
79
+ })
80
+ })
81
+
82
+ describe('category rendering as links (with categoryBasePath)', () => {
83
+ it('renders category tags as links when categoryBasePath is provided', () => {
84
+ render(
85
+ <ArticleDetailHero article={articleWith3Categories} categoryBasePath="/articles/category" />
86
+ )
87
+ const politicsLink = screen.getByRole('link', { name: 'Politics' })
88
+ expect(politicsLink).toHaveAttribute('href', '/articles/category/politics')
89
+ })
90
+
91
+ it('caps linked category tags at 4 when article has more than 4 categories', () => {
92
+ render(
93
+ <ArticleDetailHero article={articleWith6Categories} categoryBasePath="/articles/category" />
94
+ )
95
+ const links = screen
96
+ .getAllByRole('link')
97
+ .filter((el) =>
98
+ [
99
+ 'Politics',
100
+ 'Elections',
101
+ 'Voting',
102
+ 'Campaigns',
103
+ 'Democracy',
104
+ 'Civic Engagement',
105
+ ].includes(el.textContent ?? '')
106
+ )
107
+ expect(links).toHaveLength(4)
108
+ expect(screen.queryByRole('link', { name: 'Democracy' })).not.toBeInTheDocument()
109
+ expect(screen.queryByRole('link', { name: 'Civic Engagement' })).not.toBeInTheDocument()
110
+ })
111
+ })
112
+
113
+ describe('showDate prop', () => {
114
+ const articleWithDate: Article = {
115
+ ...baseArticle,
116
+ date: '2024-01-15',
117
+ }
118
+
119
+ it('does not render the date when showDate is omitted (default behavior)', () => {
120
+ render(<ArticleDetailHero article={articleWithDate} />)
121
+ expect(screen.queryByText('2024-01-15')).not.toBeInTheDocument()
122
+ })
123
+
124
+ it('does not render the date when showDate={false}', () => {
125
+ render(<ArticleDetailHero article={articleWithDate} showDate={false} />)
126
+ expect(screen.queryByText('2024-01-15')).not.toBeInTheDocument()
127
+ })
128
+
129
+ it('renders the date when showDate={true} and article.date is set', () => {
130
+ render(<ArticleDetailHero article={articleWithDate} showDate={true} />)
131
+ expect(screen.getByText('2024-01-15')).toBeInTheDocument()
132
+ })
133
+ })
134
+ })
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen } from '@testing-library/react'
6
+ import { ArticleNavigation } from '../ArticleNavigation'
7
+
8
+ jest.mock('next/link', () => ({
9
+ __esModule: true,
10
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
11
+ <a href={href}>{children}</a>
12
+ ),
13
+ }))
14
+
15
+ const basePath = '/articles'
16
+
17
+ const prevArticle = { slug: 'previous-article', title: 'Previous Article Title' }
18
+ const nextArticle = { slug: 'next-article', title: 'Next Article Title' }
19
+
20
+ describe('ArticleNavigation', () => {
21
+ describe('null rendering', () => {
22
+ it('renders nothing when both previous and next are null', () => {
23
+ const { container } = render(
24
+ <ArticleNavigation previous={null} next={null} basePath={basePath} />
25
+ )
26
+ expect(container.firstChild).toBeNull()
27
+ })
28
+ })
29
+
30
+ describe('previous link', () => {
31
+ it('renders the previous link with the correct href', () => {
32
+ render(<ArticleNavigation previous={prevArticle} next={null} basePath={basePath} />)
33
+ const link = screen.getByRole('link', { name: /previous article title/i })
34
+ expect(link).toHaveAttribute('href', '/articles/previous-article')
35
+ })
36
+
37
+ it('shows the "Previous Article" label', () => {
38
+ render(<ArticleNavigation previous={prevArticle} next={null} basePath={basePath} />)
39
+ expect(screen.getByText('Previous Article')).toBeInTheDocument()
40
+ })
41
+ })
42
+
43
+ describe('next link', () => {
44
+ it('renders the next link with the correct href', () => {
45
+ render(<ArticleNavigation previous={null} next={nextArticle} basePath={basePath} />)
46
+ const link = screen.getByRole('link', { name: /next article title/i })
47
+ expect(link).toHaveAttribute('href', '/articles/next-article')
48
+ })
49
+
50
+ it('shows the "Next Article" label', () => {
51
+ render(<ArticleNavigation previous={null} next={nextArticle} basePath={basePath} />)
52
+ expect(screen.getByText('Next Article')).toBeInTheDocument()
53
+ })
54
+ })
55
+
56
+ describe('both links', () => {
57
+ it('renders both previous and next links when both are provided', () => {
58
+ render(<ArticleNavigation previous={prevArticle} next={nextArticle} basePath={basePath} />)
59
+ expect(screen.getByRole('link', { name: /previous article title/i })).toHaveAttribute(
60
+ 'href',
61
+ '/articles/previous-article'
62
+ )
63
+ expect(screen.getByRole('link', { name: /next article title/i })).toHaveAttribute(
64
+ 'href',
65
+ '/articles/next-article'
66
+ )
67
+ expect(screen.getByText('Previous Article')).toBeInTheDocument()
68
+ expect(screen.getByText('Next Article')).toBeInTheDocument()
69
+ })
70
+ })
71
+
72
+ describe('title truncation', () => {
73
+ it('truncates titles longer than 60 characters to 60 chars followed by "..."', () => {
74
+ const longTitle = 'A'.repeat(61)
75
+ render(
76
+ <ArticleNavigation
77
+ previous={{ slug: 'long-prev', title: longTitle }}
78
+ next={null}
79
+ basePath={basePath}
80
+ />
81
+ )
82
+ const truncated = 'A'.repeat(60) + '...'
83
+ expect(screen.getByText(truncated)).toBeInTheDocument()
84
+ })
85
+
86
+ it('does not truncate a title of exactly 60 characters', () => {
87
+ const exactTitle = 'B'.repeat(60)
88
+ render(
89
+ <ArticleNavigation
90
+ previous={{ slug: 'exact-prev', title: exactTitle }}
91
+ next={null}
92
+ basePath={basePath}
93
+ />
94
+ )
95
+ expect(screen.getByText(exactTitle)).toBeInTheDocument()
96
+ expect(screen.queryByText(exactTitle + '...')).not.toBeInTheDocument()
97
+ })
98
+
99
+ it('does not truncate a title shorter than 60 characters', () => {
100
+ const shortTitle = 'Short Title'
101
+ render(
102
+ <ArticleNavigation
103
+ previous={{ slug: 'short-prev', title: shortTitle }}
104
+ next={null}
105
+ basePath={basePath}
106
+ />
107
+ )
108
+ expect(screen.getByText('Short Title')).toBeInTheDocument()
109
+ })
110
+ })
111
+
112
+ describe('basePath handling', () => {
113
+ it('uses a custom basePath in generated hrefs', () => {
114
+ render(
115
+ <ArticleNavigation previous={prevArticle} next={nextArticle} basePath="/news/articles" />
116
+ )
117
+ expect(screen.getByRole('link', { name: /previous article title/i })).toHaveAttribute(
118
+ 'href',
119
+ '/news/articles/previous-article'
120
+ )
121
+ expect(screen.getByRole('link', { name: /next article title/i })).toHaveAttribute(
122
+ 'href',
123
+ '/news/articles/next-article'
124
+ )
125
+ })
126
+ })
127
+ })