@@ -7,13 +7,16 @@ import { Link } from '@/frame/components/Link'
77import { useTranslation } from '@/languages/components/useTranslation'
88import { ArticleCardItems , ChildTocItem , TocItem } from '@/landings/types'
99import { LandingType } from '@/landings/context/LandingContext'
10+ import type { QueryParams } from '@/search/components/hooks/useMultiQueryParams'
1011
1112import styles from './LandingArticleGridWithFilter.module.scss'
1213
1314type ArticleGridProps = {
1415 tocItems : TocItem [ ]
1516 includedCategories ?: string [ ]
1617 landingType : LandingType
18+ params : QueryParams
19+ updateParams : ( updates : Partial < QueryParams > , shouldPushHistory ?: boolean ) => void
1720}
1821
1922const ALL_CATEGORIES = 'all_categories'
@@ -69,17 +72,24 @@ const useResponsiveArticlesPerPage = () => {
6972 return articlesPerPage
7073}
7174
72- export const ArticleGrid = ( { tocItems, includedCategories, landingType } : ArticleGridProps ) => {
75+ export const ArticleGrid = ( {
76+ tocItems,
77+ includedCategories,
78+ landingType,
79+ params,
80+ updateParams,
81+ } : ArticleGridProps ) => {
7382 const { t } = useTranslation ( 'product_landing' )
74- const [ searchQuery , setSearchQuery ] = useState ( '' )
75- const [ selectedCategory , setSelectedCategory ] = useState ( ALL_CATEGORIES )
76- const [ selectedCategoryIndex , setSelectedCategoryIndex ] = useState ( 0 )
77- const [ currentPage , setCurrentPage ] = useState ( 1 )
7883 const articlesPerPage = useResponsiveArticlesPerPage ( )
7984
8085 const inputRef = useRef < HTMLInputElement > ( null )
8186 const headingRef = useRef < HTMLHeadingElement > ( null )
8287
88+ // Read filter state directly from query params
89+ const searchQuery = params [ 'articles-filter' ] || ''
90+ const selectedCategory = params [ 'articles-category' ] || ALL_CATEGORIES
91+ const currentPage = parseInt ( params [ 'articles-page' ] || '1' , 10 )
92+
8393 // Recursively flatten all articles from tocItems, including both direct children and nested articles
8494 const allArticles = useMemo ( ( ) => flattenArticles ( tocItems ) , [ tocItems ] )
8595
@@ -99,25 +109,43 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
99109 return allArticles
100110 } , [ allArticles , includedCategories , landingType ] )
101111
102- // Reset to first page when articlesPerPage changes (screen size changes)
112+ // Extract unique categories for dropdown from filtered articles (so all dropdown options have matching articles)
113+ const categories : string [ ] = useMemo (
114+ ( ) => [
115+ ALL_CATEGORIES ,
116+ ...Array . from (
117+ new Set ( filteredArticlesByLandingType . flatMap ( ( item ) => ( item . category || [ ] ) as string [ ] ) ) ,
118+ )
119+ . filter ( ( category : string ) => {
120+ if ( ! includedCategories || includedCategories . length === 0 ) return true
121+ // Case-insensitive comparison for dropdown filtering
122+ const lowerCategory = category . toLowerCase ( )
123+ return includedCategories . some ( ( included ) => included . toLowerCase ( ) === lowerCategory )
124+ } )
125+ . sort ( ( a , b ) => a . localeCompare ( b ) ) ,
126+ ] ,
127+ [ filteredArticlesByLandingType , includedCategories ] ,
128+ )
129+
130+ // Calculate the selected category index based on the current query param
131+ const selectedCategoryIndex = useMemo ( ( ) => {
132+ const index = categories . indexOf ( selectedCategory )
133+ return index !== - 1 ? index : 0
134+ } , [ categories , selectedCategory ] )
135+
136+ // Clear invalid category from query params if it doesn't exist in available categories
103137 useEffect ( ( ) => {
104- setCurrentPage ( 1 )
105- } , [ articlesPerPage ] )
138+ if ( selectedCategory !== ALL_CATEGORIES && selectedCategoryIndex === 0 ) {
139+ updateParams ( { 'articles-category' : '' } )
140+ }
141+ } , [ selectedCategory , selectedCategoryIndex , updateParams ] )
106142
107- // Extract unique categories for dropdown from filtered articles (so all dropdown options have matching articles)
108- const categories : string [ ] = [
109- ALL_CATEGORIES ,
110- ...Array . from (
111- new Set ( filteredArticlesByLandingType . flatMap ( ( item ) => ( item . category || [ ] ) as string [ ] ) ) ,
112- )
113- . filter ( ( category : string ) => {
114- if ( ! includedCategories || includedCategories . length === 0 ) return true
115- // Case-insensitive comparison for dropdown filtering
116- const lowerCategory = category . toLowerCase ( )
117- return includedCategories . some ( ( included ) => included . toLowerCase ( ) === lowerCategory )
118- } )
119- . sort ( ( a , b ) => a . localeCompare ( b ) ) ,
120- ]
143+ // Sync the input field value with query params
144+ useEffect ( ( ) => {
145+ if ( inputRef . current ) {
146+ inputRef . current . value = searchQuery
147+ }
148+ } , [ searchQuery ] )
121149
122150 const applyFilters = ( ) => {
123151 let results = filteredArticlesByLandingType
@@ -154,31 +182,86 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
154182 const paginatedResults = filteredResults . slice ( startIndex , startIndex + articlesPerPage )
155183
156184 const handleSearch = ( query : string ) => {
157- setSearchQuery ( query )
158- setCurrentPage ( 1 ) // Reset to first page when searching
185+ // Update query params, clear if empty, and reset to first page
186+ // Don't add to history for search filtering
187+ updateParams ( { 'articles-filter' : query || '' , 'articles-page' : '' } , false )
159188 }
160189
161- const handleFilter = ( option : string , index : number ) => {
162- setSelectedCategory ( option )
163- setSelectedCategoryIndex ( index )
164- setCurrentPage ( 1 ) // Reset to first page when filtering
190+ const handleFilter = ( option : string ) => {
191+ // Update query params, clear if "all categories", and reset to first page
192+ updateParams (
193+ {
194+ 'articles-category' : option === ALL_CATEGORIES ? '' : option ,
195+ 'articles-page' : '' ,
196+ } ,
197+ true ,
198+ )
165199 }
166200
201+ // Track previous page to determine if we should scroll
202+ const prevPageRef = useRef ( currentPage )
203+ const hasMountedRef = useRef ( false )
204+
167205 const handlePageChange = ( e : React . MouseEvent , pageNumber : number ) => {
168206 e . preventDefault ( )
169207 if ( pageNumber >= 1 && pageNumber <= totalPages ) {
170- setCurrentPage ( pageNumber )
171- if ( headingRef . current ) {
172- const elementPosition = headingRef . current . getBoundingClientRect ( ) . top + window . scrollY
173- const offsetPosition = elementPosition - 140 // 140px offset from top
174- window . scrollTo ( {
175- top : offsetPosition ,
176- behavior : 'smooth' ,
177- } )
178- }
208+ // Update page in query params, clear if page 1
209+ updateParams ( { 'articles-page' : pageNumber === 1 ? '' : String ( pageNumber ) } , true )
179210 }
180211 }
181212
213+ // Scroll to heading on initial mount if query params are present
214+ useEffect ( ( ) => {
215+ if ( ! hasMountedRef . current ) {
216+ hasMountedRef . current = true
217+
218+ // Check if any VALID article grid query params are present on initial load
219+ // Don't scroll if category is invalid (selectedCategoryIndex === 0 means invalid or "all")
220+ const hasValidCategory = selectedCategory !== ALL_CATEGORIES && selectedCategoryIndex !== 0
221+ const hasQueryParams = searchQuery || hasValidCategory || currentPage > 1
222+
223+ if ( hasQueryParams && headingRef . current ) {
224+ // Use setTimeout to ensure the component is fully rendered
225+ setTimeout ( ( ) => {
226+ if ( headingRef . current ) {
227+ const elementPosition = headingRef . current . getBoundingClientRect ( ) . top + window . scrollY
228+ const offsetPosition = elementPosition - 140 // 140px offset from top
229+ window . scrollTo ( {
230+ top : offsetPosition ,
231+ behavior : 'smooth' ,
232+ } )
233+ }
234+ } , 100 )
235+ }
236+ }
237+ } , [ ] ) // Only run on mount
238+
239+ // Scroll to heading when page changes via pagination
240+ useEffect ( ( ) => {
241+ const pageChanged = currentPage !== prevPageRef . current
242+ const isPaginationClick = pageChanged && prevPageRef . current !== 1
243+
244+ // Scroll if page changed via pagination (not from filter/category reset to page 1)
245+ // This includes: going to page 2+, or going back to page 1 from a higher page
246+ const shouldScroll = pageChanged && ( currentPage > 1 || isPaginationClick )
247+
248+ if ( shouldScroll && headingRef . current ) {
249+ // Delay scroll slightly to let router finish and restore scroll position first
250+ setTimeout ( ( ) => {
251+ if ( headingRef . current ) {
252+ const elementPosition = headingRef . current . getBoundingClientRect ( ) . top + window . scrollY
253+ const offsetPosition = elementPosition - 140 // 140px offset from top
254+ window . scrollTo ( {
255+ top : offsetPosition ,
256+ behavior : 'smooth' ,
257+ } )
258+ }
259+ } , 150 ) // Slightly longer than router debounce (100ms) + execution time
260+ }
261+
262+ prevPageRef . current = currentPage
263+ } , [ currentPage ] )
264+
182265 return (
183266 < div data-testid = "article-grid-container" >
184267 { /* Filter and Search Controls */ }
@@ -204,7 +287,7 @@ export const ArticleGrid = ({ tocItems, includedCategories, landingType }: Artic
204287 < ActionList . Item
205288 key = { index }
206289 selected = { index === selectedCategoryIndex }
207- onSelect = { ( ) => handleFilter ( category , index ) }
290+ onSelect = { ( ) => handleFilter ( category ) }
208291 >
209292 { category === ALL_CATEGORIES ? t ( 'article_grid.all_categories' ) : category }
210293 </ ActionList . Item >
0 commit comments