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
|
- /versioning
|
||||||
- /learning-about-github
|
- /learning-about-github
|
||||||
- /empty-categories
|
- /empty-categories
|
||||||
|
- /carousel
|
||||||
communityRedirect:
|
communityRedirect:
|
||||||
name: Provide HubGit Feedback
|
name: Provide HubGit Feedback
|
||||||
href: 'https://hubgit.com/orgs/community/discussions/categories/get-started'
|
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(searchResults).toBeVisible()
|
||||||
await expect(aiSection).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 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 = {
|
type LandingCarouselProps = {
|
||||||
|
heading?: string
|
||||||
recommended?: string[] // Array of article paths
|
recommended?: string[] // Array of article paths
|
||||||
flatArticles: TocItem[]
|
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
|
// Helper function to find article data from tocItems
|
||||||
const findArticleData = (articlePath: string) => {
|
const findArticleData = (articlePath: string) => {
|
||||||
|
if (typeof articlePath !== 'string') {
|
||||||
|
console.warn('Invalid articlePath:', articlePath)
|
||||||
|
return null
|
||||||
|
}
|
||||||
const cleanPath = articlePath.startsWith('/') ? articlePath.slice(1) : articlePath
|
const cleanPath = articlePath.startsWith('/') ? articlePath.slice(1) : articlePath
|
||||||
return flatArticles.find(
|
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
|
// Process recommended articles to get article data
|
||||||
const processedRecommendedItems =
|
const processedItems = (recommended || [])
|
||||||
recommended?.map((recommendedArticlePath) => {
|
.filter((item) => typeof item === 'string' && item.length > 0)
|
||||||
|
.map((recommendedArticlePath) => {
|
||||||
const articleData = findArticleData(recommendedArticlePath)
|
const articleData = findArticleData(recommendedArticlePath)
|
||||||
return {
|
return {
|
||||||
article: recommendedArticlePath,
|
article: recommendedArticlePath,
|
||||||
title: articleData?.title || 'Unknown Article',
|
title: articleData?.title || 'Unknown Article',
|
||||||
description: articleData?.intro || '',
|
description: articleData?.intro || '',
|
||||||
url: articleData?.fullPath || recommendedArticlePath,
|
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 (
|
return (
|
||||||
<div>
|
<div className={styles.carousel} data-testid="landing-carousel">
|
||||||
<h2
|
<div className={styles.header}>
|
||||||
style={{
|
<h2 className={styles.heading}>{headingText}</h2>
|
||||||
marginTop: '3rem',
|
{totalItems > itemsPerView && (
|
||||||
}}
|
<div className={styles.navigation}>
|
||||||
>
|
<button
|
||||||
TODO: Carousel placeholder
|
onClick={goToPrevious}
|
||||||
</h2>
|
disabled={currentPage === 0}
|
||||||
<ul>
|
className={cx('btn btn-sm', styles.navButton)}
|
||||||
{processedRecommendedItems.map((article) => (
|
aria-label="Previous articles"
|
||||||
<li key={article.article || article.title}>
|
>
|
||||||
<a href={article.url}>
|
<ChevronLeftIcon size={16} />
|
||||||
<h2>{article.title}</h2>
|
</button>
|
||||||
</a>
|
|
||||||
<p>{article.description}</p>
|
<button
|
||||||
</li>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user