Games as an overlay sheet; remember the detail tab

- New GameSheet: bottom drop-up overlay (backdrop / grab handle / Esc to
  dismiss) summoned from the nav 'Games' button or the feed's game pill;
  picking a game/sub-dex writes settings and the feed updates in place
- Removed the #/games route and GamePicker view
- Detail page remembers the last-opened tab in a new pdx.ui store and
  restores it on return; selectTab guards unknown ids

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-27 12:37:16 -04:00
parent 404ca7dbbd
commit 5144f760f5
8 changed files with 287 additions and 129 deletions

135
src/components/GameSheet.js Normal file
View File

@ -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'));
}

View File

@ -1,25 +1,32 @@
import { el } from '../lib/dom.js'; import { el } from '../lib/dom.js';
import { openGameSheet } from './GameSheet.js';
const ITEMS = [ const ITEMS = [
{ href: '#/', label: 'Dex', icon: '▦', match: (h) => h === '#/' || h === '' || h === '#' }, { label: 'Dex', icon: '▦', href: '#/', match: (h) => h === '#/' || h === '' || h === '#' },
{ href: '#/games', label: 'Games', icon: '◉', match: (h) => h.startsWith('#/games') }, { label: 'Games', icon: '◉', action: openGameSheet },
{ href: '#/search', label: 'Search', icon: '⌕', match: (h) => h.startsWith('#/search') }, { label: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') },
{ href: '#/settings', label: 'Settings', icon: '⚙', match: (h) => h.startsWith('#/settings') }, { 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 * 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() { export function Nav() {
const links = ITEMS.map((item) => const links = ITEMS.map((item) => {
el( const inner = [
'a',
{ class: 'nav__link', href: item.href },
el('span', { class: 'nav__icon', 'aria-hidden': 'true' }, item.icon), el('span', { class: 'nav__icon', 'aria-hidden': 'true' }, item.icon),
el('span', { class: 'nav__label' }, item.label), 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( const nav = el(
'nav', 'nav',
@ -31,6 +38,7 @@ export function Nav() {
function sync() { function sync() {
const hash = location.hash || '#/'; const hash = location.hash || '#/';
ITEMS.forEach((item, i) => { ITEMS.forEach((item, i) => {
if (!item.match) return;
links[i].classList.toggle('is-active', item.match(hash)); links[i].classList.toggle('is-active', item.match(hash));
links[i].setAttribute('aria-current', item.match(hash) ? 'page' : 'false'); links[i].setAttribute('aria-current', item.match(hash) ? 'page' : 'false');
}); });

View File

@ -1,14 +1,12 @@
import { el } from './lib/dom.js'; import { el } from './lib/dom.js';
import { DexGrid } from './views/DexGrid.js'; import { DexGrid } from './views/DexGrid.js';
import { PokemonDetail } from './views/PokemonDetail.js'; import { PokemonDetail } from './views/PokemonDetail.js';
import { GamePicker } from './views/GamePicker.js';
import { SearchView } from './views/SearchView.js'; import { SearchView } from './views/SearchView.js';
import { SettingsView } from './views/SettingsView.js'; import { SettingsView } from './views/SettingsView.js';
const routes = [ const routes = [
{ pattern: /^#?\/?$/, view: () => DexGrid() }, { pattern: /^#?\/?$/, view: () => DexGrid() },
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) }, { pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) },
{ pattern: /^#\/games$/, view: () => GamePicker() },
{ pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() }, { pattern: /^#\/settings$/, view: () => SettingsView() },
]; ];

9
src/store/ui.js Normal file
View File

@ -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',
});

View File

@ -224,14 +224,17 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-top: 8px; margin-top: 8px;
padding: 5px 8px 5px 12px; padding: 5px 10px 5px 12px;
border: none;
border-radius: 999px; border-radius: 999px;
background: var(--surface-2); background: var(--surface-2);
color: var(--text-dim); color: var(--text-dim);
font: inherit;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
text-transform: capitalize; text-transform: capitalize;
cursor: pointer;
} }
.game-pill:hover { .game-pill:hover {
color: var(--text); color: var(--text);
@ -1276,3 +1279,114 @@
left: calc(50% + 110px); 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;
}

View File

@ -5,6 +5,7 @@ import { settings } from '../store/settings.js';
import { selection, stats } from '../store/selection.js'; import { selection, stats } from '../store/selection.js';
import { Card } from '../components/Card.js'; import { Card } from '../components/Card.js';
import { ProgressRing } from '../components/ProgressRing.js'; import { ProgressRing } from '../components/ProgressRing.js';
import { openGameSheet } from '../components/GameSheet.js';
const FILTERS = [ const FILTERS = [
{ key: 'all', label: 'All', test: () => true }, { key: 'all', label: 'All', test: () => true },
@ -30,8 +31,8 @@ export async function DexGrid() {
const title = el('h1', {}); const title = el('h1', {});
const gamePill = el( const gamePill = el(
'a', 'button',
{ class: 'game-pill', href: '#/games' }, { class: 'game-pill', type: 'button', onclick: () => openGameSheet() },
el('span', {}), el('span', {}),
el('span', { class: 'game-pill__chev', 'aria-hidden': 'true' }, '⌄'), el('span', { class: 'game-pill__chev', 'aria-hidden': 'true' }, '⌄'),
); );

View File

@ -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;
}

View File

@ -3,6 +3,7 @@ import { loadSnapshot } from '../data/snapshot.js';
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js'; import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
import { settings } from '../store/settings.js'; import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { entry, toggle } from '../store/selection.js'; import { entry, toggle } from '../store/selection.js';
import { Sprite } from '../components/Sprite.js'; import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js'; import { TypeChip } from '../components/TypeChip.js';
@ -256,14 +257,15 @@ export async function PokemonDetail(nationalId) {
), ),
); );
function selectTab(id) { function selectTab(id) {
const tab = TABS.find((t) => t.id === id) || TABS[0];
TABS.forEach((t, i) => { TABS.forEach((t, i) => {
const active = t.id === id; const active = t.id === tab.id;
tabButtons[i].classList.toggle('is-active', active); tabButtons[i].classList.toggle('is-active', active);
tabButtons[i].setAttribute('aria-selected', String(active)); tabButtons[i].setAttribute('aria-selected', String(active));
}); });
const tab = TABS.find((t) => t.id === id);
body.replaceChildren(tab.node); body.replaceChildren(tab.node);
if (id === 'stats') animateStats(); if (tab.id === 'stats') animateStats();
ui.set({ detailTab: tab.id });
} }
function animateStats() { function animateStats() {
@ -329,7 +331,7 @@ export async function PokemonDetail(nationalId) {
), ),
); );
selectTab('about'); selectTab(ui.get().detailTab);
const off = settings.subscribe(() => syncTrack()); const off = settings.subscribe(() => syncTrack());
onTeardown(view, off); onTeardown(view, off);