diff --git a/src/components/GameSheet.js b/src/components/GameSheet.js new file mode 100644 index 0000000..eb1f823 --- /dev/null +++ b/src/components/GameSheet.js @@ -0,0 +1,135 @@ +import { el, clear } from '../lib/dom.js'; +import { loadSnapshot } from '../data/snapshot.js'; +import { + versionGroupsByGeneration, + dexesForVersionGroup, + prettify, +} from '../data/pokedex-resolver.js'; +import { settings } from '../store/settings.js'; + +let openInstance = null; + +/** + * A summonable overlay for picking the game (version group) and, for games + * with more than one regional dex, the active sub-dex. Slides up from the + * bottom; dismiss with the backdrop, the ✕, or Escape. Selecting writes to + * `settings`, which the feed reacts to — no navigation. + */ +export async function openGameSheet() { + if (openInstance) return; + const snap = await loadSnapshot(); + + const backdrop = el('div', { + class: 'sheet-backdrop', + onclick: (e) => { + if (e.target === backdrop) close(); + }, + }); + const panel = el('div', { + class: 'gsheet', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': 'Choose your game', + }); + + const onKey = (e) => { + if (e.key === 'Escape') close(); + }; + + function close() { + document.removeEventListener('keydown', onKey); + backdrop.classList.remove('is-open'); + setTimeout(() => { + backdrop.remove(); + openInstance = null; + }, 220); + } + + const subdex = el('div', { class: 'gsheet__subdex' }); + const list = el('div', { class: 'gsheet__list' }); + + function renderSubdex() { + const st = settings.get(); + const dexes = dexesForVersionGroup(snap, st.versionGroup); + clear(subdex); + if (dexes.length <= 1) return; + subdex.append(el('span', { class: 'gsheet__label' }, 'Pokédex')); + for (const dex of dexes) { + const active = (st.pokedex || dexes[0].key) === dex.key; + subdex.append( + el( + 'button', + { + class: `feed-chip${active ? ' is-active' : ''}`, + type: 'button', + onclick: () => { + settings.set({ pokedex: dex.key }); + close(); + }, + }, + prettify(dex.name || dex.key), + ), + ); + } + } + + function renderGames() { + const st = settings.get(); + clear(list); + for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) { + list.append( + el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`), + ); + const grid = el('div', { class: 'gsheet__grid' }); + for (const vg of versionGroups) { + const active = vg.key === st.versionGroup; + const dexCount = dexesForVersionGroup(snap, vg.key).length; + grid.append( + el( + 'button', + { + class: `gamecard${active ? ' is-active' : ''}`, + type: 'button', + onclick: () => { + settings.set({ versionGroup: vg.key, pokedex: null }); + if (dexesForVersionGroup(snap, vg.key).length <= 1) { + close(); + } else { + renderGames(); + renderSubdex(); + } + }, + }, + el('span', { class: 'gamecard__name' }, prettify(vg.name || vg.key)), + el('span', { class: 'gamecard__meta' }, vg.versions.map(prettify).join(' / ')), + el('span', { class: 'gamecard__meta' }, `${dexCount} ${dexCount === 1 ? 'dex' : 'dexes'}`), + ), + ); + } + list.append(grid); + } + } + + panel.append( + el( + 'div', + { class: 'gsheet__head' }, + el('h2', {}, 'Choose your game'), + el( + 'button', + { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, + '✕', + ), + ), + subdex, + list, + ); + backdrop.append(panel); + document.body.append(backdrop); + document.addEventListener('keydown', onKey); + openInstance = backdrop; + + renderGames(); + renderSubdex(); + requestAnimationFrame(() => backdrop.classList.add('is-open')); +} diff --git a/src/components/Nav.js b/src/components/Nav.js index 52a3b51..14739e1 100644 --- a/src/components/Nav.js +++ b/src/components/Nav.js @@ -1,25 +1,32 @@ import { el } from '../lib/dom.js'; +import { openGameSheet } from './GameSheet.js'; const ITEMS = [ - { href: '#/', label: 'Dex', icon: '▦', match: (h) => h === '#/' || h === '' || h === '#' }, - { href: '#/games', label: 'Games', icon: '◉', match: (h) => h.startsWith('#/games') }, - { href: '#/search', label: 'Search', icon: '⌕', match: (h) => h.startsWith('#/search') }, - { href: '#/settings', label: 'Settings', icon: '⚙', match: (h) => h.startsWith('#/settings') }, + { label: 'Dex', icon: '▦', href: '#/', match: (h) => h === '#/' || h === '' || h === '#' }, + { label: 'Games', icon: '◉', action: openGameSheet }, + { label: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') }, + { label: 'Settings', icon: '⚙', href: '#/settings', match: (h) => h.startsWith('#/settings') }, ]; /** * One nav element, styled by CSS into a bottom bar on phones and a sidebar - * on wide screens. The active item is derived from the hash. + * on wide screens. Links derive their active state from the hash; the Games + * item summons an overlay instead of navigating. */ export function Nav() { - const links = ITEMS.map((item) => - el( - 'a', - { class: 'nav__link', href: item.href }, + const links = ITEMS.map((item) => { + const inner = [ el('span', { class: 'nav__icon', 'aria-hidden': 'true' }, item.icon), el('span', { class: 'nav__label' }, item.label), - ), - ); + ]; + return item.action + ? el( + 'button', + { class: 'nav__link', type: 'button', onclick: () => item.action() }, + ...inner, + ) + : el('a', { class: 'nav__link', href: item.href }, ...inner); + }); const nav = el( 'nav', @@ -31,6 +38,7 @@ export function Nav() { function sync() { const hash = location.hash || '#/'; ITEMS.forEach((item, i) => { + if (!item.match) return; links[i].classList.toggle('is-active', item.match(hash)); links[i].setAttribute('aria-current', item.match(hash) ? 'page' : 'false'); }); diff --git a/src/router.js b/src/router.js index 3c45661..f6320b5 100644 --- a/src/router.js +++ b/src/router.js @@ -1,14 +1,12 @@ import { el } from './lib/dom.js'; import { DexGrid } from './views/DexGrid.js'; import { PokemonDetail } from './views/PokemonDetail.js'; -import { GamePicker } from './views/GamePicker.js'; import { SearchView } from './views/SearchView.js'; import { SettingsView } from './views/SettingsView.js'; const routes = [ { pattern: /^#?\/?$/, view: () => DexGrid() }, { pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) }, - { pattern: /^#\/games$/, view: () => GamePicker() }, { pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/settings$/, view: () => SettingsView() }, ]; diff --git a/src/store/ui.js b/src/store/ui.js new file mode 100644 index 0000000..4bb4772 --- /dev/null +++ b/src/store/ui.js @@ -0,0 +1,9 @@ +import { createStore } from './createStore.js'; + +/** + * Small view-state that should survive reloads but isn't a user "setting": + * e.g. which detail-page tab you last looked at. + */ +export const ui = createStore('pdx.ui', { + detailTab: 'about', +}); diff --git a/src/styles/layout.css b/src/styles/layout.css index c12234e..6c6b3ba 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -224,14 +224,17 @@ align-items: center; gap: 6px; margin-top: 8px; - padding: 5px 8px 5px 12px; + padding: 5px 10px 5px 12px; + border: none; border-radius: 999px; background: var(--surface-2); color: var(--text-dim); + font: inherit; font-size: 0.82rem; font-weight: 600; text-decoration: none; text-transform: capitalize; + cursor: pointer; } .game-pill:hover { color: var(--text); @@ -1276,3 +1279,114 @@ left: calc(50% + 110px); } } + +/* ---------- Game sheet (overlay) --------------------------- */ +.sheet-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); + opacity: 0; + transition: opacity 0.2s ease; +} +.sheet-backdrop.is-open { + opacity: 1; +} +.gsheet { + width: 100%; + max-width: 560px; + max-height: 82vh; + display: flex; + flex-direction: column; + padding: 4px 20px 24px; + background: var(--surface); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + box-shadow: var(--shadow-lg); + transform: translateY(100%); + transition: transform 0.24s var(--ease-spring); +} +.sheet-backdrop.is-open .gsheet { + transform: none; +} +@media (min-width: 640px) { + .sheet-backdrop { + align-items: center; + } + .gsheet { + border-radius: var(--radius-lg); + max-height: 78vh; + transform: translateY(24px) scale(0.97); + } +} +@media (prefers-reduced-motion: reduce) { + .gsheet { + transition: none; + } +} +.gsheet__head { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0 8px; + background: var(--surface); +} +.gsheet__head::before { + content: ""; + position: absolute; + top: 6px; + left: 50%; + transform: translateX(-50%); + width: 38px; + height: 4px; + border-radius: 2px; + background: var(--border); +} +.gsheet__head h2 { + margin: 0; + font-size: 1.1rem; +} +.gsheet__close { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--surface-2); + color: var(--text-dim); + font-size: 0.85rem; + cursor: pointer; +} +.gsheet__subdex { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 6px 0 2px; +} +.gsheet__label { + align-self: center; + font-size: 0.78rem; + color: var(--text-dim); +} +.gsheet__list { + overflow-y: auto; + margin-top: 4px; + padding-bottom: 8px; +} +.gsheet__gen { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin: 18px 0 8px; +} +.gsheet__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: 8px; +} diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index 817c1a4..5db9cc6 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -5,6 +5,7 @@ import { settings } from '../store/settings.js'; import { selection, stats } from '../store/selection.js'; import { Card } from '../components/Card.js'; import { ProgressRing } from '../components/ProgressRing.js'; +import { openGameSheet } from '../components/GameSheet.js'; const FILTERS = [ { key: 'all', label: 'All', test: () => true }, @@ -30,8 +31,8 @@ export async function DexGrid() { const title = el('h1', {}); const gamePill = el( - 'a', - { class: 'game-pill', href: '#/games' }, + 'button', + { class: 'game-pill', type: 'button', onclick: () => openGameSheet() }, el('span', {}), el('span', { class: 'game-pill__chev', 'aria-hidden': 'true' }, '⌄'), ); diff --git a/src/views/GamePicker.js b/src/views/GamePicker.js deleted file mode 100644 index 97d3fee..0000000 --- a/src/views/GamePicker.js +++ /dev/null @@ -1,109 +0,0 @@ -import { el, onTeardown } from '../lib/dom.js'; -import { loadSnapshot } from '../data/snapshot.js'; -import { - versionGroupsByGeneration, - dexesForVersionGroup, - prettify, -} from '../data/pokedex-resolver.js'; -import { settings } from '../store/settings.js'; - -/** - * Two-tier selection: pick a game (version group), then — if it has more - * than one regional dex — pick which dex is active. Both persist to - * settings, so the dex grid resumes here on next launch. - */ -export async function GamePicker() { - const snap = await loadSnapshot(); - const view = el('section', { class: 'view gamepicker' }); - - const subdexBar = el('div', { class: 'gamepicker__subdex' }); - - function renderSubdex() { - const st = settings.get(); - const dexes = dexesForVersionGroup(snap, st.versionGroup); - subdexBar.replaceChildren(); - if (dexes.length <= 1) return; - - subdexBar.append(el('span', { class: 'gamepicker__subdex-label' }, 'Dex:')); - for (const dex of dexes) { - const active = (st.pokedex || dexes[0].key) === dex.key; - subdexBar.append( - el( - 'button', - { - class: `chip${active ? ' is-active' : ''}`, - type: 'button', - onclick: () => { - settings.set({ pokedex: dex.key }); - renderSubdex(); - }, - }, - prettify(dex.name || dex.key), - ), - ); - } - } - - function renderGames() { - const st = settings.get(); - list.replaceChildren(); - for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) { - list.append(el('h2', { class: 'gamepicker__gen' }, `Gen ${generation.id} — ${generation.name}`)); - const groupWrap = el('div', { class: 'gamepicker__grid' }); - for (const vg of versionGroups) { - const active = vg.key === st.versionGroup; - const dexCount = dexesForVersionGroup(snap, vg.key).length; - groupWrap.append( - el( - 'button', - { - class: `gamecard${active ? ' is-active' : ''}`, - type: 'button', - onclick: () => { - settings.set({ versionGroup: vg.key, pokedex: null }); - renderGames(); - renderSubdex(); - }, - }, - el('span', { class: 'gamecard__name' }, prettify(vg.name || vg.key)), - el( - 'span', - { class: 'gamecard__meta' }, - vg.versions.map((v) => prettify(v)).join(' / '), - ), - el( - 'span', - { class: 'gamecard__meta' }, - `${dexCount} ${dexCount === 1 ? 'dex' : 'dexes'}`, - ), - ), - ); - } - list.append(groupWrap); - } - } - - const list = el('div', { class: 'gamepicker__list' }); - - view.append( - el( - 'header', - { class: 'view__header' }, - el('h1', {}, 'Choose your game'), - el('p', {}, 'Sets which Pokédex, numbering and flavor text the app shows.'), - ), - subdexBar, - list, - ); - - renderGames(); - renderSubdex(); - - const off = settings.subscribe(() => { - renderGames(); - renderSubdex(); - }); - onTeardown(view, off); - - return view; -} diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 146068e..2a2372d 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -3,6 +3,7 @@ import { loadSnapshot } from '../data/snapshot.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js'; import { settings } from '../store/settings.js'; +import { ui } from '../store/ui.js'; import { entry, toggle } from '../store/selection.js'; import { Sprite } from '../components/Sprite.js'; import { TypeChip } from '../components/TypeChip.js'; @@ -256,14 +257,15 @@ export async function PokemonDetail(nationalId) { ), ); function selectTab(id) { + const tab = TABS.find((t) => t.id === id) || TABS[0]; TABS.forEach((t, i) => { - const active = t.id === id; + const active = t.id === tab.id; tabButtons[i].classList.toggle('is-active', active); tabButtons[i].setAttribute('aria-selected', String(active)); }); - const tab = TABS.find((t) => t.id === id); body.replaceChildren(tab.node); - if (id === 'stats') animateStats(); + if (tab.id === 'stats') animateStats(); + ui.set({ detailTab: tab.id }); } function animateStats() { @@ -329,7 +331,7 @@ export async function PokemonDetail(nationalId) { ), ); - selectTab('about'); + selectTab(ui.get().detailTab); const off = settings.subscribe(() => syncTrack()); onTeardown(view, off);