landing page carousel component (#57177)
Co-authored-by: GitHub Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Carousel Article One
|
||||
intro: 'This is the first test article for the carousel component.'
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
layout: inline
|
||||
---
|
||||
|
||||
This is the content of the first test article for the LandingCarousel component. It demonstrates how the carousel displays multiple articles in a responsive layout.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Carousel Article Two
|
||||
intro: 'This is the second test article for the carousel component.'
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
layout: inline
|
||||
---
|
||||
|
||||
This is the content of the second test article for the LandingCarousel component. It helps test the navigation and pagination features.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Category One
|
||||
intro: 'First category of test articles.'
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
children:
|
||||
- /article-one
|
||||
- /article-two
|
||||
---
|
||||
|
||||
This is the first category of test articles.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Carousel Article Three
|
||||
intro: 'This is the third test article for the carousel component.'
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
layout: inline
|
||||
---
|
||||
|
||||
This is the content of the third test article for the LandingCarousel component. It allows testing of responsive behavior across different screen sizes.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Category Two
|
||||
intro: 'Second category of test articles.'
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
children:
|
||||
- /article-three
|
||||
---
|
||||
|
||||
This is the second category of test articles.
|
||||
25
src/fixtures/fixtures/content/get-started/carousel/index.md
Normal file
25
src/fixtures/fixtures/content/get-started/carousel/index.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Carousel Test Category
|
||||
intro: 'A test category page for testing the LandingCarousel component.'
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
layout: category-landing
|
||||
recommended:
|
||||
- /category-one/article-one
|
||||
- /category-one/article-two
|
||||
- /category-two/article-three
|
||||
spotlight:
|
||||
- article: /category-one/article-one
|
||||
image: /assets/images/placeholder.png
|
||||
- article: /category-one/article-two
|
||||
image: /assets/images/placeholder.png
|
||||
- article: /category-two/article-three
|
||||
image: /assets/images/placeholder.png
|
||||
children:
|
||||
- /category-one
|
||||
- /category-two
|
||||
---
|
||||
|
||||
This is a test category page for the LandingCarousel component.
|
||||
@@ -28,6 +28,7 @@ children:
|
||||
- /versioning
|
||||
- /learning-about-github
|
||||
- /empty-categories
|
||||
- /carousel
|
||||
communityRedirect:
|
||||
name: Provide HubGit Feedback
|
||||
href: 'https://hubgit.com/orgs/community/discussions/categories/get-started'
|
||||
|
||||
@@ -997,3 +997,54 @@ test('open search, Ask AI returns 400 error and shows general search results', a
|
||||
await expect(searchResults).toBeVisible()
|
||||
await expect(aiSection).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('LandingCarousel component', () => {
|
||||
test('displays carousel on test page', async ({ page }) => {
|
||||
await page.goto('/get-started/carousel?feature=discovery-landing')
|
||||
|
||||
const carousel = page.locator('[data-testid="landing-carousel"]')
|
||||
await expect(carousel).toBeVisible()
|
||||
|
||||
// Check that article cards are present
|
||||
const items = page.locator('[data-testid="carousel-items"]')
|
||||
const cards = items.locator('div')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
|
||||
// Verify cards have real titles (not "Unknown Article" when article not found)
|
||||
const firstCardTitle = cards.first().locator('h3')
|
||||
await expect(firstCardTitle).toBeVisible()
|
||||
await expect(firstCardTitle).not.toHaveText('Unknown Article')
|
||||
})
|
||||
|
||||
test('navigation works on desktop', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 800 })
|
||||
await page.goto('/get-started/carousel?feature=discovery-landing')
|
||||
|
||||
const carousel = page.locator('[data-testid="landing-carousel"]')
|
||||
await expect(carousel).toBeVisible()
|
||||
|
||||
// Should show 3 cards on desktop
|
||||
const cards = carousel.locator('a')
|
||||
await expect(cards).toHaveCount(3)
|
||||
|
||||
// Check for navigation buttons if there are more than 3 articles
|
||||
const nextButton = carousel.getByRole('button', { name: 'Next articles' })
|
||||
if (await nextButton.isVisible()) {
|
||||
const prevButton = carousel.getByRole('button', { name: 'Previous articles' })
|
||||
await expect(prevButton).toBeDisabled() // Should be disabled on first page
|
||||
await expect(nextButton).toBeEnabled()
|
||||
}
|
||||
})
|
||||
|
||||
test('responsive behavior on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 })
|
||||
await page.goto('/get-started/carousel?feature=discovery-landing')
|
||||
|
||||
const carousel = page.locator('[data-testid="landing-carousel"]')
|
||||
await expect(carousel).toBeVisible()
|
||||
|
||||
// Should show 1 card on mobile
|
||||
const cards = carousel.locator('a')
|
||||
await expect(cards).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
97
src/landings/components/shared/LandingCarousel.module.scss
Normal file
97
src/landings/components/shared/LandingCarousel.module.scss
Normal file
@@ -0,0 +1,97 @@
|
||||
.carousel {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--fgColor-default);
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.navButton {
|
||||
min-width: 32px !important;
|
||||
padding: 6px !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.itemsGrid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1012px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.articleCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
min-height: 120px; /* Ensures consistent card heights */
|
||||
box-shadow:
|
||||
0px 1px 3px 0px rgba(31, 35, 40, 0.08),
|
||||
0px 1px 0px 0px rgba(31, 35, 40, 0.06);
|
||||
}
|
||||
|
||||
.articleTitle {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.articleLink {
|
||||
color: var(--fgColor-accent);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.articleDescription {
|
||||
margin: 0;
|
||||
color: var(--fgColor-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fgColor-muted);
|
||||
}
|
||||
@@ -1,50 +1,161 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@primer/octicons-react'
|
||||
import { Token } from '@primer/react'
|
||||
import cx from 'classnames'
|
||||
import type { TocItem } from '@/landings/types'
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import styles from './LandingCarousel.module.scss'
|
||||
|
||||
type ProcessedArticleItem = {
|
||||
article: string
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
category: string[]
|
||||
}
|
||||
|
||||
type LandingCarouselProps = {
|
||||
heading?: string
|
||||
recommended?: string[] // Array of article paths
|
||||
flatArticles: TocItem[]
|
||||
}
|
||||
|
||||
export const LandingCarousel = ({ flatArticles, recommended }: LandingCarouselProps) => {
|
||||
// Hook to get current items per view based on screen size
|
||||
const useResponsiveItemsPerView = () => {
|
||||
const [itemsPerView, setItemsPerView] = useState(3) // Default to desktop
|
||||
|
||||
useEffect(() => {
|
||||
const updateItemsPerView = () => {
|
||||
const width = window.innerWidth
|
||||
if (width < 768) {
|
||||
// Mobile: 1 column
|
||||
setItemsPerView(1)
|
||||
} else if (width < 1012) {
|
||||
// Tablet: 2 columns
|
||||
setItemsPerView(2)
|
||||
} else {
|
||||
// Desktop: 3 columns
|
||||
setItemsPerView(3)
|
||||
}
|
||||
}
|
||||
|
||||
updateItemsPerView()
|
||||
window.addEventListener('resize', updateItemsPerView)
|
||||
return () => window.removeEventListener('resize', updateItemsPerView)
|
||||
}, [])
|
||||
|
||||
return itemsPerView
|
||||
}
|
||||
|
||||
export const LandingCarousel = ({
|
||||
heading = '',
|
||||
flatArticles,
|
||||
recommended,
|
||||
}: LandingCarouselProps) => {
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const itemsPerView = useResponsiveItemsPerView()
|
||||
const { t } = useTranslation('discovery_landing')
|
||||
const headingText = heading || t('recommended')
|
||||
|
||||
// Reset to first page when itemsPerView changes (screen size changes)
|
||||
useEffect(() => {
|
||||
setCurrentPage(0)
|
||||
}, [itemsPerView])
|
||||
|
||||
// Helper function to find article data from tocItems
|
||||
const findArticleData = (articlePath: string) => {
|
||||
if (typeof articlePath !== 'string') {
|
||||
console.warn('Invalid articlePath:', articlePath)
|
||||
return null
|
||||
}
|
||||
const cleanPath = articlePath.startsWith('/') ? articlePath.slice(1) : articlePath
|
||||
return flatArticles.find(
|
||||
(item) => item.fullPath && cleanPath.split('/').pop() === item.fullPath.split('/').pop(),
|
||||
(item) =>
|
||||
item.fullPath?.endsWith(cleanPath) ||
|
||||
item.fullPath?.includes(cleanPath.split('/').pop() || ''),
|
||||
)
|
||||
}
|
||||
|
||||
// Process recommended items to get article data
|
||||
const processedRecommendedItems =
|
||||
recommended?.map((recommendedArticlePath) => {
|
||||
// Process recommended articles to get article data
|
||||
const processedItems = (recommended || [])
|
||||
.filter((item) => typeof item === 'string' && item.length > 0)
|
||||
.map((recommendedArticlePath) => {
|
||||
const articleData = findArticleData(recommendedArticlePath)
|
||||
return {
|
||||
article: recommendedArticlePath,
|
||||
title: articleData?.title || 'Unknown Article',
|
||||
description: articleData?.intro || '',
|
||||
url: articleData?.fullPath || recommendedArticlePath,
|
||||
category: articleData?.category || [],
|
||||
}
|
||||
}) || []
|
||||
})
|
||||
|
||||
const totalItems = processedItems.length
|
||||
const totalPages = Math.ceil(totalItems / itemsPerView)
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentPage((prev) => Math.max(0, prev - 1))
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1))
|
||||
}
|
||||
|
||||
// Calculate the start index based on current page
|
||||
const startIndex = currentPage * itemsPerView
|
||||
const visibleItems = processedItems.slice(startIndex, startIndex + itemsPerView)
|
||||
|
||||
if (totalItems === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
marginTop: '3rem',
|
||||
}}
|
||||
>
|
||||
TODO: Carousel placeholder
|
||||
</h2>
|
||||
<ul>
|
||||
{processedRecommendedItems.map((article) => (
|
||||
<li key={article.article || article.title}>
|
||||
<a href={article.url}>
|
||||
<h2>{article.title}</h2>
|
||||
</a>
|
||||
<p>{article.description}</p>
|
||||
</li>
|
||||
<div className={styles.carousel} data-testid="landing-carousel">
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.heading}>{headingText}</h2>
|
||||
{totalItems > itemsPerView && (
|
||||
<div className={styles.navigation}>
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
disabled={currentPage === 0}
|
||||
className={cx('btn btn-sm', styles.navButton)}
|
||||
aria-label="Previous articles"
|
||||
>
|
||||
<ChevronLeftIcon size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
className={cx('btn btn-sm', styles.navButton)}
|
||||
aria-label="Next articles"
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.itemsGrid} data-testid="carousel-items">
|
||||
{visibleItems.map((article: ProcessedArticleItem, index) => (
|
||||
<div
|
||||
key={startIndex + index}
|
||||
className={cx(styles.articleCard, 'border', 'border-default', 'rounded-2')}
|
||||
>
|
||||
<div className="mb-2">
|
||||
{article.category.map((cat) => (
|
||||
<Token key={cat} text={cat} className="mr-1 mb-2" />
|
||||
))}
|
||||
</div>
|
||||
<h3 className={styles.articleTitle}>
|
||||
<a href={article.url} className={styles.articleLink}>
|
||||
{article.title}
|
||||
</a>
|
||||
</h3>
|
||||
<p className={styles.articleDescription}>{article.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user