dex/src/views/SearchView.js
chris 3193b167ac Search moves: hide "HMs only" for games without HMs
Gen 8+ games (Sword/Shield, Scarlet/Violet, Legends Arceus) dropped HMs
entirely, so "HMs only" there just zeroed the list with no way to see
why. buildFilters now checks whether the selected game actually has any
TM / HM machines and only renders the checkbox that applies; a filter
left stuck on from a previous game is cleared (and the "by number" sort
reverts) when it no longer has a control. SearchView also re-runs its
filters + results when the version group changes, so switching games
from the Games sheet updates it live.

"TMs only" + "HMs only" together already show the union (verified: 58 in
FRLG = 50 TMs + 8 HMs, ordered TM01…HM08).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-08 12:26:52 -04:00

421 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { entry } from '../store/selection.js';
import { prettify } from '../data/pokedex-resolver.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
const DMG = { physical: 'Phys', special: 'Spec', status: 'Stat' };
const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
];
const itemSprite = (name) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
const TABS = [
{ id: 'pokemon', label: 'Pokémon' },
{ id: 'moves', label: 'Moves' },
{ id: 'items', label: 'Items' },
{ id: 'abilities', label: 'Abilities' },
];
/** Numeric sort key from a TM/HM/TR label like "TM25": TMs, then HMs, then TRs. */
const tmSortKey = (label) => {
if (!label) return 1e9;
const m = String(label).match(/^([A-Z]+)0*(\d+)$/);
if (!m) return 1e9 - 1;
return (({ TM: 0, HM: 1, TR: 2 })[m[1]] ?? 3) * 1000 + Number(m[2]);
};
export async function SearchView() {
const snap = await loadSnapshot();
const view = el('section', { class: 'view search' });
let tab = TABS.some((t) => t.id === ui.get().searchTab) ? ui.get().searchTab : 'pokemon';
let query = ui.get().searchQuery || '';
const input = el('input', {
class: 'search__input',
type: 'search',
value: query,
oninput: (e) => {
query = e.target.value;
ui.set({ searchQuery: query });
run();
},
});
const seg = el(
'div',
{ class: 'seg' },
...TABS.map((t) =>
el(
'button',
{
class: `seg__btn${t.id === tab ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
tab = t.id;
ui.set({ searchTab: t.id });
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab));
syncUI();
run();
},
},
t.label,
),
),
);
const filters = el('div', { class: 'lookup__filters' });
const note = el('p', { class: 'search__note' });
const results = el('div', { class: 'search__results' });
// ---- filter controls (rebuilt per tab) ---------------------------
function pill(label, options, value, onChange) {
return el(
'select',
{ class: 'feed-sort__select', onchange: (e) => onChange(e.target.value) },
...options.map(([v, t]) => el('option', { value: v, selected: v === value }, t)),
);
}
// "TMs only" / "HMs only" checkboxes. Toggling either rebuilds the filter
// row so the sort dropdown gains/loses "TM / HM number", and drops that
// sort if it's active once neither is on.
function machineToggle(key, label) {
return el(
'label',
{ class: 'lookup__check' },
el('input', {
type: 'checkbox',
checked: !!ui.get()[key],
onchange: (e) => {
ui.set({ [key]: e.target.checked });
if (!ui.get().mvTm && !ui.get().mvHm && ui.get().mvSort === 'tm') {
ui.set({ mvSort: 'name' });
}
buildFilters();
run();
},
}),
label,
);
}
function buildFilters() {
clear(filters);
if (tab === 'moves') {
const vg = settings.get().versionGroup;
const gameHasKind = (kind) =>
snap.moves.some((m) =>
m.machines.some(
(x) => (vg === 'all' || x.vg === vg) && (x.tm || '').startsWith(kind),
),
);
const hasTm = gameHasKind('TM');
const hasHm = gameHasKind('HM'); // false for Gen 8+ games, which dropped HMs
// A machine filter left on for a game that has no such machines would
// silently zero the list with no visible checkbox to switch it off.
if ((!hasTm && ui.get().mvTm) || (!hasHm && ui.get().mvHm)) {
ui.set({ mvTm: hasTm && ui.get().mvTm, mvHm: hasHm && ui.get().mvHm });
if (!ui.get().mvTm && !ui.get().mvHm && ui.get().mvSort === 'tm') {
ui.set({ mvSort: 'name' });
}
}
filters.append(
...[
pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => {
ui.set({ mvType: v });
run();
}),
pill(
'mvClass',
[['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']],
ui.get().mvClass,
(v) => {
ui.set({ mvClass: v });
run();
},
),
pill(
'mvSort',
[
['name', 'AZ'],
['power', 'Power'],
['accuracy', 'Accuracy'],
['gen', 'Newest'],
// "By number" only makes sense with a TM/HM filter on.
...(ui.get().mvTm || ui.get().mvHm ? [['tm', 'TM / HM number']] : []),
],
ui.get().mvSort,
(v) => {
ui.set({ mvSort: v });
run();
},
),
hasTm ? machineToggle('mvTm', 'TMs only') : null,
hasHm ? machineToggle('mvHm', 'HMs only') : null,
].filter(Boolean),
);
} else if (tab === 'items') {
filters.append(
pill(
'itCat',
[['', 'Any category'], ...snap.itemCategories.map((c) => [c, prettify(c)])],
ui.get().itCat,
(v) => {
ui.set({ itCat: v });
run();
},
),
pill('itSort', [['name', 'AZ'], ['id', 'Dex order']], ui.get().itSort, (v) => {
ui.set({ itSort: v });
run();
}),
);
} else if (tab === 'abilities') {
filters.append(
pill(
'abSort',
[['name', 'AZ'], ['count', 'Most Pokémon'], ['gen', 'Newest']],
ui.get().abSort,
(v) => {
ui.set({ abSort: v });
run();
},
),
);
}
}
function syncUI() {
input.placeholder =
tab === 'pokemon'
? 'Name, number or type…'
: tab === 'moves'
? 'Move name…'
: tab === 'items'
? 'Item name…'
: 'Ability name or effect…';
buildFilters();
}
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Search'), note),
seg,
filters,
input,
results,
);
syncUI();
// ---- Pokémon -------------------------------------------------
function renderPokemon(q) {
const style = settings.get().spriteStyle;
const matches = snap.species
.filter(
(s) =>
loose(s.name).includes(q) ||
String(s.id) === q ||
(s.types || []).some((t) => t === q),
)
.slice(0, 60);
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
for (const s of matches) {
const e = entry(s.id);
results.append(
el(
'a',
{ class: `search__row${e.caught ? ' is-caught' : ''}`, href: `#/pokemon/${s.id}` },
Sprite(s.id, { style, alt: s.name, size: 52 }),
el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`),
el('span', { class: 'search__name' }, s.name.replace(/-/g, ' ')),
el('span', { class: 'search__types' }, ...(s.types || []).map(TypeChip)),
),
);
}
}
// ---- Moves (from snapshot, offline) -----------------------------
function renderMoves(q) {
const st = settings.get();
const vg = st.versionGroup;
const type = ui.get().mvType;
const cls = ui.get().mvClass;
const sort = ui.get().mvSort;
const tmOnly = !!ui.get().mvTm;
const hmOnly = !!ui.get().mvHm;
const machineFilter = tmOnly || hmOnly;
const wantKinds = [tmOnly && 'TM', hmOnly && 'HM'].filter(Boolean);
const kindOf = (x) => (x.tm || '').slice(0, 2); // TM | HM | TR
// The machine entry to show / sort / filter by. For a specific game
// it's that game's entry; for National ("all") — where a move can be a
// TM in one game and an HM in another — it's the newest entry of a kind
// the filter is asking for. TM/HM numbers are baked into the snapshot.
const machineFor = (m) => {
if (vg !== 'all') return m.machines.find((x) => x.vg === vg) || null;
if (wantKinds.length) {
return [...m.machines].reverse().find((x) => wantKinds.includes(kindOf(x))) || null;
}
return m.machines[m.machines.length - 1] || null;
};
let list = snap.moves.filter((m) => {
if (q && !loose(m.name).includes(q)) return false;
if (type && m.type !== type) return false;
if (cls && m.damageClass !== cls) return false;
if (machineFilter) {
const e = machineFor(m);
if (!e) return false;
// machineFor already restricted the kind for National; for a
// specific game, check the game's own entry is the right kind.
if (vg !== 'all' && !wantKinds.includes(kindOf(e))) return false;
}
return true;
});
list.sort((a, b) => {
if (sort === 'power') return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name);
if (sort === 'accuracy') return (b.accuracy ?? -1) - (a.accuracy ?? -1) || a.name.localeCompare(b.name);
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
if (sort === 'tm') {
return (
tmSortKey(machineFor(a)?.tm) - tmSortKey(machineFor(b)?.tm) ||
a.name.localeCompare(b.name)
);
}
return a.name.localeCompare(b.name);
});
note.textContent = `${list.length} move${list.length === 1 ? '' : 's'}`;
const shown = list.slice(0, 400);
for (const m of shown) {
const tmCell = machineFilter
? el('span', { class: 'moverow__tm' }, machineFor(m)?.tm || '')
: null;
results.append(
el(
'a',
{ class: 'search__row search__row--slim', href: `#/move/${m.id}` },
tmCell,
el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')),
el(
'span',
{ class: 'search__types' },
TypeChip(m.type),
el('span', { class: 'moverow__cat', dataset: { cat: m.damageClass || '' } }, DMG[m.damageClass] || '—'),
el('span', { class: 'search__num' }, m.power != null ? `${m.power} pw` : ''),
),
),
);
}
if (list.length > shown.length) {
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`));
}
}
// ---- Items (from snapshot, offline) ---------------------------
function renderItems(q) {
const cat = ui.get().itCat;
const sort = ui.get().itSort;
let list = snap.items.filter((it) => {
if (q && !loose(it.name).includes(q)) return false;
if (cat && it.category !== cat) return false;
return true;
});
list.sort((a, b) => (sort === 'id' ? a.id - b.id : a.name.localeCompare(b.name)));
note.textContent = `${list.length} item${list.length === 1 ? '' : 's'}`;
const shown = list.slice(0, 400);
for (const it of shown) {
const icon = el('img', { class: 'search__item-icon', loading: 'lazy', alt: '', src: itemSprite(it.name) });
icon.addEventListener('error', () => icon.remove(), { once: true });
results.append(
el(
'a',
{ class: 'search__row', href: `#/item/${it.id}` },
icon,
el('span', { class: 'search__name' }, it.name.replace(/-/g, ' ')),
el('span', { class: 'search__cat' }, prettify(it.category)),
),
);
}
if (list.length > shown.length) {
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`));
}
}
// ---- Abilities (from snapshot, offline) ---------------------
function renderAbilities(q) {
const sort = ui.get().abSort;
let list = snap.abilities.filter(
(a) => !q || loose(a.name).includes(q) || a.effect.toLowerCase().includes(q),
);
list.sort((a, b) => {
if (sort === 'count') return b.pokemon.length - a.pokemon.length || a.name.localeCompare(b.name);
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
return a.name.localeCompare(b.name);
});
note.textContent = `${list.length} abilit${list.length === 1 ? 'y' : 'ies'}`;
for (const a of list.slice(0, 400)) {
results.append(
el(
'a',
{ class: 'search__row search__row--ability', href: `#/ability/${a.id}` },
el('span', { class: 'search__name' }, a.name.replace(/-/g, ' ')),
el('span', { class: 'search__cat' }, `Gen ${a.generation}`),
el('span', { class: 'search__cat' }, `${a.pokemon.length} 🐾`),
el('span', { class: 'search__ab-eff' }, a.effect || '—'),
),
);
}
}
function run() {
const q = loose(query);
clear(results);
note.textContent = '';
if (tab === 'pokemon') {
const st = settings.get();
note.textContent = `Every Pokémon, any game. Opens for ${
st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup)
}.`;
if (q) renderPokemon(q);
return;
}
if (tab === 'moves') renderMoves(q);
else if (tab === 'items') renderItems(q);
else renderAbilities(q);
}
run();
requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0));
// Switching games (from the Games sheet) changes which TM/HM filters
// apply and the numbers shown — refresh the filter row and results.
let lastVg = settings.get().versionGroup;
const offSettings = settings.subscribe((s) => {
if (s.versionGroup === lastVg) return;
lastVg = s.versionGroup;
syncUI();
run();
});
onTeardown(view, () => {
offSettings();
ui.set({ searchScroll: window.scrollY });
});
return view;
}