@fullstackdatasolutions/articles 0.4.3 → 0.5.0
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.
- package/README.md +119 -2
- package/dist/index.cjs +182 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +241 -110
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +221 -188
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +36 -1
- package/dist/server.d.ts +36 -1
- package/dist/server.js +219 -188
- package/dist/server.js.map +1 -1
- package/package.json +5 -1
- package/src/ArticleContent.tsx +17 -0
- package/src/ArticleSchemas.tsx +97 -1
- package/src/ArticleSocialShare.tsx +1 -1
- package/src/ArticleTOC.tsx +29 -0
- package/src/ScrollToTop.tsx +25 -0
- package/src/__tests__/ArticleContent.test.tsx +91 -0
- package/src/__tests__/ArticleSchemas.test.tsx +212 -1
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -7
- package/src/__tests__/ArticleTOC.test.tsx +75 -0
- package/src/__tests__/ScrollToTop.test.tsx +87 -0
- package/src/__tests__/seoUtils.test.ts +256 -0
- package/src/__tests__/server-articles.test.ts +98 -0
- package/src/articleTypes.ts +26 -0
- package/src/articlesConfig.ts +4 -0
- package/src/index.ts +10 -2
- package/src/markdown.ts +143 -130
- package/src/seoUtils.ts +20 -14
- package/src/server-articles.ts +81 -78
- package/src/server.ts +3 -2
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { render } from '@testing-library/react'
|
|
2
2
|
import { Article } from '../articleTypes'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ArticleSchema,
|
|
5
|
+
ArticleSEO,
|
|
6
|
+
BreadcrumbSchema,
|
|
7
|
+
CollectionPageSchema,
|
|
8
|
+
FAQPageSchema,
|
|
9
|
+
} from '../ArticleSchemas'
|
|
4
10
|
|
|
5
11
|
const mockArticle: Article = {
|
|
6
12
|
slug: 'test-article',
|
|
@@ -21,6 +27,11 @@ function parseSchemaScript(container: HTMLElement) {
|
|
|
21
27
|
return JSON.parse(script!.innerHTML)
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
function parseAllSchemaScripts(container: HTMLElement) {
|
|
31
|
+
const scripts = container.querySelectorAll('script[type="application/ld+json"]')
|
|
32
|
+
return Array.from(scripts).map((s) => JSON.parse(s.innerHTML))
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
describe('ArticleSchema', () => {
|
|
25
36
|
it('renders a ld+json script tag', () => {
|
|
26
37
|
const { container } = render(
|
|
@@ -128,3 +139,203 @@ describe('CollectionPageSchema', () => {
|
|
|
128
139
|
expect(schema.url).toBe('https://example.com/articles')
|
|
129
140
|
})
|
|
130
141
|
})
|
|
142
|
+
|
|
143
|
+
describe('FAQPageSchema', () => {
|
|
144
|
+
const faqItems = [
|
|
145
|
+
{ question: 'What is this?', answer: 'A test.' },
|
|
146
|
+
{ question: 'Why?', answer: 'Because.' },
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
it('renders a ld+json script tag', () => {
|
|
150
|
+
const { container } = render(<FAQPageSchema items={faqItems} />)
|
|
151
|
+
expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('outputs schema.org/FAQPage with mainEntity items', () => {
|
|
155
|
+
const { container } = render(<FAQPageSchema items={faqItems} />)
|
|
156
|
+
const schema = parseSchemaScript(container)
|
|
157
|
+
expect(schema['@type']).toBe('FAQPage')
|
|
158
|
+
expect(schema.mainEntity).toHaveLength(2)
|
|
159
|
+
expect(schema.mainEntity[0]['@type']).toBe('Question')
|
|
160
|
+
expect(schema.mainEntity[0].name).toBe('What is this?')
|
|
161
|
+
expect(schema.mainEntity[0].acceptedAnswer['@type']).toBe('Answer')
|
|
162
|
+
expect(schema.mainEntity[0].acceptedAnswer.text).toBe('A test.')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('ArticleSEO', () => {
|
|
167
|
+
const baseArticle: Article = {
|
|
168
|
+
slug: 'test-article',
|
|
169
|
+
title: 'Test Article',
|
|
170
|
+
excerpt: 'A test excerpt.',
|
|
171
|
+
date: '2025-03-01',
|
|
172
|
+
author: 'Jane Doe',
|
|
173
|
+
category: 'Campaigns',
|
|
174
|
+
categories: ['Campaigns'],
|
|
175
|
+
readTime: '3 min read',
|
|
176
|
+
featuredImage: '/articles/test/image.jpg',
|
|
177
|
+
tags: ['campaigns'],
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
it('renders Article JSON-LD with correct fields', () => {
|
|
181
|
+
const { container } = render(
|
|
182
|
+
<ArticleSEO
|
|
183
|
+
article={baseArticle}
|
|
184
|
+
articleUrl="https://example.com/articles/test-article"
|
|
185
|
+
siteName="Example Site"
|
|
186
|
+
/>
|
|
187
|
+
)
|
|
188
|
+
const schemas = parseAllSchemaScripts(container)
|
|
189
|
+
const article = schemas.find((s) => s['@type'] === 'Article')
|
|
190
|
+
expect(article).toBeDefined()
|
|
191
|
+
expect(article.headline).toBe('Test Article')
|
|
192
|
+
expect(article.description).toBe('A test excerpt.')
|
|
193
|
+
expect(article.author.name).toBe('Jane Doe')
|
|
194
|
+
expect(article.publisher.name).toBe('Example Site')
|
|
195
|
+
expect(article.mainEntityOfPage['@id']).toBe('https://example.com/articles/test-article')
|
|
196
|
+
expect(article.datePublished).toBe(new Date('2025-03-01').toISOString())
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('uses articleType when provided', () => {
|
|
200
|
+
const { container } = render(
|
|
201
|
+
<ArticleSEO
|
|
202
|
+
article={{ ...baseArticle, articleType: 'TechArticle' }}
|
|
203
|
+
articleUrl="https://example.com/articles/test-article"
|
|
204
|
+
siteName="Example Site"
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
const schemas = parseAllSchemaScripts(container)
|
|
208
|
+
const article = schemas.find((s) => s['@type'] === 'TechArticle')
|
|
209
|
+
expect(article).toBeDefined()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('includes dateModified when lastmod is set', () => {
|
|
213
|
+
const { container } = render(
|
|
214
|
+
<ArticleSEO
|
|
215
|
+
article={{ ...baseArticle, lastmod: '2025-06-01' }}
|
|
216
|
+
articleUrl="https://example.com/articles/test-article"
|
|
217
|
+
siteName="Example Site"
|
|
218
|
+
/>
|
|
219
|
+
)
|
|
220
|
+
const schemas = parseAllSchemaScripts(container)
|
|
221
|
+
const article = schemas[0]
|
|
222
|
+
expect(article.dateModified).toBe(new Date('2025-06-01').toISOString())
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('includes isPartOf when series is set', () => {
|
|
226
|
+
const { container } = render(
|
|
227
|
+
<ArticleSEO
|
|
228
|
+
article={{ ...baseArticle, series: 'Campaign Guides' }}
|
|
229
|
+
articleUrl="https://example.com/articles/test-article"
|
|
230
|
+
siteName="Example Site"
|
|
231
|
+
/>
|
|
232
|
+
)
|
|
233
|
+
const schemas = parseAllSchemaScripts(container)
|
|
234
|
+
const article = schemas[0]
|
|
235
|
+
expect(article.isPartOf).toEqual({ '@type': 'Blog', name: 'Campaign Guides' })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('includes logo in publisher when siteLogo is provided', () => {
|
|
239
|
+
const { container } = render(
|
|
240
|
+
<ArticleSEO
|
|
241
|
+
article={baseArticle}
|
|
242
|
+
articleUrl="https://example.com/articles/test-article"
|
|
243
|
+
siteName="Example Site"
|
|
244
|
+
siteLogo="https://example.com/logo.png"
|
|
245
|
+
/>
|
|
246
|
+
)
|
|
247
|
+
const schemas = parseAllSchemaScripts(container)
|
|
248
|
+
const article = schemas[0]
|
|
249
|
+
expect(article.publisher.logo).toEqual({
|
|
250
|
+
'@type': 'ImageObject',
|
|
251
|
+
url: 'https://example.com/logo.png',
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('renders FAQPage JSON-LD when faq items are present', () => {
|
|
256
|
+
const faq = [{ question: 'What?', answer: 'This.' }]
|
|
257
|
+
const { container } = render(
|
|
258
|
+
<ArticleSEO
|
|
259
|
+
article={{ ...baseArticle, faq }}
|
|
260
|
+
articleUrl="https://example.com/articles/test-article"
|
|
261
|
+
siteName="Example Site"
|
|
262
|
+
/>
|
|
263
|
+
)
|
|
264
|
+
const schemas = parseAllSchemaScripts(container)
|
|
265
|
+
const faqSchema = schemas.find((s) => s['@type'] === 'FAQPage')
|
|
266
|
+
expect(faqSchema).toBeDefined()
|
|
267
|
+
expect(faqSchema.mainEntity[0].name).toBe('What?')
|
|
268
|
+
expect(faqSchema.mainEntity[0].acceptedAnswer.text).toBe('This.')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('does not render FAQPage JSON-LD when faq is absent', () => {
|
|
272
|
+
const { container } = render(
|
|
273
|
+
<ArticleSEO
|
|
274
|
+
article={baseArticle}
|
|
275
|
+
articleUrl="https://example.com/articles/test-article"
|
|
276
|
+
siteName="Example Site"
|
|
277
|
+
/>
|
|
278
|
+
)
|
|
279
|
+
const schemas = parseAllSchemaScripts(container)
|
|
280
|
+
expect(schemas.find((s) => s['@type'] === 'FAQPage')).toBeUndefined()
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('renders HowTo JSON-LD when howTo steps are present', () => {
|
|
284
|
+
const howTo = [
|
|
285
|
+
{ name: 'Step 1', text: 'Do this.' },
|
|
286
|
+
{ name: 'Step 2', text: 'Then that.' },
|
|
287
|
+
]
|
|
288
|
+
const { container } = render(
|
|
289
|
+
<ArticleSEO
|
|
290
|
+
article={{ ...baseArticle, howTo }}
|
|
291
|
+
articleUrl="https://example.com/articles/test-article"
|
|
292
|
+
siteName="Example Site"
|
|
293
|
+
/>
|
|
294
|
+
)
|
|
295
|
+
const schemas = parseAllSchemaScripts(container)
|
|
296
|
+
const howToSchema = schemas.find((s) => s['@type'] === 'HowTo')
|
|
297
|
+
expect(howToSchema).toBeDefined()
|
|
298
|
+
expect(howToSchema.name).toBe('Test Article')
|
|
299
|
+
expect(howToSchema.step).toHaveLength(2)
|
|
300
|
+
expect(howToSchema.step[0].name).toBe('Step 1')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('does not render HowTo JSON-LD when howTo is absent', () => {
|
|
304
|
+
const { container } = render(
|
|
305
|
+
<ArticleSEO
|
|
306
|
+
article={baseArticle}
|
|
307
|
+
articleUrl="https://example.com/articles/test-article"
|
|
308
|
+
siteName="Example Site"
|
|
309
|
+
/>
|
|
310
|
+
)
|
|
311
|
+
const schemas = parseAllSchemaScripts(container)
|
|
312
|
+
expect(schemas.find((s) => s['@type'] === 'HowTo')).toBeUndefined()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('omits datePublished and dateModified when article has no date', () => {
|
|
316
|
+
const { container } = render(
|
|
317
|
+
<ArticleSEO
|
|
318
|
+
article={{ ...baseArticle, date: undefined }}
|
|
319
|
+
articleUrl="https://example.com/articles/test-article"
|
|
320
|
+
siteName="Example Site"
|
|
321
|
+
/>
|
|
322
|
+
)
|
|
323
|
+
const schemas = parseAllSchemaScripts(container)
|
|
324
|
+
const schema = schemas[0]
|
|
325
|
+
expect(schema.datePublished).toBeUndefined()
|
|
326
|
+
expect(schema.dateModified).toBeUndefined()
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('omits image when featuredImage is empty', () => {
|
|
330
|
+
const { container } = render(
|
|
331
|
+
<ArticleSEO
|
|
332
|
+
article={{ ...baseArticle, featuredImage: '' }}
|
|
333
|
+
articleUrl="https://example.com/articles/test-article"
|
|
334
|
+
siteName="Example Site"
|
|
335
|
+
/>
|
|
336
|
+
)
|
|
337
|
+
const schemas = parseAllSchemaScripts(container)
|
|
338
|
+
const schema = schemas[0]
|
|
339
|
+
expect(schema.image).toBeUndefined()
|
|
340
|
+
})
|
|
341
|
+
})
|
|
@@ -10,7 +10,7 @@ const mockWriteText = jest.fn()
|
|
|
10
10
|
|
|
11
11
|
beforeEach(() => {
|
|
12
12
|
jest.clearAllMocks()
|
|
13
|
-
|
|
13
|
+
globalThis.open = mockOpen
|
|
14
14
|
Object.defineProperty(navigator, 'clipboard', {
|
|
15
15
|
value: { writeText: mockWriteText },
|
|
16
16
|
writable: true,
|
|
@@ -46,7 +46,7 @@ describe('ArticleSocialShare', () => {
|
|
|
46
46
|
})
|
|
47
47
|
|
|
48
48
|
describe('share window buttons', () => {
|
|
49
|
-
it('clicking LinkedIn calls
|
|
49
|
+
it('clicking LinkedIn calls globalThis.open with the correct LinkedIn URL', () => {
|
|
50
50
|
render(<ArticleSocialShare {...defaultProps} />)
|
|
51
51
|
fireEvent.click(screen.getByRole('button', { name: /linkedin/i }))
|
|
52
52
|
const encodedUrl = encodeURIComponent(defaultProps.url)
|
|
@@ -57,7 +57,7 @@ describe('ArticleSocialShare', () => {
|
|
|
57
57
|
)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
it('clicking Facebook calls
|
|
60
|
+
it('clicking Facebook calls globalThis.open with the correct Facebook URL', () => {
|
|
61
61
|
render(<ArticleSocialShare {...defaultProps} />)
|
|
62
62
|
fireEvent.click(screen.getByRole('button', { name: /facebook/i }))
|
|
63
63
|
const encodedUrl = encodeURIComponent(defaultProps.url)
|
|
@@ -68,7 +68,7 @@ describe('ArticleSocialShare', () => {
|
|
|
68
68
|
)
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
it('clicking Twitter calls
|
|
71
|
+
it('clicking Twitter calls globalThis.open with correct URL and encoded title', () => {
|
|
72
72
|
render(<ArticleSocialShare {...defaultProps} />)
|
|
73
73
|
fireEvent.click(screen.getByRole('button', { name: /twitter/i }))
|
|
74
74
|
const encodedTitle = encodeURIComponent(defaultProps.title)
|
|
@@ -80,7 +80,7 @@ describe('ArticleSocialShare', () => {
|
|
|
80
80
|
)
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
it('clicking Reddit calls
|
|
83
|
+
it('clicking Reddit calls globalThis.open with correct URL and encoded title', () => {
|
|
84
84
|
render(<ArticleSocialShare {...defaultProps} />)
|
|
85
85
|
fireEvent.click(screen.getByRole('button', { name: /reddit/i }))
|
|
86
86
|
const encodedTitle = encodeURIComponent(defaultProps.title)
|
|
@@ -92,7 +92,7 @@ describe('ArticleSocialShare', () => {
|
|
|
92
92
|
)
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
it('clicking WhatsApp calls
|
|
95
|
+
it('clicking WhatsApp calls globalThis.open with the correct WhatsApp URL', () => {
|
|
96
96
|
render(<ArticleSocialShare {...defaultProps} />)
|
|
97
97
|
fireEvent.click(screen.getByRole('button', { name: /whatsapp/i }))
|
|
98
98
|
const encodedTitle = encodeURIComponent(defaultProps.title)
|
|
@@ -104,7 +104,7 @@ describe('ArticleSocialShare', () => {
|
|
|
104
104
|
)
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
it('clicking Telegram calls
|
|
107
|
+
it('clicking Telegram calls globalThis.open with the correct Telegram URL', () => {
|
|
108
108
|
render(<ArticleSocialShare {...defaultProps} />)
|
|
109
109
|
fireEvent.click(screen.getByRole('button', { name: /telegram/i }))
|
|
110
110
|
const encodedTitle = encodeURIComponent(defaultProps.title)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import { ArticleTOC } from '../ArticleTOC'
|
|
7
|
+
import type { TocItem } from '../articleTypes'
|
|
8
|
+
|
|
9
|
+
const twoItems: TocItem[] = [
|
|
10
|
+
{ id: 'intro', depth: 2, text: 'Introduction' },
|
|
11
|
+
{ id: 'details', depth: 3, text: 'Details' },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
describe('ArticleTOC', () => {
|
|
15
|
+
describe('empty toc', () => {
|
|
16
|
+
it('returns null when toc is an empty array', () => {
|
|
17
|
+
const { container } = render(<ArticleTOC toc={[]} />)
|
|
18
|
+
expect(container.firstChild).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('nav element', () => {
|
|
23
|
+
it('renders nav with aria-label "Table of contents" when toc has items', () => {
|
|
24
|
+
render(<ArticleTOC toc={twoItems} />)
|
|
25
|
+
expect(screen.getByRole('navigation', { name: 'Table of contents' })).toBeInTheDocument()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders "On this page" header', () => {
|
|
29
|
+
render(<ArticleTOC toc={twoItems} />)
|
|
30
|
+
expect(screen.getByText('On this page')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('applies custom className to the nav element', () => {
|
|
34
|
+
render(<ArticleTOC toc={twoItems} className="custom-class" />)
|
|
35
|
+
const nav = screen.getByRole('navigation', { name: 'Table of contents' })
|
|
36
|
+
expect(nav.className).toContain('custom-class')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('list items', () => {
|
|
41
|
+
it('renders the correct number of list items', () => {
|
|
42
|
+
render(<ArticleTOC toc={twoItems} />)
|
|
43
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('each anchor href is #item.id', () => {
|
|
47
|
+
render(<ArticleTOC toc={twoItems} />)
|
|
48
|
+
const links = screen.getAllByRole('link')
|
|
49
|
+
expect(links[0]).toHaveAttribute('href', '#intro')
|
|
50
|
+
expect(links[1]).toHaveAttribute('href', '#details')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('each anchor text matches item.text', () => {
|
|
54
|
+
render(<ArticleTOC toc={twoItems} />)
|
|
55
|
+
expect(screen.getByText('Introduction')).toBeInTheDocument()
|
|
56
|
+
expect(screen.getByText('Details')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('indentation', () => {
|
|
61
|
+
it('h2 items (depth 2) have 0rem padding', () => {
|
|
62
|
+
const items: TocItem[] = [{ id: 'h2-item', depth: 2, text: 'H2 Heading' }]
|
|
63
|
+
render(<ArticleTOC toc={items} />)
|
|
64
|
+
const li = screen.getByRole('listitem')
|
|
65
|
+
expect(li).toHaveStyle({ paddingLeft: '0rem' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('h3 items (depth 3) have 1rem padding', () => {
|
|
69
|
+
const items: TocItem[] = [{ id: 'h3-item', depth: 3, text: 'H3 Heading' }]
|
|
70
|
+
render(<ArticleTOC toc={items} />)
|
|
71
|
+
const li = screen.getByRole('listitem')
|
|
72
|
+
expect(li).toHaveStyle({ paddingLeft: '1rem' })
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
6
|
+
import { ScrollToTop } from '../ScrollToTop'
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
Object.defineProperty(globalThis, 'scrollY', { value: 0, writable: true, configurable: true })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('ScrollToTop', () => {
|
|
13
|
+
it('does not render the button initially when scrollY is 0', () => {
|
|
14
|
+
render(<ScrollToTop />)
|
|
15
|
+
expect(screen.queryByRole('button', { name: 'Scroll to top' })).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('renders the button with aria-label "Scroll to top" after scroll event with scrollY > 300', () => {
|
|
19
|
+
render(<ScrollToTop />)
|
|
20
|
+
act(() => {
|
|
21
|
+
Object.defineProperty(globalThis, 'scrollY', {
|
|
22
|
+
value: 350,
|
|
23
|
+
writable: true,
|
|
24
|
+
configurable: true,
|
|
25
|
+
})
|
|
26
|
+
globalThis.dispatchEvent(new Event('scroll'))
|
|
27
|
+
})
|
|
28
|
+
expect(screen.getByRole('button', { name: 'Scroll to top' })).toBeInTheDocument()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('hides the button after scroll event brings scrollY back to <= 300', () => {
|
|
32
|
+
render(<ScrollToTop />)
|
|
33
|
+
act(() => {
|
|
34
|
+
Object.defineProperty(globalThis, 'scrollY', {
|
|
35
|
+
value: 350,
|
|
36
|
+
writable: true,
|
|
37
|
+
configurable: true,
|
|
38
|
+
})
|
|
39
|
+
globalThis.dispatchEvent(new Event('scroll'))
|
|
40
|
+
})
|
|
41
|
+
expect(screen.getByRole('button', { name: 'Scroll to top' })).toBeInTheDocument()
|
|
42
|
+
act(() => {
|
|
43
|
+
Object.defineProperty(globalThis, 'scrollY', {
|
|
44
|
+
value: 100,
|
|
45
|
+
writable: true,
|
|
46
|
+
configurable: true,
|
|
47
|
+
})
|
|
48
|
+
globalThis.dispatchEvent(new Event('scroll'))
|
|
49
|
+
})
|
|
50
|
+
expect(screen.queryByRole('button', { name: 'Scroll to top' })).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('clicking the button calls globalThis.scrollTo with { top: 0, behavior: "smooth" }', () => {
|
|
54
|
+
const mockScrollTo = jest.fn()
|
|
55
|
+
globalThis.scrollTo = mockScrollTo
|
|
56
|
+
|
|
57
|
+
render(<ScrollToTop />)
|
|
58
|
+
act(() => {
|
|
59
|
+
Object.defineProperty(globalThis, 'scrollY', {
|
|
60
|
+
value: 350,
|
|
61
|
+
writable: true,
|
|
62
|
+
configurable: true,
|
|
63
|
+
})
|
|
64
|
+
globalThis.dispatchEvent(new Event('scroll'))
|
|
65
|
+
})
|
|
66
|
+
fireEvent.click(screen.getByRole('button', { name: 'Scroll to top' }))
|
|
67
|
+
expect(mockScrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('removes the scroll event listener on unmount', () => {
|
|
71
|
+
const addSpy = jest.spyOn(globalThis, 'addEventListener')
|
|
72
|
+
const removeSpy = jest.spyOn(globalThis, 'removeEventListener')
|
|
73
|
+
|
|
74
|
+
const { unmount } = render(<ScrollToTop />)
|
|
75
|
+
unmount()
|
|
76
|
+
|
|
77
|
+
const addCall = addSpy.mock.calls.find((c) => c[0] === 'scroll')
|
|
78
|
+
const removeCall = removeSpy.mock.calls.find((c) => c[0] === 'scroll')
|
|
79
|
+
|
|
80
|
+
expect(addCall).toBeDefined()
|
|
81
|
+
expect(removeCall).toBeDefined()
|
|
82
|
+
expect(addCall![1]).toBe(removeCall![1])
|
|
83
|
+
|
|
84
|
+
addSpy.mockRestore()
|
|
85
|
+
removeSpy.mockRestore()
|
|
86
|
+
})
|
|
87
|
+
})
|