dex/src/router.js
chris a437f539fc Animation pass: shared-element morph, sliding tabs, count-ups
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
2026-09-10 09:22:08 -04:00

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();
}