Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { GameList } from './components/GameList/GameList';
import { mockGames } from './mocks/games';
import { useDebounce } from './hooks/useDebounce';
import type { Suggestion } from './graphql/searchSuggestions';
import { Header } from './components/Header/Header';
import './App.css';

Expand All @@ -14,9 +14,13 @@ function App() {
const [view, setView] = useState<'list' | 'grid'>('list');
const [showFavorites, setShowFavorites] = useState(false);

// Det brukeren skriver nå, og det vi faktisk søker på etter 300 ms pause
const [input, setInput] = useState('');
const query = useDebounce(input.trim(), 300);
// Søket i hovedlista. Settes først når brukeren trykker Enter, ikke for hver bokstav
// (forslagene i søkefeltet tar seg av det), så lista og pagineringen ikke hopper
const [query, setQuery] = useState('');

// MIDLERTIDIG: det finnes ingen detaljside ennå, så valgt spill vises i lista.
// Den som lager detaljsiden bytter denne linjen til navigering.
const handleSelectGame = (game: Suggestion) => setQuery(game.name);

// MIDLERTIDIG: filtrerer mockdata på klienten for å kunne teste søket.
// Når GraphQL er klart, sendes `query` til backend og filtreres i databasen.
Expand All @@ -37,8 +41,8 @@ function App() {
return (
<>
<Header
search={input}
onSearchChange={setInput}
onSearch={setQuery}
onSelectGame={handleSelectGame}
favoritesCount={favorites.size}
showFavorites={showFavorites}
onToggleFavorites={() => setShowFavorites((v) => !v)}
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/apollo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';

/**
* Felles GraphQL-klient for hele appen. Svar caches i minnet, så samme søk
* to ganger gir bare én forespørsel. URL-en kan overstyres med VITE_GRAPHQL_URL ved deploy.
*/

export const client = new ApolloClient({
link: new HttpLink({ uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001' }),
cache: new InMemoryCache(),
});
87 changes: 87 additions & 0 deletions frontend/src/components/GameSearch/GameSearch.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
.game-search {
position: relative; /* forslagslista plasseres i forhold til skjemaet */
}

/* Skjuler labelen visuelt, men beholder den for skjermlesere */
.game-search__label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}

/* Bare en linje under, som resten av toppfeltet */
.game-search__input {
width: 100%;
padding: 0.4rem 0;
font: inherit;
color: var(--text-h);
background: transparent;
border: 0;
border-bottom: 1px solid var(--text);
border-radius: 0;
-webkit-appearance: textfield;
appearance: textfield;
}

.game-search__input::placeholder {
color: var(--text);
}

.game-search__input:focus-visible {
outline: none;
border-bottom-color: var(--text-h);
box-shadow: 0 1px 0 var(--text-h);
}

/* Forslagene legger seg over innholdet under, med tynn linje rundt i stedet for skygge */
.game-search__popup {
position: absolute;
z-index: 20;
top: calc(100% + 2px);
left: 0;
right: 0;
text-align: left;
background: var(--bg);
border: 1px solid var(--border);
border-top: 0;
}

.game-search__list {
margin: 0;
padding: 4px 0;
list-style: none;
}

.game-search__option {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 6px 12px;
color: var(--text-h);
cursor: pointer;
}

/* Markert forslag (pil eller mus): strek under som de andre knappene, og svak bakgrunn */
.game-search__option[aria-selected='true'] {
background: var(--code-bg);
text-decoration: underline;
text-underline-offset: 4px;
}

.game-search__year,
.game-search__status {
font-family: var(--mono);
font-size: 14px;
color: var(--text);
}

.game-search__status {
margin: 0;
padding: 8px 12px;
}
138 changes: 138 additions & 0 deletions frontend/src/components/GameSearch/GameSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useId, useState, type KeyboardEvent } from 'react';
import { useQuery } from '@apollo/client/react';
import { useDebounce } from '../../hooks/useDebounce';
import { SEARCH_SUGGESTIONS, type Suggestion } from '../../graphql/searchSuggestions';
import './GameSearch.css';

// Kortere tekst enn dette gir for mange tilfeldige treff, så vi spør ikke serveren
const MIN_CHARS = 2;

interface GameSearchProps {
/** Enter uten valgt forslag: søk i hovedlista */
onSearch: (term: string) => void;
/** Brukeren valgte ett spill i forslagslista */
onSelectGame: (game: Suggestion) => void;
}

/**
* Søkefelt med forslag (combobox). Mens man skriver hentes bare noen få treff fra
* serveren og vises under feltet. Hovedlista endres ikke før man trykker Enter, så
* den ikke hopper for hver bokstav og pagineringen ikke nullstilles hele tiden.
*
* Tastatur: pil opp/ned flytter i lista, Enter velger, Escape lukker.
* Følger WAI-ARIA-mønsteret "combobox med listbox".
*/

export function GameSearch({ onSearch, onSelectGame }: GameSearchProps) {
const [input, setInput] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1); // markert forslag, -1 = ingen
const id = useId();
const listId = `${id}-list`;

// Spør først 300 ms etter siste tastetrykk, og ikke ved for kort tekst
const term = useDebounce(input.trim(), 300);
const { data, previousData, loading } = useQuery(SEARCH_SUGGESTIONS, {
variables: { search: term },
skip: term.length < MIN_CHARS,
});

// Viser forrige svar mens det nye lastes, så lista ikke blinker
const suggestions = (data ?? previousData)?.games.edges.map((e) => e.node) ?? [];
const showPopup = open && term.length >= MIN_CHARS;

function select(game: Suggestion) {
setOpen(false);
setActive(-1);
onSelectGame(game);
}

function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setActive((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => Math.max(i - 1, -1));
} else if (e.key === 'Escape') {
setOpen(false);
setActive(-1);
} else if (e.key === 'Enter' && active >= 0 && suggestions[active]) {
e.preventDefault(); // velger forslaget i stedet for å sende skjemaet
select(suggestions[active]);
}
}

return (
<form
className="game-search"
role="search"
onSubmit={(e) => {
e.preventDefault();
setOpen(false);
onSearch(input.trim());
}}
>
{/* Skjult visuelt, men lest opp av skjermlesere. Placeholder alene er ikke nok */}
<label htmlFor={`${id}-input`} className="game-search__label">
Søk etter spill
</label>
<input
id={`${id}-input`}
className="game-search__input"
type="search"
role="combobox"
aria-expanded={showPopup && suggestions.length > 0}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={active >= 0 ? `${id}-option-${active}` : undefined}
placeholder="Søk etter spill …"
autoComplete="off"
value={input}
onChange={(e) => {
setInput(e.target.value);
setOpen(true);
setActive(-1);
}}
onKeyDown={handleKeyDown}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>

{showPopup && (
<div className="game-search__popup">
{suggestions.length > 0 ? (
<ul id={listId} role="listbox" aria-label="Forslag" className="game-search__list">
{suggestions.map((game, i) => (
<li
key={game.id}
id={`${id}-option-${i}`}
role="option"
aria-selected={i === active}
className="game-search__option"
// mousedown, ikke click: click kommer etter blur, og da er lista allerede lukket
onMouseDown={(e) => {
e.preventDefault();
select(game);
}}
onMouseEnter={() => setActive(i)}
>
<span>{game.name}</span>
{game.released && (
<span className="game-search__year">{game.released.slice(0, 4)}</span>
)}
</li>
))}
</ul>
) : (
// role="status" gjør at skjermlesere leser opp meldingen
<p role="status" className="game-search__status">
{loading ? 'Søker …' : 'Ingen treff'}
</p>
)}
</div>
)}
</form>
);
}
13 changes: 7 additions & 6 deletions frontend/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Logo } from '../Logo/Logo';
import { SearchBar } from '../SearchBar/SearchBar';
import { GameSearch } from '../GameSearch/GameSearch';
import type { Suggestion } from '../../graphql/searchSuggestions';
import { ThemeToggle } from '../ThemeToggle/ThemeToggle';
import './Header.css';

interface HeaderProps {
search: string;
onSearchChange: (value: string) => void;
onSearch: (term: string) => void;
onSelectGame: (game: Suggestion) => void;
favoritesCount: number;
showFavorites: boolean;
onToggleFavorites: () => void;
Expand All @@ -16,8 +17,8 @@ interface HeaderProps {
* Ingen bokser, bare tekst og en tynn linje under.
*/
export function Header({
search,
onSearchChange,
onSearch,
onSelectGame,
favoritesCount,
showFavorites,
onToggleFavorites,
Expand All @@ -26,7 +27,7 @@ export function Header({
<header className="header">
<Logo />
<div className="header__search">
<SearchBar value={search} onChange={onSearchChange} />
<GameSearch onSearch={onSearch} onSelectGame={onSelectGame} />
</div>
<nav className="header__nav" aria-label="Meny">
<button
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/graphql/searchSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { gql, type TypedDocumentNode } from '@apollo/client';

/** Ett forslag i søkefeltet. Bare det dropdownen viser, så svaret blir lite. */
export interface Suggestion {
id: string;
name: string;
released: string | null;
}

interface SuggestionsData {
games: { edges: { node: Suggestion }[] };
}

interface SuggestionsVars {
search: string;
}

/**
* Henter noen få spill som matcher teksten. totalCount spørres ikke om,
* så backend slipper å telle alle treff for hver forespørsel.
*/
export const SEARCH_SUGGESTIONS: TypedDocumentNode<SuggestionsData, SuggestionsVars> = gql`
query SearchSuggestions($search: String!) {
games(search: $search, first: 6) {
edges {
node {
id
name
released
}
}
}
}
`;
6 changes: 5 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ApolloProvider } from '@apollo/client/react';
import { client } from './apollo';
import './index.css';
import App from './App.tsx';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</StrictMode>,
);
Loading