Skip to content

Commit 7096260

Browse files
authored
Render an empty div for game cards until they inter the viewport (#2460)
* Render an empty div for game cards until they inter the viewport * Fix ratio for gamepad layout * Re-render list when toggling non-available filter * Remove unused code
1 parent ac6b135 commit 7096260

File tree

4 files changed

+106
-57
lines changed

4 files changed

+106
-57
lines changed

src/frontend/screens/Library/components/GameCard/index.css

+6-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
flex-direction: column;
1111
border-radius: var(--space-3xs);
1212
animation: fade-in 0.2s ease-in;
13+
aspect-ratio: 173/275;
14+
}
15+
16+
.gameCard.gamepad {
17+
aspect-ratio: 3/4;
1318
}
1419

1520
.gameCard:focus-within {
@@ -101,7 +106,7 @@
101106
padding: var(--space-xs-fixed);
102107
}
103108

104-
.gameCard > .icons.gamepad {
109+
.gameCard.gamepad > .icons {
105110
display: none;
106111
}
107112

src/frontend/screens/Library/components/GameCard/index.tsx

+34-10
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ const GameCard = ({
6363
isRecent = false,
6464
gameInfo: gameInfoFromProps
6565
}: Card) => {
66+
const [visible, setVisible] = useState(false)
67+
68+
useEffect(() => {
69+
// render an empty div until the card enters the viewport
70+
// check GameList for the other side of this detection
71+
const callback = (e: CustomEvent<{ appNames: string[] }>) => {
72+
if (e.detail.appNames.includes(gameInfoFromProps.app_name)) {
73+
setVisible(true)
74+
}
75+
}
76+
77+
window.addEventListener('visible-cards', callback)
78+
79+
return () => {
80+
window.removeEventListener('visible-cards', callback)
81+
}
82+
}, [])
83+
6684
const [gameInfo, setGameInfo] = useState<GameInfo | SideloadGame>(
6785
gameInfoFromProps
6886
)
@@ -80,7 +98,8 @@ const GameCard = ({
8098
favouriteGames,
8199
allTilesInColor,
82100
showDialogModal,
83-
setIsSettingsModalOpen
101+
setIsSettingsModalOpen,
102+
activeController
84103
} = useContext(ContextProvider)
85104

86105
const {
@@ -323,6 +342,7 @@ const GameCard = ({
323342
const instClass = isInstalled ? 'installed' : ''
324343
const hiddenClass = isHiddenGame ? 'hidden' : ''
325344
const notAvailableClass = notAvailable ? 'notAvailable' : ''
345+
const gamepadClass = activeController ? 'gamepad' : ''
326346
const imgClasses = `gameImg ${isInstalled ? 'installed' : ''} ${
327347
allTilesInColor ? 'allTilesInColor' : ''
328348
}`
@@ -332,13 +352,21 @@ const GameCard = ({
332352

333353
const wrapperClasses = `${
334354
grid ? 'gameCard' : 'gameListItem'
335-
} ${instClass} ${hiddenClass} ${notAvailableClass}`
336-
337-
const { activeController } = useContext(ContextProvider)
355+
} ${instClass} ${hiddenClass} ${notAvailableClass} ${gamepadClass}`
338356

339357
const showUpdateButton =
340358
hasUpdate && !isUpdating && !isQueued && !notAvailable
341359

360+
if (!visible) {
361+
return (
362+
<div
363+
className={wrapperClasses}
364+
data-app-name={appName}
365+
data-invisible={true}
366+
></div>
367+
)
368+
}
369+
342370
return (
343371
<div>
344372
{showUninstallModal && (
@@ -349,7 +377,7 @@ const GameCard = ({
349377
/>
350378
)}
351379
<ContextMenu items={items}>
352-
<div className={wrapperClasses}>
380+
<div className={wrapperClasses} data-app-name={appName}>
353381
{haveStatus && <span className="gameCardStatus">{label}</span>}
354382
<Link
355383
to={`/gamepage/${runner}/${appName}`}
@@ -399,11 +427,7 @@ const GameCard = ({
399427
</span>
400428
</Link>
401429
<>
402-
<span
403-
className={classNames('icons', {
404-
gamepad: activeController
405-
})}
406-
>
430+
<span className="icons">
407431
{showUpdateButton && (
408432
<SvgButton
409433
className="updateIcon"

src/frontend/screens/Library/components/GamesList/index.tsx

+65-46
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useContext, useEffect, useState } from 'react'
1+
import React, { useContext, useEffect } from 'react'
22
import { GameInfo, Runner, SideloadGame } from 'common/types'
33
import cx from 'classnames'
44
import GameCard from '../GameCard'
@@ -26,62 +26,51 @@ const GamesList = ({
2626
onlyInstalled = false,
2727
isRecent = false
2828
}: Props): JSX.Element => {
29-
const { gameUpdates, showNonAvailable } = useContext(ContextProvider)
29+
const { gameUpdates } = useContext(ContextProvider)
3030
const { t } = useTranslation()
31-
const [gameCards, setGameCards] = useState<JSX.Element[]>([])
3231

3332
useEffect(() => {
34-
let mounted = true
35-
36-
const createGameCards = async () => {
37-
if (!library.length) {
38-
return
33+
if (library.length) {
34+
const options = {
35+
root: document.querySelector('.listing'),
36+
rootMargin: '500px',
37+
threshold: 0
3938
}
40-
const resolvedLibrary = library.map(async (gameInfo) => {
41-
const { app_name, is_installed, runner } = gameInfo
4239

43-
let is_dlc = false
44-
if (gameInfo.runner !== 'sideload') {
45-
is_dlc = gameInfo.install.is_dlc ?? false
46-
}
40+
const callback: IntersectionObserverCallback = (entries, observer) => {
41+
const entered: string[] = []
42+
entries.forEach((entry) => {
43+
if (entry.intersectionRatio > 0) {
44+
// when a card is intersecting the viewport
45+
const appName = (entry.target as HTMLDivElement).dataset
46+
.appName as string
4747

48-
if (is_dlc) {
49-
return null
50-
}
51-
if (!is_installed && onlyInstalled) {
52-
return null
53-
}
48+
// store this appName for later
49+
entered.push(appName)
50+
// stop observing this element
51+
observer.unobserve(entry.target)
52+
}
53+
})
5454

55-
const hasUpdate = is_installed && gameUpdates?.includes(app_name)
56-
return (
57-
<GameCard
58-
key={app_name}
59-
hasUpdate={hasUpdate}
60-
buttonClick={() => {
61-
if (gameInfo.runner !== 'sideload')
62-
handleGameCardClick(app_name, runner, gameInfo)
63-
}}
64-
forceCard={layout === 'grid'}
65-
isRecent={isRecent}
66-
gameInfo={gameInfo}
67-
/>
55+
// dispatch an event with the newley visible cards
56+
// check GameCard for the other side of this detection
57+
window.dispatchEvent(
58+
new CustomEvent('visible-cards', { detail: { appNames: entered } })
6859
)
69-
})
70-
const gameCardElements = (await Promise.all(
71-
resolvedLibrary
72-
)) as JSX.Element[]
73-
74-
if (mounted) {
75-
setGameCards(gameCardElements)
7660
}
77-
}
7861

79-
createGameCards()
62+
const observer = new IntersectionObserver(callback, options)
63+
64+
document.querySelectorAll('[data-invisible]').forEach((card) => {
65+
observer.observe(card)
66+
})
8067

81-
return () => {
82-
mounted = false
68+
return () => {
69+
observer.disconnect()
70+
}
8371
}
84-
}, [library, onlyInstalled, layout, gameUpdates, isRecent, showNonAvailable])
72+
return () => ({})
73+
}, [library])
8574

8675
return (
8776
<div
@@ -100,7 +89,37 @@ const GamesList = ({
10089
<span>{t('wine.actions', 'Action')}</span>
10190
</div>
10291
)}
103-
{!!library.length && gameCards}
92+
{!!library.length &&
93+
library.map((gameInfo) => {
94+
const { app_name, is_installed, runner } = gameInfo
95+
96+
let is_dlc = false
97+
if (gameInfo.runner !== 'sideload') {
98+
is_dlc = gameInfo.install.is_dlc ?? false
99+
}
100+
101+
if (is_dlc) {
102+
return null
103+
}
104+
if (!is_installed && onlyInstalled) {
105+
return null
106+
}
107+
108+
const hasUpdate = is_installed && gameUpdates?.includes(app_name)
109+
return (
110+
<GameCard
111+
key={app_name}
112+
hasUpdate={hasUpdate}
113+
buttonClick={() => {
114+
if (gameInfo.runner !== 'sideload')
115+
handleGameCardClick(app_name, runner, gameInfo)
116+
}}
117+
forceCard={layout === 'grid'}
118+
isRecent={isRecent}
119+
gameInfo={gameInfo}
120+
/>
121+
)
122+
})}
104123
</div>
105124
)
106125
}

src/frontend/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ declare global {
143143
}
144144

145145
interface WindowEventMap {
146+
'visible-cards': CustomEvent<{ appNames: string[] }>
146147
'controller-changed': CustomEvent<{ controllerId: string }>
147148
}
148149
}

0 commit comments

Comments
 (0)