New lib/anim.js (countUp, fadeSlideIn, segThumb), all reduced-motion aware: - Shared-element morph: tapping a card tags its art with view-transition-name; the detail skeleton now renders the real sprite at hero size (router passes the match, animates the skeleton mount), so the sprite flies from the grid into the hero and the skeleton→content swap is seamless. - Segmented controls (Team / Search / Breeding) get a sliding pill that measures each button and eases between them. - Count-ups: the dex-feed progress number and the detail Stats total tick up. - Detail tab content fades/rises on switch. - Global :active scale-press on buttons/chips/rows, plus soft background/border transitions. - Hero sprite has a slow idle bob. - Router swallows the AbortError when a fast follow-up nav skips an in-flight view transition. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
112 lines
4.1 KiB
JavaScript
112 lines
4.1 KiB
JavaScript
import { el } from './lib/dom.js';
|
|
import { DexGrid } from './views/DexGrid.js';
|
|
import { PokemonDetail } from './views/PokemonDetail.js';
|
|
import { SearchView } from './views/SearchView.js';
|
|
import { SettingsView } from './views/SettingsView.js';
|
|
import { MoveDetail } from './views/MoveDetail.js';
|
|
import { ItemDetail } from './views/ItemDetail.js';
|
|
import { AbilityDetail } from './views/AbilityDetail.js';
|
|
import { TeamView } from './views/TeamView.js';
|
|
import { NaturesView } from './views/NaturesView.js';
|
|
import { TypeChartView } from './views/TypeChartView.js';
|
|
import { CompareView } from './views/CompareView.js';
|
|
import { ProgressView } from './views/ProgressView.js';
|
|
import { BreedingView } from './views/BreedingView.js';
|
|
import { ShinyView } from './views/ShinyView.js';
|
|
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
|
import { prefersReducedMotion } from './store/settings.js';
|
|
|
|
const routes = [
|
|
{ pattern: /^#?\/?$/, view: () => DexGrid() },
|
|
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton },
|
|
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton },
|
|
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton },
|
|
{ pattern: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton },
|
|
{ pattern: /^#\/team$/, view: () => TeamView() },
|
|
{ pattern: /^#\/natures$/, view: () => NaturesView() },
|
|
{ pattern: /^#\/types$/, view: () => TypeChartView() },
|
|
{ pattern: /^#\/compare$/, view: () => CompareView() },
|
|
{ pattern: /^#\/progress$/, view: () => ProgressView() },
|
|
{ pattern: /^#\/breeding$/, view: () => BreedingView() },
|
|
{ pattern: /^#\/shiny$/, view: () => ShinyView() },
|
|
{ pattern: /^#\/search$/, view: () => SearchView() },
|
|
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
|
];
|
|
|
|
function resolve(hash) {
|
|
for (const route of routes) {
|
|
const match = hash.match(route.pattern);
|
|
if (match) return { route, match };
|
|
}
|
|
return { route: routes[0], match: [] };
|
|
}
|
|
|
|
const canAnimate = () =>
|
|
typeof document.startViewTransition === 'function' && !prefersReducedMotion();
|
|
|
|
let rerenderCurrent = null;
|
|
|
|
/** Re-run the active route (e.g. after the selected game changed). */
|
|
export function rerender() {
|
|
if (rerenderCurrent) rerenderCurrent();
|
|
}
|
|
|
|
export function initRouter(host) {
|
|
let current = null;
|
|
|
|
function mount(node, animate) {
|
|
if (current) current.dispatchEvent(new CustomEvent('view:teardown'));
|
|
current = node;
|
|
if (animate && canAnimate()) {
|
|
const t = document.startViewTransition(() => host.replaceChildren(node));
|
|
// A fast follow-up navigation skips the in-flight transition; that's
|
|
// expected — swallow the AbortError from every promise it exposes.
|
|
t?.ready?.catch(() => {});
|
|
t?.finished?.catch(() => {});
|
|
t?.updateCallbackDone?.catch(() => {});
|
|
} else {
|
|
host.replaceChildren(node);
|
|
}
|
|
}
|
|
|
|
async function render() {
|
|
const hash = location.hash || '#/';
|
|
const { route, match } = resolve(hash);
|
|
|
|
// Instant feedback: a shaped skeleton for detail pages, a light
|
|
// placeholder otherwise. Detail skeletons animate in so the tapped
|
|
// card's sprite can morph into the hero (shared-element transition).
|
|
const isPokemon = /^#\/pokemon\/\d+$/.test(hash);
|
|
mount(
|
|
route.skeleton
|
|
? route.skeleton(match)
|
|
: el('div', { class: 'view view--loading' }, 'Loading…'),
|
|
isPokemon,
|
|
);
|
|
window.scrollTo(0, 0);
|
|
|
|
try {
|
|
const node = await route.view(match);
|
|
if ((location.hash || '#/') !== hash) return; // superseded
|
|
// Skeleton/placeholder -> content morphs via the View Transitions API.
|
|
mount(node, true);
|
|
} catch (err) {
|
|
console.error(err);
|
|
mount(
|
|
el(
|
|
'div',
|
|
{ class: 'view view--error' },
|
|
el('h1', {}, 'Something went wrong'),
|
|
el('p', {}, err.message || String(err)),
|
|
el('a', { href: '#/', class: 'button' }, 'Back to the dex'),
|
|
),
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
|
|
rerenderCurrent = render;
|
|
window.addEventListener('hashchange', render);
|
|
render();
|
|
}
|