1
0
mirror of synced 2025-12-19 18:10:59 -05:00

landing page carousel component (#57177)

Co-authored-by: GitHub Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Robert Sese
2025-08-28 15:45:58 -05:00
committed by GitHub
parent 9b0d2beb9a
commit 03da100a30
10 changed files with 366 additions and 23 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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'

View File

@@ -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)
})
})

View 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);
}

View File

@@ -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>
)
}