even more navigation refinements
This commit is contained in:
@@ -11,29 +11,31 @@ interface FocusableItemProps {
|
||||
className?: string;
|
||||
focusKey?: string;
|
||||
scrollOptions?: ScrollIntoViewOptions;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, scrollOptions }: FocusableItemProps) => {
|
||||
const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, scrollOptions, active }: FocusableItemProps) => {
|
||||
const { mode } = useInputMode();
|
||||
const { ref, focused: rawFocused } = useFocusable({
|
||||
onFocus: () => {
|
||||
onFocus();
|
||||
if (mode === 'gamepad') {
|
||||
if (scrollOptions) {
|
||||
ref.current?.scrollIntoView(scrollOptions);
|
||||
} else {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}
|
||||
}
|
||||
},
|
||||
const { ref, focused: rawFocused, focusKey: internalFocusKey } = useFocusable({
|
||||
onFocus,
|
||||
onEnterPress,
|
||||
focusKey
|
||||
});
|
||||
|
||||
const focused = mode === 'gamepad' ? rawFocused : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'gamepad' && (focused || active) && ref.current) {
|
||||
if (scrollOptions) {
|
||||
ref.current.scrollIntoView(scrollOptions);
|
||||
} else {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}
|
||||
}
|
||||
}, [focused, active, mode, scrollOptions]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`${className} ${focused ? 'focused' : ''}`}>
|
||||
<div ref={ref} data-focusable-key={internalFocusKey} className={`${className} ${focused ? 'focused' : ''}`}>
|
||||
{children(focused)}
|
||||
</div>
|
||||
);
|
||||
@@ -44,6 +46,7 @@ const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, acti
|
||||
onFocus={onSelect}
|
||||
className="shrink-0"
|
||||
focusKey={`PLATFORM_${platform.id}`}
|
||||
active={active}
|
||||
>
|
||||
{(focused) => (
|
||||
<div className="flex flex-col items-center gap-2 group cursor-pointer w-[110px]">
|
||||
@@ -77,6 +80,7 @@ const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean,
|
||||
className="w-full"
|
||||
focusKey={`GAME_${game.id}`}
|
||||
scrollOptions={{ behavior: 'smooth', block: 'nearest' }}
|
||||
active={active}
|
||||
>
|
||||
{(focused) => (
|
||||
<div className={`flex items-center gap-4 px-6 py-2.5 cursor-pointer transition-all duration-300 border-l-4 ${focused ? 'bg-[#2563eb]/20 border-[#2563eb] z-10' : active ? 'bg-[#2563eb]/10 border-[#2563eb]/70' : 'border-transparent hover:bg-white/5'}`}>
|
||||
@@ -130,20 +134,24 @@ const AlphabetScroller = ({
|
||||
};
|
||||
|
||||
export const GamesPage = () => {
|
||||
const { mode } = useInputMode();
|
||||
const [selectedPlatformId, setSelectedPlatformId] = useState<number | null>(null);
|
||||
const [selectedGameId, setSelectedGameId] = useState<string | null>(null);
|
||||
const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null);
|
||||
const detailTimeoutRef = React.useRef<any>(null);
|
||||
|
||||
const { ref: platformsRef, focusKey: platformsFocusKey } = useFocusable({
|
||||
focusKey: 'PLATFORMS_ZONE',
|
||||
trackChildren: true,
|
||||
preferredChildFocusKey: selectedPlatformId ? `PLATFORM_${selectedPlatformId}` : undefined
|
||||
preferredChildFocusKey: selectedPlatformId ? `PLATFORM_${selectedPlatformId}` : undefined,
|
||||
onBlur: () => setActiveZone(prev => prev === 'platforms' ? null : prev)
|
||||
});
|
||||
|
||||
const { ref: gamesRef, focusKey: gamesFocusKey } = useFocusable({
|
||||
focusKey: 'GAMES_ZONE',
|
||||
trackChildren: true,
|
||||
preferredChildFocusKey: selectedGameId ? `GAME_${selectedGameId}` : undefined
|
||||
preferredChildFocusKey: selectedGameId ? `GAME_${selectedGameId}` : undefined,
|
||||
onBlur: () => setActiveZone(prev => prev === 'games' ? null : prev)
|
||||
});
|
||||
|
||||
const { ref: alphabetRef, focusKey: alphabetFocusKey } = useFocusable({
|
||||
@@ -192,10 +200,20 @@ export const GamesPage = () => {
|
||||
}
|
||||
}, [gamesQuery.data, selectedGameId]);
|
||||
|
||||
const handlePlatformFocus = (id: number) => {
|
||||
setSelectedPlatformId(id);
|
||||
setSelectedGameId(null);
|
||||
setActiveZone('platforms');
|
||||
const handlePlatformFocus = (id: number, options: { skipZoneChange?: boolean } = {}) => {
|
||||
if (mode === 'gamepad') {
|
||||
if (!options.skipZoneChange) {
|
||||
setActiveZone('platforms');
|
||||
}
|
||||
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
||||
detailTimeoutRef.current = setTimeout(() => {
|
||||
setSelectedPlatformId(id);
|
||||
setSelectedGameId(null);
|
||||
}, 150);
|
||||
} else {
|
||||
setSelectedPlatformId(id);
|
||||
setSelectedGameId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Alphabet Logic
|
||||
@@ -225,6 +243,97 @@ export const GamesPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateFocusFromScroll = (container: HTMLElement) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
const candidates = Array.from(container.querySelectorAll('[data-focusable-key]'));
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
let closestKey: string | null = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
candidates.forEach((el) => {
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const elCenterX = elRect.left + elRect.width / 2;
|
||||
const elCenterY = elRect.top + elRect.height / 2;
|
||||
|
||||
const dist = Math.sqrt(Math.pow(centerX - elCenterX, 2) + Math.pow(centerY - elCenterY, 2));
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
closestKey = el.getAttribute('data-focusable-key');
|
||||
}
|
||||
});
|
||||
|
||||
if (closestKey && mode === 'gamepad') {
|
||||
console.log("[MagneticFocus] Setting focus to:", closestKey);
|
||||
setFocus(closestKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Right analog stick scrolling — scrolls whichever zone is currently active
|
||||
const SCROLL_SPEED = 12; // Increased speed for right stick
|
||||
useEffect(() => {
|
||||
const onAnalogScroll = (e: Event) => {
|
||||
const { dx, dy } = (e as CustomEvent<{ dx: number; dy: number }>).detail;
|
||||
let target: HTMLElement | null = null;
|
||||
if (activeZone === 'platforms') target = platformsRef.current as HTMLElement | null;
|
||||
else if (activeZone === 'games') target = gamesRef.current as HTMLElement | null;
|
||||
else if (activeZone === 'alphabet') target = alphabetRef.current as HTMLElement | null;
|
||||
else if (activeZone === 'details') target = detailsRef.current as HTMLElement | null;
|
||||
|
||||
if (target) {
|
||||
target.scrollLeft += dx * SCROLL_SPEED;
|
||||
target.scrollTop += dy * SCROLL_SPEED;
|
||||
|
||||
// Magnetic Focus: Update the focused element to match the new scroll position
|
||||
if (activeZone === 'platforms' || activeZone === 'games' || activeZone === 'alphabet') {
|
||||
updateFocusFromScroll(target);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('analogscroll', onAnalogScroll);
|
||||
return () => window.removeEventListener('analogscroll', onAnalogScroll);
|
||||
}, [activeZone, platformsRef, gamesRef, alphabetRef, detailsRef, mode]);
|
||||
|
||||
// Bumper navigation — LB/RB to cycle platforms globally
|
||||
useEffect(() => {
|
||||
const onCycle = (direction: 'next' | 'prev') => {
|
||||
if (filteredPlatforms.length <= 1) return;
|
||||
|
||||
// Find current platform index (use most recent selectedPlatformId)
|
||||
const currentIndex = filteredPlatforms.findIndex(p => p.id === selectedPlatformId);
|
||||
if (currentIndex === -1 && selectedPlatformId !== null) return;
|
||||
|
||||
let nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1;
|
||||
|
||||
// Looping
|
||||
if (nextIndex >= filteredPlatforms.length) nextIndex = 0;
|
||||
if (nextIndex < 0) nextIndex = filteredPlatforms.length - 1;
|
||||
|
||||
const nextPlatform = filteredPlatforms[nextIndex];
|
||||
|
||||
// Silent switch: don't change activeZone highlight unless we are already there
|
||||
handlePlatformFocus(nextPlatform.id, { skipZoneChange: activeZone !== 'platforms' });
|
||||
|
||||
// If we are currently in the platforms zone, move focus visually to the new item
|
||||
if (activeZone === 'platforms') {
|
||||
setFocus(`PLATFORM_${nextPlatform.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => onCycle('prev');
|
||||
const handleNext = () => onCycle('next');
|
||||
|
||||
window.addEventListener('previousplatform', handlePrev);
|
||||
window.addEventListener('nextplatform', handleNext);
|
||||
return () => {
|
||||
window.removeEventListener('previousplatform', handlePrev);
|
||||
window.removeEventListener('nextplatform', handleNext);
|
||||
};
|
||||
}, [filteredPlatforms, selectedPlatformId, activeZone, mode]);
|
||||
|
||||
if (platformsQuery.isLoading) return <div className="h-full flex items-center justify-center text-white/50 geist-mono uppercase">Initializing Archives...</div>;
|
||||
|
||||
return (
|
||||
@@ -232,13 +341,13 @@ export const GamesPage = () => {
|
||||
{/* Header / Platforms */}
|
||||
<div className="px-12 py-4 shrink-0 border-b border-white/5 bg-black/10">
|
||||
<FocusContext.Provider value={platformsFocusKey}>
|
||||
<div ref={platformsRef} className={`flex gap-6 overflow-x-auto py-4 ${activeZone === 'platforms' ? 'scrollbar-active' : ''}`}>
|
||||
<div ref={platformsRef} className={`flex gap-6 overflow-x-auto py-4 scrollbar-hoverable ${activeZone === 'platforms' ? 'scrollbar-active' : ''}`}>
|
||||
{filteredPlatforms.map(p => (
|
||||
<PlatformItem
|
||||
key={p.id}
|
||||
platform={p}
|
||||
active={selectedPlatformId === p.id}
|
||||
onSelect={() => handlePlatformFocus(p.id)}
|
||||
onSelect={() => handlePlatformFocus(p.id, { skipZoneChange: false })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -251,7 +360,7 @@ export const GamesPage = () => {
|
||||
<div className="w-[500px] border-r border-white/5 flex overflow-hidden bg-black/20">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<FocusContext.Provider value={gamesFocusKey}>
|
||||
<div ref={gamesRef} className={`flex-1 overflow-y-auto ${activeZone === 'games' ? 'scrollbar-active' : ''}`}>
|
||||
<div ref={gamesRef} className={`flex-1 overflow-y-auto scrollbar-hoverable ${activeZone === 'games' ? 'scrollbar-active' : ''}`}>
|
||||
{gamesQuery.isLoading ? (
|
||||
<div className="p-12 text-center text-white/20 geist-mono text-[10px] uppercase">Retrieving index...</div>
|
||||
) : (
|
||||
@@ -260,7 +369,17 @@ export const GamesPage = () => {
|
||||
key={game.id}
|
||||
game={game}
|
||||
active={selectedGameId === game.id}
|
||||
onFocus={() => { setSelectedGameId(game.id); setActiveZone('games'); }}
|
||||
onFocus={() => {
|
||||
if (mode === 'gamepad') {
|
||||
setActiveZone('games');
|
||||
if (detailTimeoutRef.current) clearTimeout(detailTimeoutRef.current);
|
||||
detailTimeoutRef.current = setTimeout(() => {
|
||||
setSelectedGameId(game.id);
|
||||
}, 150);
|
||||
} else {
|
||||
setSelectedGameId(game.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -282,7 +401,7 @@ export const GamesPage = () => {
|
||||
|
||||
{/* Right Column: Game Details */}
|
||||
<FocusContext.Provider value={detailsFocusKey}>
|
||||
<div ref={detailsRef} className={`flex-1 overflow-y-auto relative ${activeZone === 'details' ? 'scrollbar-active' : ''}`}>
|
||||
<div ref={detailsRef} className={`flex-1 overflow-y-auto relative scrollbar-hoverable ${activeZone === 'details' ? 'scrollbar-active' : ''}`}>
|
||||
{detailsQuery.data ? (
|
||||
<div className="p-16">
|
||||
<div className="flex gap-12 items-start">
|
||||
@@ -322,7 +441,7 @@ export const GamesPage = () => {
|
||||
|
||||
<div className="flex gap-4 mb-12">
|
||||
<FocusableItem
|
||||
onFocus={() => setActiveZone('details')}
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_PLAY"
|
||||
@@ -336,7 +455,7 @@ export const GamesPage = () => {
|
||||
</FocusableItem>
|
||||
|
||||
<FocusableItem
|
||||
onFocus={() => setActiveZone('details')}
|
||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||
onEnterPress={() => console.log("Download", detailsQuery.data?.id)}
|
||||
className="shrink-0"
|
||||
focusKey="DETAILS_DOWNLOAD"
|
||||
|
||||
@@ -8,6 +8,8 @@ const BUTTON_DPAD_UP = 12;
|
||||
const BUTTON_DPAD_DOWN = 13;
|
||||
const BUTTON_DPAD_LEFT = 14;
|
||||
const BUTTON_DPAD_RIGHT = 15;
|
||||
const BUTTON_LB = 4;
|
||||
const BUTTON_RB = 5;
|
||||
const AXIS_THRESHOLD = 0.5;
|
||||
const INITIAL_DELAY_MS = 400;
|
||||
const REPEAT_RATE_MS = 100;
|
||||
@@ -86,6 +88,19 @@ export const useGamepad = () => {
|
||||
handleInput(`gp${i}_right`, right, () => navigateByDirection('right', {}));
|
||||
handleInput(`gp${i}_enter`, enter, () => dispatchEnter(), false);
|
||||
handleInput(`gp${i}_back`, back, () => dispatchEscape(), false);
|
||||
|
||||
const lb = gp.buttons[BUTTON_LB]?.pressed;
|
||||
const rb = gp.buttons[BUTTON_RB]?.pressed;
|
||||
handleInput(`gp${i}_lb`, lb, () => window.dispatchEvent(new CustomEvent('previousplatform')));
|
||||
handleInput(`gp${i}_rb`, rb, () => window.dispatchEvent(new CustomEvent('nextplatform')));
|
||||
|
||||
// Right analog stick — scroll the currently focused list
|
||||
const rsX = gp.axes[2] ?? 0;
|
||||
const rsY = gp.axes[3] ?? 0;
|
||||
if (Math.abs(rsX) > AXIS_THRESHOLD || Math.abs(rsY) > AXIS_THRESHOLD) {
|
||||
setMode('gamepad');
|
||||
window.dispatchEvent(new CustomEvent('analogscroll', { detail: { dx: rsX, dy: rsY } }));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Gamepad Polling Error", e);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover-color, rgba(255, 255, 255, 0.2));
|
||||
background: var(--scrollbar-thumb-color, rgba(255, 255, 255, 0.2));
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
@@ -46,10 +46,12 @@ body {
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* Setting the CSS variable on the scrollable container causes the pseudo-element
|
||||
to inherit it, which is the correct way to drive scrollbar color via state. */
|
||||
.scrollbar-active {
|
||||
to inherit it, which is the correct way to drive scrollbar color via state.
|
||||
.scrollbar-active = gamepad / zone focus
|
||||
.scrollbar-hoverable = mouse hover (same mechanism, same color) */
|
||||
.scrollbar-active,
|
||||
.scrollbar-hoverable:hover {
|
||||
--scrollbar-thumb-color: var(--color-accent);
|
||||
--scrollbar-thumb-hover-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Auto-scroll animation for long game titles when focused */
|
||||
|
||||
Reference in New Issue
Block a user