From e6599c4af8594e2300a96e524462b5cc743b4f6f Mon Sep 17 00:00:00 2001 From: Guangcong Luo Date: Mon, 29 Jun 2026 10:40:30 +0000 Subject: [PATCH] Preact: Replace teambuilder Wizard with Form This replaces the old Preact Teambuilder Wizard with a new Preact Teambuilder Form (which looks/works much more like the oldclient teambuilder). I admit defeat here. I made the Wizard with buttons rather than text boxes because I thought it was the only way to fit everything into a tiny vertical space. But I got so many complaints about how the buttons were worse that I gave text boxes another try, and I managed to fit them in with only around two more pixels of height per set than the Wizard. And the text boxes are so much nicer in every other way that it completely eliminates any reason to use the Wizard. I've retained all of the standard Preact client niceties: - scales down to mobile - fits 6 pokemon vertically on a 1440x900-equivalent screen - i.e. a 13" MacBook Air at default resolution - a move multiselect mode (accessible by clicking the "Moves" label) - supports >4 moves - you might think this isn't useful for normal teambuilding, but it does let you add moves before removing them, if you want to be indecisive and come back later - sample sets and box sets importer, right after selecting species And added some new ones: - type-color tints! these look surprisingly way nicer than you'd expect - new type icons - new gender icons - a modern dropdown for Tera types - animation when going from list view to editing mode - scroll position matching when leaving editing mode - pressing Esc will now reliably leave editing mode - in general, keyboard behavior should be more predictable --- play.pokemonshowdown.com/js/lib/preact.d.ts | 6 +- .../src/battle-team-editor.tsx | 1508 ++++++++++++++--- .../src/panel-teambuilder-team.tsx | 7 +- play.pokemonshowdown.com/src/panels.tsx | 8 +- play.pokemonshowdown.com/style/battle-log.css | 47 +- play.pokemonshowdown.com/style/sim-types.css | 152 ++ .../style/teambuilder.css | 212 ++- 7 files changed, 1698 insertions(+), 242 deletions(-) diff --git a/play.pokemonshowdown.com/js/lib/preact.d.ts b/play.pokemonshowdown.com/js/lib/preact.d.ts index 841a78a74..558a20877 100644 --- a/play.pokemonshowdown.com/js/lib/preact.d.ts +++ b/play.pokemonshowdown.com/js/lib/preact.d.ts @@ -4,8 +4,8 @@ export as namespace preact; declare namespace preact { type Key = string | number; type Ref = (instance: T) => void; - type ComponentChild = VNode | string | number | null; - type ComponentChildren = ComponentChild[] | ComponentChild | object | string | number | null; + type ComponentChild = VNode | string | number | null | false; + type ComponentChildren = ComponentChild[] | ComponentChild | object | string | number | null | false; /** * @deprecated @@ -716,6 +716,7 @@ declare global { type?: string; useMap?: string; value?: string | string[] | number; + defaultValue?: string | string[] | number; width?: number | string; wmode?: string; wrap?: string; @@ -821,6 +822,7 @@ declare global { script: HTMLAttributes; section: HTMLAttributes; select: HTMLAttributes; + selectedcontent: {}; slot: HTMLAttributes; small: HTMLAttributes; source: HTMLAttributes; diff --git a/play.pokemonshowdown.com/src/battle-team-editor.tsx b/play.pokemonshowdown.com/src/battle-team-editor.tsx index 6f7e220bc..6745b2e9a 100644 --- a/play.pokemonshowdown.com/src/battle-team-editor.tsx +++ b/play.pokemonshowdown.com/src/battle-team-editor.tsx @@ -18,8 +18,18 @@ import { PSModel } from "./client-core"; import { Net } from "./client-connection"; import { PSIcon } from "./panels"; -type SelectionType = 'pokemon' | 'ability' | 'item' | 'move' | 'stats' | 'details'; +type InnerFocusType = 'pokemon' | 'ability' | 'item' | 'move' | 'stats' | 'details' | 'import'; +type TeamEditorMode = 'wizard' | 'form' | 'import'; +interface FocusState { + setIndex: number; + type: InnerFocusType | 'nickname'; + /** -1 means no specific slot is focused; other values used only for move */ + typeIndex: number; +}; +interface InnerFocusState extends FocusState { + type: InnerFocusType; +} type SampleSets = { [speciesName: string]: { [setName: string]: Dex.PokemonSet, @@ -55,14 +65,7 @@ export class TeamEditorState extends PSModel { searchIndex = 0; originalSpecies: string | null = null; narrow = false; - selectionTypeOrder: readonly SelectionType[] = [ - 'pokemon', 'ability', 'item', 'move', 'stats', 'details', - ]; - innerFocus: { - setIndex: number, - type: SelectionType, - typeIndex?: number, - } | null = null; + innerFocus: InnerFocusState | null = null; isLetsGo = false; isNatDex = false; isBDSP = false; @@ -72,6 +75,7 @@ export class TeamEditorState extends PSModel { defaultLevel = 100; readonly = false; fetching = false; + handleParentKeyDown?: (ev: KeyboardEvent) => boolean | void; private userSetsCache: Record = {}; constructor(team: Team) { super(); @@ -127,13 +131,30 @@ export class TeamEditorState extends PSModel { this.defaultLevel = 5; } } - setSearchType(type: SearchType, i: number, value?: string) { + stringifyFocus(focus: FocusState | null): string { + if (!focus) return ''; + return `set-${focus.setIndex}-${focus.type}${focus.typeIndex >= 0 ? `-${focus.typeIndex}` : ''}`; + } + parseFocus(value: null): null; + parseFocus(value: string): FocusState; + parseFocus(value: string | null): FocusState | null; + parseFocus(value: string | null): FocusState | null { + if (!value) return null; + const match = value.split('-'); + const type = match[2] as InnerFocusType; + return { + setIndex: parseInt(match[1]), + type, + typeIndex: match[3] ? parseInt(match[3]) : -1, + }; + } + setSearchType(type: SearchType, i: number, value?: string, typeIndex = -1) { const set = this.sets[i]; this.search.setType(type, this.format, set); this.originalSpecies = null; this.search.prependResults = null; if (type === 'move') { - this.search.prependResults = this.getSearchMoves(set); + this.search.prependResults = this.getSearchMoves(set, typeIndex); if (value && this.search.prependResults.some(row => row[1].split('_')[2] === toID(value))) { value = ''; } @@ -168,9 +189,9 @@ export class TeamEditorState extends PSModel { this.search.find(value || ''); this.searchIndex = this.search.results?.[0]?.[0] === 'header' ? 1 : 0; } - updateSearchMoves(set: Dex.PokemonSet) { + updateSearchMoves(set: Dex.PokemonSet, typeIndex = -1) { let oldResultsLength = this.search.prependResults?.length || 0; - this.search.prependResults = this.getSearchMoves(set); + this.search.prependResults = this.getSearchMoves(set, typeIndex); this.searchIndex += this.search.prependResults.length - oldResultsLength; if (this.searchIndex < 0) this.searchIndex = 0; this.search.results = null; @@ -180,9 +201,11 @@ export class TeamEditorState extends PSModel { this.search.find(''); } } - getSearchMoves(set: Dex.PokemonSet) { + getSearchMoves(set: Dex.PokemonSet, typeIndex = -1) { const out: SearchRow[] = []; - for (let i = 0; i < Math.max(set.moves.length, 4); i++) { + const start = typeIndex >= 0 ? typeIndex : 0; + const end = typeIndex >= 0 ? typeIndex + 1 : Math.max(set.moves.length, 4); + for (let i = start; i < end; i++) { out.push(['move', `_${i + 1}_${toID(set.moves[i] || '')}` as ID]); } return out; @@ -878,6 +901,39 @@ export class TeamEditorState extends PSModel { if (Object.keys(cachedSets).length === 0) return null; return cachedSets[set.species] || {}; } + loadSampleSet(setIndex: number, setName: string) { + if (this.readonly) return false; + const set = this.sets[setIndex]; + if (!set?.species) return false; + + const data = TeamEditorState.sampleSets?.[this.format]; + const sid = toID(set.species); + const setTemplate = data?.dex?.[set.species]?.[setName] ?? data?.dex?.[sid]?.[setName] ?? + data?.stats?.[set.species]?.[setName] ?? data?.stats?.[sid]?.[setName]; + if (!setTemplate) return false; + + const applied: Partial = JSON.parse(JSON.stringify(setTemplate)); + Object.assign(set, applied); + + this.save(); + return true; + } + loadUserSet(setIndex: number, setName: string) { + if (this.readonly) return false; + const set = this.sets[setIndex]; + if (!set?.species) return false; + + const userSets = this.getUserSets(set); + const setTemplate = userSets?.[setName]; + if (!setTemplate) return false; + + const applied: Partial = JSON.parse(JSON.stringify(setTemplate)); + delete applied.name; + Object.assign(set, applied); + + this.save(); + return true; + } static renderClipboard(cancelClipboard: () => void) { if (!TeamEditorState.clipboard) return null; @@ -914,13 +970,13 @@ export class TeamEditorState extends PSModel { export class TeamEditor extends preact.Component<{ team: Team, narrow?: boolean, onChange?: () => void, readOnly?: boolean, children?: preact.ComponentChildren, resources?: preact.ComponentChildren, + editorRef?: (editor: TeamEditorState) => void, }> { - wizard = true; + mode: TeamEditorMode = 'form'; editor!: TeamEditorState; setTab = (ev: Event) => { const target = ev.currentTarget as HTMLButtonElement; - const wizard = target.value === 'wizard'; - this.wizard = wizard; + this.mode = target.value as TeamEditorMode; this.forceUpdate(); }; static probablyMobile() { @@ -974,27 +1030,33 @@ export class TeamEditor extends preact.Component<{ this.editor.subscribe(() => { this.forceUpdate(); }); + this.props.editorRef?.(this.editor); } const editor = this.editor; window.editor = editor; // debug editor.updateTeam(!!this.props.readOnly); - editor.narrow = this.props.narrow ?? document.body.offsetWidth < 500; + editor.narrow = this.props.narrow ?? window.innerWidth < 500; if (this.props.team.format !== editor.format) { editor.setFormat(this.props.team.format); } return
    -
  • -
  • */} +
{TeamEditorState.renderClipboard(this.cancelClipboard)} - {this.wizard ? ( + {this.mode === 'wizard' ? ( + ) : this.mode === 'form' ? ( + ) : ( )} @@ -1032,14 +1094,14 @@ class TeamTextbox extends preact.Component<{ windowing = true; selection: { setIndex: number, - type: SelectionType | null, + type: InnerFocusType | null, typeIndex: number, lineRange: [number, number] | null, } | null = null; innerFocus: { offsetY: number | null, setIndex: number, - type: SelectionType, + type: InnerFocusType, /** i.e. which move is this */ typeIndex: number, range: [number, number], @@ -1231,7 +1293,7 @@ class TeamTextbox extends preact.Component<{ } return false; }; - updateText = (noTextChange?: boolean, autoSelect?: boolean | SelectionType) => { + updateText = (noTextChange?: boolean, autoSelect?: boolean | InnerFocusType) => { const textbox = this.textbox; let value = textbox.value; let selectionStart = textbox.selectionStart || 0; @@ -1272,6 +1334,7 @@ class TeamTextbox extends preact.Component<{ let index = 0; /** for the set we're currently parsing */ let setIndex: number | null = null; + let moveIndex = 0; let nextSetIndex = 0; if (!noTextChange) this.setInfo = []; this.selection = null; @@ -1283,6 +1346,7 @@ class TeamTextbox extends preact.Component<{ if (!line.trim()) { setIndex = null; + moveIndex = 0; index = nlIndex + 1; continue; } @@ -1311,6 +1375,7 @@ class TeamTextbox extends preact.Component<{ }); } setIndex = nextSetIndex; + moveIndex = 0; nextSetIndex++; } @@ -1318,13 +1383,15 @@ class TeamTextbox extends preact.Component<{ let start = index, end = index + line.length; if (index <= selectionStart && selectionEnd <= selectionEndCutoff) { // both ends within range - let type: SelectionType | null = null; + let type: InnerFocusType | null = null; const lcLine = line.toLowerCase().trim(); + let typeIndex = -1; if (lcLine.startsWith('ability:')) { type = 'ability'; } else if (lcLine.startsWith('-')) { type = 'move'; + typeIndex = moveIndex; } else if ( !lcLine || lcLine.startsWith('level:') || lcLine.startsWith('gender:') || (lcLine + ':').startsWith('shiny:') || (lcLine + ':').startsWith('gigantamax:') || @@ -1354,11 +1421,12 @@ class TeamTextbox extends preact.Component<{ if (typeof autoSelect === 'string') autoSelect = autoSelect === type; this.selection = { - setIndex, type, lineRange: [start, end], typeIndex: 0, + setIndex, type, lineRange: [start, end], typeIndex, }; if (autoSelect) this.engageFocus(); } + if (line.trim().startsWith('-')) moveIndex++; index = nlIndex + 1; } if (!noTextChange) { @@ -1397,13 +1465,13 @@ class TeamTextbox extends preact.Component<{ } this.innerFocus = focus; - if (focus.type === 'details' || focus.type === 'stats') { + if (focus.type === 'details' || focus.type === 'stats' || focus.type === 'import') { this.forceUpdate(); return; } const value = this.textbox.value.slice(focus.range[0], focus.range[1]); - editor.setSearchType(focus.type, focus.setIndex, value); + editor.setSearchType(focus.type, focus.setIndex, value, focus.typeIndex); this.resetScroll(); this.textbox.setSelectionRange(focus.range[0], focus.range[1]); this.forceUpdate(); @@ -1425,7 +1493,7 @@ class TeamTextbox extends preact.Component<{ } else if (!type) { this.changeSet(this.innerFocus!.type, ''); } else { - this.changeSet(type as SelectionType, name, moveSlot); + this.changeSet(type as InnerFocusType, name, moveSlot); } }; getSelectionTypeRange(): [number, number] | null { @@ -1501,7 +1569,7 @@ class TeamTextbox extends preact.Component<{ } return [start, end]; } - changeSet(type: SelectionType, name: string, moveSlot?: string) { + changeSet(type: InnerFocusType, name: string, moveSlot?: string) { const focus = this.innerFocus; if (!focus) return; @@ -1621,8 +1689,8 @@ class TeamTextbox extends preact.Component<{ this.engageFocus({ offsetY: null, setIndex: i, - type: target.name as SelectionType, - typeIndex: 0, + type: target.name as InnerFocusType, + typeIndex: -1, range: [0, 0], rangeEndChar: '', }); @@ -1638,7 +1706,7 @@ class TeamTextbox extends preact.Component<{ offsetY: this.getYAt(end, true), setIndex: this.setInfo.length, type: 'pokemon', - typeIndex: 0, + typeIndex: -1, range: [end, end], rangeEndChar: '@', }); @@ -1829,23 +1897,55 @@ class TeamTextbox extends preact.Component<{ class TeamWizard extends preact.Component<{ editor: TeamEditorState, onChange?: () => void, onUpdate: () => void, }> { + readonly isForm: boolean = false; setSearchBox: string | null = null; + focusAnimationStartLocation: { + rect: { left: number, top: number }, + } | null = null; windowing = true; + closeInnerFocus = (ev?: Event) => { + this.changeFocus(null); + ev?.preventDefault(); + }; + startFocusAnimation(source: Element | null) { + if (this.props.editor.innerFocus) return; + const setButton = source?.closest('.set-button'); + if (!setButton) return; + const rect = setButton.getBoundingClientRect(); + this.focusAnimationStartLocation = { + rect: { left: rect.left, top: rect.top }, + }; + } + finishFocusAnimation() { + const start = this.focusAnimationStartLocation; + if (!start) return; + this.focusAnimationStartLocation = null; + if (window.PS?.prefs.noanim || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return; + const setButton = this.base!.querySelector('.team-focus-editor .set-button'); + if (!setButton) return; + const rect = setButton.getBoundingClientRect(); + const dx = start.rect.left - rect.left; + const dy = start.rect.top - rect.top; + if (!dx && !dy) return; + setButton.animate?.([ + { transform: `translate(${dx}px, ${dy}px)` }, + { transform: 'translate(0, 0)' }, + ], { + duration: 250, + easing: 'cubic-bezier(.2, 0, .2, 1)', + }); + } setFocus = (ev: Event) => { const { editor } = this.props; if (editor.readonly) return; const target = ev.currentTarget as HTMLButtonElement; - const [rawType, i] = (target.value || '').split('|'); - const setIndex = parseInt(i); - const type = rawType as SelectionType; - if (!target.value || editor.innerFocus?.setIndex === setIndex && editor.innerFocus.type === type) { - this.changeFocus(null); + if (!target.value || editor.stringifyFocus(editor.innerFocus) === target.value) { + this.closeInnerFocus(ev); return; } - this.changeFocus({ - setIndex, - type, - }); + const focus = editor.parseFocus(target.value) as InnerFocusState; + this.startFocusAnimation(target); + this.changeFocus(focus); }; deleteSet = (ev: Event) => { const target = ev.currentTarget as HTMLButtonElement; @@ -1856,6 +1956,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex: editor.sets.length, type: 'pokemon', + typeIndex: -1, }); } this.handleSetChange(); @@ -1879,6 +1980,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex, type: 'pokemon', + typeIndex: -1, }); } this.handleSetChange(); @@ -1912,12 +2014,12 @@ class TeamWizard extends preact.Component<{ const set = editor.sets[focus.setIndex]; if (focus.type === 'details') { this.setSearchBox = set.name || ''; - } else if (focus.type !== 'stats') { + } else if (focus.type !== 'stats' && focus.type !== 'import') { let value; if (focus.type === 'pokemon') value = set?.species || ''; else if (focus.type === 'item') value = set.item; else if (focus.type === 'ability') value = set.ability; - editor.setSearchType(focus.type, focus.setIndex, value); + editor.setSearchType(focus.type, focus.setIndex, value, focus.typeIndex); this.resetScroll(); this.setSearchBox = value || ''; } @@ -1928,7 +2030,7 @@ class TeamWizard extends preact.Component<{ const sprite = Dex.getTeambuilderSprite(set, editor.dex); const spriteClass = set && Dex.getTeambuilderSpriteData(set, editor.dex).pixelated ? ' pixelated' : ''; if (!set) { - return
+ return
{editor.deletedSet ? ( @@ -1939,7 +2041,7 @@ class TeamWizard extends preact.Component<{ {!editor.isChampions && } )} @@ -3068,7 +4169,7 @@ class DetailsForm extends preact.Component<{ const genderTable = { 'M': "Male", 'F': "Female" }; if (gender === 'N') return 'Unknown'; return <> - {} + {} {genderTable[gender]} ; } @@ -3171,11 +4272,12 @@ class DetailsForm extends preact.Component<{ ) : ( )} diff --git a/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx b/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx index c64db5183..616b7bcf9 100644 --- a/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx +++ b/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx @@ -9,7 +9,7 @@ import { PS, PSRoom, type RoomOptions, type Team } from "./client-main"; import { PSPanelWrapper, PSRoomPanel } from "./panels"; import { toID, type ID } from "./battle-dex"; import { BattleLog } from "./battle-log"; -import { TeamEditor } from "./battle-team-editor"; +import { TeamEditor, type TeamEditorState } from "./battle-team-editor"; import { Net, PSLoginServer } from "./client-connection"; import { Teams } from "./battle-teams"; import { CopyableURLBox } from "./panel-chat"; @@ -20,6 +20,7 @@ class TeamRoom extends PSRoom { team!: Team; teamDeleted = false; forceReload = false; + editor?: TeamEditorState; override clientCommands = this.parseClientCommands({ 'validate'(target) { if (this.team.format.length <= 4) { @@ -37,6 +38,9 @@ class TeamRoom extends PSRoom { if (team) this.setFormat(team.format); this.load(); } + override onParentKeyDown = (e?: Event) => { + return this.editor?.handleParentKeyDown?.(e as KeyboardEvent); + }; getTeam() { const team = PS.teams.byKey[this.id.slice(5)] || null; this.teamDeleted = !team && (!!this.team || this.teamDeleted); @@ -265,6 +269,7 @@ class TeamPanel extends PSRoomPanel { { this.props.room.editor = editor; }} > {!!(team.packedTeam && team.format.length > 4) &&

diff --git a/play.pokemonshowdown.com/src/panels.tsx b/play.pokemonshowdown.com/src/panels.tsx index f0eb34b2e..a1b4130b9 100644 --- a/play.pokemonshowdown.com/src/panels.tsx +++ b/play.pokemonshowdown.com/src/panels.tsx @@ -1178,7 +1178,7 @@ export class ReconnectTimer extends preact.Component { export function PSIcon( props: { pokemon: string | Pokemon | ServerPokemon | Dex.PokemonSet | null } | { item: string | null } | { type: string, b?: boolean, new?: boolean, tera?: boolean } | - { category: string } + { category: string } | { gender: string } ) { if ('pokemon' in props) { return ; @@ -1216,5 +1216,11 @@ export function PSIcon( height="14" width="32" class="pixelated" style="vertical-align:middle" />; } + if ('gender' in props) { + return {props.gender}; + } return null!; } diff --git a/play.pokemonshowdown.com/style/battle-log.css b/play.pokemonshowdown.com/style/battle-log.css index 69105dc25..a2521e504 100644 --- a/play.pokemonshowdown.com/style/battle-log.css +++ b/play.pokemonshowdown.com/style/battle-log.css @@ -148,6 +148,34 @@ button:disabled { select.button { text-align: left; } +select.base-select, select.base-select::picker(select) { + appearance: base-select; +} +select.base-select { + width: fit-content; +} +select.base-select::picker-icon { + margin-left: 2px; + display: inline; +} +.base-select::picker(select) { + text-align: left; + background: #E1E8E8; + color: black; + border: 1px solid #999999; + + padding: 2px 4px; + border-radius: 6px; + box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #DDDDDD, 2px 2px 3px rgba(0,0,0,.2); +} +.dark .base-select::picker(select) { + background: #0D151E; + color: #DDD; + border-color: #34373b; + + box-shadow: 2px 2px 3px rgba(0,0,0,.5), inset 0.5px 1px 1px rgba(255, 255, 255, 0.5); +} + .button option, .button optgroup { background: #f6f6f6; @@ -483,7 +511,7 @@ pre.textbox { box-shadow: none; } -.option { +.option, .base-select option { background-color: transparent; border: 1px solid transparent; border-radius: 4px; @@ -494,24 +522,33 @@ pre.textbox { text-align: left; overflow: hidden; } -.option.sel, .option.cur { +.base-select option { + padding: 0 4px; +} +.option.sel, .option.cur, .base-select option:checked { border-color: #999999; } .option:hover, .option.sel:hover, -.option.cur:hover { +.option.cur:hover, +.base-select option:hover { border-color: #888888; background-color: #D5D5D5; color: black; } +.base-select option::checkmark { + display: none; +} -.dark .option { +.dark .option, .dark .base-select option { + background: transparent; color: #DDD; box-shadow: none; } .dark .option:hover, .dark .option.sel:hover, -.dark .option.cur:hover { +.dark .option.cur:hover, +.dark .base-select option:hover { border-color: #777777; background-color: rgba(100, 100, 100, 0.5); color: #FFFFFF; diff --git a/play.pokemonshowdown.com/style/sim-types.css b/play.pokemonshowdown.com/style/sim-types.css index 0b79dc97c..c75e108d1 100644 --- a/play.pokemonshowdown.com/style/sim-types.css +++ b/play.pokemonshowdown.com/style/sim-types.css @@ -505,6 +505,158 @@ .typetile-Stellar { background: linear-gradient(90deg, hsl(330, 66%, 58%), hsl(126, 79%, 45%) 40%, hsl(231, 98%, 65%)); } +.tint-Normal.tint-Normal { + background-color: #d3d7d8; + color: #535a58; +} +.tint-Fire.tint-Fire { + background-color: #ecc4c8; + color: #8a2730; +} +.tint-Fighting.tint-Fighting { + background-color: #f0cfad; + color: #8a4f00; +} +.tint-Water.tint-Water { + background-color: #c1d4f2; + color: #1d4f9c; +} +.tint-Flying.tint-Flying { + background-color: #c9dcf1; + color: #38618a; +} +.tint-Grass.tint-Grass { + background-color: #c4dcbd; + color: #2a6b21; +} +.tint-Poison.tint-Poison { + background-color: #d3c1e6; + color: #653090; +} +.tint-Electric.tint-Electric { + background-color: #ece1ac; + color: #735800; +} +.tint-Ground.tint-Ground { + background-color: #dbc7b7; + color: #704016; +} +.tint-Psychic.tint-Psychic { + background-color: #edc4d6; + color: #9b2f58; +} +.tint-Rock.tint-Rock { + background-color: #d8d4bd; + color: #6b633c; +} +.tint-Ice.tint-Ice { + background-color: #c2e4ed; + color: #247083; +} +.tint-Bug.tint-Bug { + background-color: #d2d9b1; + color: #5b6615; +} +.tint-Dragon.tint-Dragon { + background-color: #c5caee; + color: #3544a2; +} +.tint-Ghost.tint-Ghost { + background-color: #cec2d8; + color: #4f2e5f; +} +.tint-Dark.tint-Dark { + background-color: #cac3c9; + color: #49393a; +} +.tint-Steel.tint-Steel { + background-color: #c5dbe3; + color: #2f6b7a; +} +.tint-Fairy.tint-Fairy { + background-color: #ebc7eb; + color: #9a409a; +} +.tint-Stellar.tint-Stellar { + background-color: #d7d0e8; + color: #594b83; +} +.dark .tint-Normal.tint-Normal { + background-color: #575d5c; + color: #e4e8e8; +} +.dark .tint-Fire.tint-Fire { + background-color: #66383d; + color: #ffd6da; +} +.dark .tint-Fighting.tint-Fighting { + background-color: #674a2f; + color: #ffe0bd; +} +.dark .tint-Water.tint-Water { + background-color: #344f75; + color: #d8e6ff; +} +.dark .tint-Flying.tint-Flying { + background-color: #4d6070; + color: #deedff; +} +.dark .tint-Grass.tint-Grass { + background-color: #365536; + color: #dcf0d6; +} +.dark .tint-Poison.tint-Poison { + background-color: #513d68; + color: #eadcff; +} +.dark .tint-Electric.tint-Electric { + background-color: #615838; + color: #fff0b8; +} +.dark .tint-Ground.tint-Ground { + background-color: #5d4535; + color: #f0d8c7; +} +.dark .tint-Psychic.tint-Psychic { + background-color: #653b50; + color: #ffd9e8; +} +.dark .tint-Rock.tint-Rock { + background-color: #5b5747; + color: #eee8cf; +} +.dark .tint-Ice.tint-Ice { + background-color: #3f6470; + color: #d8f5ff; +} +.dark .tint-Bug.tint-Bug { + background-color: #555a34; + color: #eef5c9; +} +.dark .tint-Dragon.tint-Dragon { + background-color: #3f4771; + color: #dce0ff; +} +.dark .tint-Ghost.tint-Ghost { + background-color: #4c3a59; + color: #eadcff; +} +.dark .tint-Dark.tint-Dark { + background-color: #4a4148; + color: #eee2eb; +} +.dark .tint-Steel.tint-Steel { + background-color: #3f5d66; + color: #dceff5; +} +.dark .tint-Fairy.tint-Fairy { + background-color: #643f64; + color: #ffdfff; +} +.dark .tint-Stellar.tint-Stellar { + background-color: #514b62; + color: #e7ddff; +} .typeicon-Normal { background: scroll #9FA19F url(https://play.pokemonshowdown.com/sprites/typeicons/Normal.png) no-repeat 2px center / 16px; } diff --git a/play.pokemonshowdown.com/style/teambuilder.css b/play.pokemonshowdown.com/style/teambuilder.css index aa3327975..eb3c9c797 100644 --- a/play.pokemonshowdown.com/style/teambuilder.css +++ b/play.pokemonshowdown.com/style/teambuilder.css @@ -435,19 +435,22 @@ width: 52px; height: 100px; } */ -.setdetails .detailcell { +.setdetails .detailcell, .set-form .detailcell { padding: 2px 4px; display: block; font-size: 9pt; text-align: center; } +.set-form .detailcell { + padding-top: 1px; +} .setdetails .detailcell + .detailcell { border-top: 1px solid #CCC; } .dark .setdetails .detailcell + .detailcell { border-color: #555; } -.setdetails label { +.setdetails label, .set-form .detailcell label { float: none; display: block; font-weight: normal; @@ -468,11 +471,12 @@ display: block; height: 15px; } -.statrow label { +.statrow em { float: left; width: 20px; height: 5px; text-align: right; + font-style: normal; font-weight: normal; font-size: 7pt; padding: 0; @@ -491,17 +495,21 @@ border: 1px solid #888888; border-width: 0 1px 2px 0; } -.statrow em { +/* more specificity to outweigh `.label strong` */ +.statrow.statrow strong { float: left; font-size: 7pt; font-style: normal; + font-weight: normal; width: 22px; height: 5px; text-align: right; + margin: 0; } .statrow small { float: left; font-size: 7pt; + height: 5px; } .statrow .statrow-head em { color: #555555; @@ -534,7 +542,7 @@ you can't delete it by pressing Backspace */ color: transparent; } -/* button editor */ +/* wizard editor */ .set-button { position: relative; @@ -558,7 +566,7 @@ you can't delete it by pressing Backspace */ /* (there's no box-sizing:margin-box) */ margin: -1px 0 0 -1px; } -.set-button .button { +.set-wizard .button { display: block; margin: 0; width: 100%; @@ -568,7 +576,7 @@ you can't delete it by pressing Backspace */ overflow: hidden; padding: 3px; } -.tiny-layout .set-button .button, +.tiny-layout .set-wizard .button, .tiny-layout .set-button .label { font-size: 11px; } @@ -576,7 +584,10 @@ you can't delete it by pressing Backspace */ color: #2f4f6e; } .dark .set-button .label { - color: #a1b0be; + color: #adc0d2; +} +.set-button.set-form .label { + color: inherit; } .set-button .set-nickname { @@ -588,10 +599,127 @@ you can't delete it by pressing Backspace */ padding: 0 3px; } .tiny-layout .set-button .set-nickname { - width: 90px; + width: 23%; } -.set-button .sprite { +/* table editor mode */ + +.set-form table, .set-form :where(.set-nickname) { + background-color: #c8d3e9; + border-radius: 5px; +} +.dark .set-form table, .dark .set-form :where(.set-nickname) { + background-color: #3f4654; +} +.set-form table { + height: 105px; +} +.set-form .border-collapse { + margin: 0 3px 1px 0; +} +.set-form .moverow + .moverow { + margin-top: 3px; +} +.set-form .overfull .moverow + .moverow { + margin-top: -2px; +} +.set-form .overfull .moverow + .moverow .textbox { + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.set-form .overfull .moverow:not(:last-child) .textbox { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +.set-form .overfull .moverow .textbox, .set-form .overfull .moverow { + padding-top: 0; + padding-bottom: 0; + height: 18px; +} +.set-form .overfull5 .moverow .textbox, .set-form .overfull5 .moverow { + height: 19.4px; +} +.set-form td { + vertical-align: top; +} +.set-button.set-form .textbox { + height: 20px; + box-sizing: border-box; + width: 100%; +} +.set-button.set-form .set-details .textbox { + height: 34px; + padding: 1px; + overflow: hidden; +} +.set-button.set-form .set-stats .textbox { + height: 89px; +} + +.set-button .set-field.incomplete { + color: #CC3311; + border-color: #CC3311; +} +.set-button button.label { + display: block; + font-family: inherit; + margin: 0; + padding: 0; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} +.set-form .set-moves .label.cur, +.set-form .set-moves .label:focus-visible { + text-decoration: underline; + outline: 0; +} +.set-form .set-moves .button { + display: none; + padding: 0 3px; + font-size: 10px; + vertical-align: top; + margin-top: 0; +} +.set-form .set-moves .button.cur, +.set-form .set-moves .label:hover .button { + display: inline-block; +} +.set-button.set-form .label .textbox { + margin-top: 0; +} +.set-button.set-form .set-nickname .label { + font-size: 10px; +} +.set-button.set-form .set-nickname { + padding: 0; +} +.set-button.set-form .set-nickname span { + padding-left: 3px; +} +.set-button.set-form .set-details { + height: 69px; +} +.set-button.set-form .itemicon { + float: right; + margin-top: -12px; + margin-right: 2px; +} +/* .set-button.set-form .set-pokemon .label { + text-shadow: #c8d3e9 1px 1px 0, #c8d3e9 1px -1px 0, #c8d3e9 -1px 1px 0, #c8d3e9 -1px -1px 0; +} +.dark .set-button.set-form .set-pokemon .label { + text-shadow: #3f4654 1px 1px 0, #3f4654 1px -1px 0, #3f4654 -1px 1px 0, #3f4654 -1px -1px 0; +} */ +.set-button.set-form .set-pokemon .label span { + visibility: hidden; +} +.set-button.set-form .statrow { + height: 14px; +} + +.set-wizard .sprite { display: block; width: 100%; height: 97px; @@ -604,65 +732,86 @@ you can't delete it by pressing Backspace */ border-bottom-left-radius: 3px; background: linear-gradient(to bottom, transparent 0%, transparent 70%, #d3d3d3 100%); } -.set-button .cur .sprite-inner { +.set-wizard .cur .sprite-inner { background: linear-gradient(to bottom, transparent 0%, transparent 70%, #f8f8f8 100%); } -.dark .set-button .sprite-inner { +.dark .set-wizard .sprite-inner { background: linear-gradient(to bottom, transparent 0%, transparent 70%, #2b2c31 100%); } -.dark .set-button .cur .sprite-inner { +.dark .set-wizard .cur .sprite-inner { background: linear-gradient(to bottom, transparent 0%, transparent 70%, #555555 100%); } -.set-button .detailcell { +.set-form .sprite-inner { + padding-top: 69px; + padding-left: 0; + height: auto; + background: none; +} +.set-wizard .detailcell { float: left; height: 50px; } -.dark .set-button .button-first.cur, -.dark .set-button .button-middle.cur, -.dark .set-button .button-last.cur { +.set-form .detailcell { + float: left; + height: 27px; + margin-bottom: 1px; +} +.dark .set-wizard .button-first.cur, +.dark .set-wizard .button-middle.cur, +.dark .set-wizard .button-last.cur { box-shadow: none; } -.set-button .detailcell + .detailcell { +.set-wizard .detailcell + .detailcell, +.set-form .detailcell + .detailcell { border-left: 1px solid #888; margin-left: 20px; - padding-left: 3px; + padding-left: 4px; } -.tiny-layout .set-button .detailcell + .detailcell { - margin-left: 3px; +.set-form .detailcell + .detailcell { + margin-left: 2px; + padding-left: 6px; } -.set-button .set-pokemon .button { +.tiny-layout .detailcell + .detailcell { + margin-left: 0; + padding-left: 4px; +} +.set-wizard .set-pokemon .button { padding: 0; } -.set-button .set-ability .button, .set-button .set-item .button { +.set-wizard .set-ability .button, .set-wizard .set-item .button { height: 40px; } .set-item .itemicon { float: right; } -.set-button .set-moves .button { +.set-wizard .set-moves .button { line-height: 18px; } -.set-button .set-moves .button.overfull { +.set-wizard .set-moves .button.overfull { line-height: normal; } -.set-button .set-details .button { +.set-wizard .set-details .button { height: 61px; } -.set-button .set-stats, .set-button .set-stats .button { +.set-button .set-stats, .set-wizard .set-stats .button { width: 138px; } -.set-button .set-stats .button { +.set-wizard .set-stats .button { padding: 3px; } -.tiny-layout .set-button .set-stats, .tiny-layout .set-button .set-stats .button { +.tiny-layout .set-button .set-stats, .tiny-layout .set-button .set-stats button { width: 40px; } -.tiny-layout .set-button .set-stats label { +.tiny-layout .set-button .set-stats button { + padding-top: 1px; +} +.tiny-layout .set-button .set-stats em { display: none; } .tiny-layout .set-button .set-stats .statgraph { transform: scale(0.5); transform-origin: 0 0; + height: 4px; } .team-focus-editor { @@ -674,6 +823,9 @@ you can't delete it by pressing Backspace */ overflow: auto; -webkit-overflow-scrolling: touch; } +.team-focus-editor .set-button { + z-index: 1; +} .team-focus-editor .tabbar { /* note to self: make this scrollable later */ overflow: auto;

- @@ -2095,6 +2212,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex, type: reverse ? 'details' : 'ability', + typeIndex: -1, }); break; case 'ability': @@ -2103,6 +2221,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex, type: reverse ? 'pokemon' : 'item', + typeIndex: -1, }); break; case 'item': @@ -2110,46 +2229,11 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex, type: reverse ? 'ability' : 'move', + typeIndex: -1, }); break; case 'move': - if (slot) { - // intentional; we're _removing_ from the slot - const i = parseInt(slot) - 1; - if (set.moves[i]) { - set.moves[i] = ''; - // remove empty slots at the end - if (i === set.moves.length - 1) { - while (set.moves.length > 4 && !set.moves[set.moves.length - 1]) { - set.moves.pop(); - } - } - // if we have more than 4 moves, move the last move into the newly-cleared slot - if (set.moves.length > 4 && i < set.moves.length - 1) { - set.moves[i] = set.moves.pop()!; - } - } - } else if (set.moves.includes(name)) { - set.moves.splice(set.moves.indexOf(name), 1); - } else { - for (let i = 0; i < set.moves.length + 1; i++) { - if (!set.moves[i]) { - set.moves[i] = name; - break; - } - } - } - if (set.moves.length === 4 && set.moves.every(Boolean)) { - this.changeFocus({ - setIndex, - type: reverse ? 'item' : 'stats', - }); - } else { - if (editor.search.query) { - this.resetScroll(); - } - editor.updateSearchMoves(set); - } + this.selectMoveResult(name, slot, reverse); break; } editor.save(); @@ -2157,44 +2241,64 @@ class TeamWizard extends preact.Component<{ this.forceUpdate(); } }; - loadSampleSet = (setName: string) => { + selectMoveResult(name: string, slot?: string, reverse?: boolean) { const { editor } = this.props; const setIndex = editor.innerFocus!.setIndex; - const set = editor.sets[setIndex]; - if (!set?.species) return; - - const data = TeamEditorState.sampleSets?.[editor.format]; - const sid = toID(set.species); - const setTemplate = data?.dex?.[set.species]?.[setName] ?? data?.dex?.[sid]?.[setName] ?? - data?.stats?.[set.species]?.[setName] ?? data?.stats?.[sid]?.[setName]; - if (!setTemplate) return; - - const applied: Partial = JSON.parse(JSON.stringify(setTemplate)); - Object.assign(set, applied); - - editor.save(); + const set = (editor.sets[setIndex] ||= { species: '', moves: [] }); + if (slot) { + // intentional; we're _removing_ from the slot + const i = parseInt(slot) - 1; + if (set.moves[i]) { + set.moves[i] = ''; + // remove empty slots at the end + if (i === set.moves.length - 1) { + while (set.moves.length > 4 && !set.moves[set.moves.length - 1]) { + set.moves.pop(); + } + } + // if we have more than 4 moves, move the last move into the newly-cleared slot + if (set.moves.length > 4 && i < set.moves.length - 1) { + set.moves[i] = set.moves.pop()!; + } + } + } else if (set.moves.includes(name)) { + set.moves.splice(set.moves.indexOf(name), 1); + } else { + for (let i = 0; i < set.moves.length + 1; i++) { + if (!set.moves[i]) { + set.moves[i] = name; + break; + } + } + } + if (set.moves.length === 4 && set.moves.every(Boolean) && !this.isForm) { + this.changeFocus({ + setIndex, + type: reverse ? 'item' : 'stats', + typeIndex: -1, + }); + } else if (reverse) { + this.changeFocus({ + setIndex, + type: 'item', + typeIndex: -1, + }); + } else { + if (editor.search.query) { + this.resetScroll(); + } + editor.updateSearchMoves(set); + } + } + handleLoadSampleSet = (setName: string) => { + const { editor } = this.props; + if (!editor.innerFocus || !editor.loadSampleSet(editor.innerFocus.setIndex, setName)) return; this.props.onUpdate?.(); this.forceUpdate(); }; - handleLoadUserSet = (ev: Event) => { - const setName = (ev.target as HTMLButtonElement).value; - this.loadUserSet(setName); - }; - loadUserSet = (setName: string) => { + handleLoadUserSet = (setName: string) => { const { editor } = this.props; - const setIndex = editor.innerFocus!.setIndex; - const set = editor.sets[setIndex]; - if (!set?.species) return; - - const userSets = editor.getUserSets(set); - const setTemplate = userSets?.[setName]; - if (!setTemplate) return; - - const applied: Partial = JSON.parse(JSON.stringify(setTemplate)); - delete applied.name; - Object.assign(set, applied); - - editor.save(); + if (!editor.innerFocus || !editor.loadUserSet(editor.innerFocus.setIndex, setName)) return; this.props.onUpdate?.(); this.forceUpdate(); }; @@ -2272,6 +2376,7 @@ class TeamWizard extends preact.Component<{ this.changeFocus({ setIndex: editor.innerFocus.setIndex, type: ev.shiftKey ? 'item' : 'stats', + typeIndex: -1, }); } else { const [name, moveSlot] = value.split('|'); @@ -2308,6 +2413,7 @@ class TeamWizard extends preact.Component<{ } override componentDidUpdate() { + this.finishFocusAnimation(); const searchBox = this.base!.querySelector('input[name=value], input[name=nickname]'); if (this.setSearchBox !== null) { if (searchBox) { @@ -2332,77 +2438,63 @@ class TeamWizard extends preact.Component<{ const { type, setIndex } = editor.innerFocus; const set = this.props.editor.sets[setIndex] as Dex.PokemonSet | undefined; const cur = (i: number) => setIndex === i ? ' cur' : ''; - const sampleSets = type === 'ability' ? editor.getSampleSets(set!) : []; - const userSets = type === 'ability' ? editor.getUserSets(set!) : null; - return
-
    -
  • - {editor.sets.map((curSet, i) =>
  • )} - {editor.canAdd() &&
  • } -
-
{this.renderSet(set, setIndex)}
+ const isSearchMode = type !== 'stats' && type !== 'details' && type !== 'import'; + const SEARCH_PLACEHOLDERS = { + 'pokemon': 'Search species or filter by type, learnable moves, ability, or egg group', + 'ability': 'Search abilities', + 'item': 'Search items', + 'move': 'Search moves or filter by type or category', + }; + return
+
+
    +
  • + {editor.sets.map((curSet, i) =>
  • )} + {editor.canAdd() &&
  • } +
+
{this.renderSet(set, setIndex)}
+ {isSearchMode &&
+ + {PSSearchResults.renderFilters(editor.search)} +
} +
{type === 'stats' ? ( ) : type === 'details' ? ( + ) : type === 'import' ? ( + ) : ( -
-
- - {PSSearchResults.renderFilters(editor.search)} -
-
- - {sampleSets?.length !== 0 && ( -
-

Sample sets

- {sampleSets ? ( -
- {sampleSets.map(setName => <> - {} - )} -
- ) : ( -
Loading...
- )} -
- )} - {userSets !== null && ( -
-

Box sets

- {Object.keys(userSets).length > 0 ? ( -
- {Object.keys(userSets).map(setName => <> - {} - )} -
- ) : ( -
No {set!.species} sets found in boxes
- )} -
- )} -
+
+ + {type === 'ability' && }
)}
; @@ -2440,13 +2532,1003 @@ class TeamWizard extends preact.Component<{ this.renderSet(set, i), ])} {pasteControls(editor.sets.length)} - {editor.canAdd() &&

}
; } } +class TeamEditorForm extends TeamWizard { + override readonly isForm = true; + /** where to focus after next render */ + pendingFocus: TeamEditorState['innerFocus'] = null; + pendingFocusValue: string | null = null; + pendingFocusSelection: [number | null, number | null, 'forward' | 'backward' | 'none' | undefined] | null = null; + /** whether to focus the details/stats button or their panel contents */ + pendingFocusButton = false; + mouseDownTextbox: HTMLInputElement | null = null; + openFocusTextbox(target: HTMLInputElement) { + const { editor } = this.props; + if (editor.readonly) return; + const focus = editor.parseFocus(target.getAttribute('data-focus')) as InnerFocusState; + if (!focus) return; + + // calling .focus() to focus after innerfocusing does trigger this listener + if (this.pendingFocus) return; + + this.pendingFocusValue = target.value; + this.pendingFocusSelection = [ + target.selectionStart, target.selectionEnd, target.selectionDirection || undefined, + ]; + target.classList.remove('incomplete'); + this.startFocusAnimation(target); + this.changeFocus(focus); + } + setFocusTextbox = (ev: FocusEvent) => { + const target = ev.currentTarget as HTMLInputElement; + if (this.mouseDownTextbox === target) return; + this.openFocusTextbox(target); + }; + mouseDownField = (ev: MouseEvent) => { + if (ev.button !== 0) return; + const target = ev.currentTarget as HTMLInputElement; + if (document.activeElement === target) return; + this.mouseDownTextbox = target; + document.addEventListener('mouseup', this.mouseUpField, { once: true }); + target.focus(); + target.select(); + ev.preventDefault(); + }; + mouseUpField = (ev: MouseEvent) => { + const target = this.mouseDownTextbox; + this.mouseDownTextbox = null; + if (!target || ev.target !== target) return; + this.openFocusTextbox(target); + }; + override changeFocus(focus: TeamEditorState['innerFocus'], focusButton = false) { + const { editor } = this.props; + editor.innerFocus = focus; + this.pendingFocus = focus; + this.pendingFocusButton = focusButton; + if (!focus) { + this.props.onUpdate(); + return; + } + + const set = editor.sets[focus.setIndex]; + if (focus.type !== 'details' && focus.type !== 'stats' && focus.type !== 'import') { + let value = ''; + if (focus.type === 'pokemon') value = set?.species || ''; + else if (focus.type === 'item') value = set?.item || ''; + else if (focus.type === 'ability') value = set?.ability || ''; + else if (focus.type === 'move' && focus.typeIndex >= 0) value = set?.moves?.[focus.typeIndex] || ''; + editor.setSearchType(focus.type, focus.setIndex, value, focus.typeIndex); + this.resetScroll(); + } + this.props.onUpdate(); + } + override componentDidMount(): void { + this.props.editor.handleParentKeyDown = this.handleKeyDown; + } + override componentWillUnmount(): void { + this.props.editor.handleParentKeyDown = undefined; + } + override componentDidUpdate() { + this.finishFocusAnimation(); + const { editor } = this.props; + const focus = this.pendingFocus; + if (focus) { + const focusValue = editor.stringifyFocus(focus); + const input = this.base!.querySelector( + (focus.type === 'details' || focus.type === 'stats' || focus.type === 'import') && this.pendingFocusButton ? + `button[name="${focus.type}"][value="${focusValue}"]` : + focus.type === 'details' ? `div[aria-label=Details] input:not([name=nickname]), div[aria-label=Details] select` : + focus.type === 'stats' ? `div[aria-label=Stats] input` : + focus.type === 'import' ? `div[aria-label="Import/Export"] textarea` : + focus.type === 'move' && focus.typeIndex === -1 ? `input[name=value]` : + `input.set-field[data-focus="${focusValue}"]` + ); + if (input) { + if ( + focus.type !== 'details' && focus.type !== 'stats' && + focus.type !== 'import' && !(focus.type === 'move' && focus.typeIndex === -1) + ) { + input.value = this.pendingFocusValue ?? + this.getInputValue(focus.setIndex, focus.type, focus.typeIndex); + input.classList.remove('incomplete'); + } + input.focus(); + if (this.pendingFocusSelection && input instanceof HTMLInputElement) { + input.setSelectionRange?.(...this.pendingFocusSelection); + } else { + (input as HTMLInputElement).select?.(); + } + this.pendingFocus = null; + this.pendingFocusValue = null; + this.pendingFocusSelection = null; + } + } + const activeElement = document.activeElement; + for (const input of this.base!.querySelectorAll('input.set-field')) { + if (input === activeElement) continue; + const curFocus = editor.parseFocus(input.getAttribute('data-focus')!); + input.value = this.getInputValue(curFocus.setIndex, curFocus.type, curFocus.typeIndex); + } + const searchBox = this.base!.querySelector('input[name=value]'); + const filters = this.base!.querySelector('.dexlist-filters'); + if (searchBox) { + if (filters) { + const { width } = filters.getBoundingClientRect(); + searchBox.style.paddingLeft = `${width + 5}px`; + } else { + searchBox.style.paddingLeft = `3px`; + } + } + } + getInputValue(setIndex: number, type: InnerFocusType | 'nickname', typeIndex: number) { + const set = this.props.editor.sets[setIndex]; + if (!set) return ''; + switch (type) { + case 'pokemon': + return set.species || ''; + case 'item': + return set.item || ''; + case 'ability': + return set.ability || ''; + case 'move': + return set.moves[typeIndex] || ''; + case 'nickname': + return set.name || ''; + default: + return ''; + } + } + canonicalValue(type: InnerFocusType, value: string): string | null { + const { editor } = this.props; + if (!value.trim()) return ''; + + switch (type) { + case 'pokemon': { + const species = editor.dex.species.get(value); + return species.exists ? species.name : null; + } + case 'item': { + if (toID(value) === 'noitem') return ''; + const item = editor.dex.items.get(value); + return item.exists ? item.name : null; + } + case 'ability': { + if (toID(value) === 'noability') return ''; + const ability = editor.dex.abilities.get(value); + return ability.exists ? ability.name : null; + } + case 'move': { + const move = editor.dex.moves.get(value); + return move.exists ? move.name : null; + } + default: + return value; + } + } + commitField(target: HTMLInputElement, selectNext?: boolean, reverse?: boolean) { + const { editor } = this.props; + const focus = editor.parseFocus(target.getAttribute('data-focus')!); + if (!focus) return true; + + if (focus.type === 'nickname') { + const set = editor.sets[focus.setIndex]; + if (!set) return true; + const name = target.value.trim(); + if (name) { + set.name = name; + } else { + delete set.name; + } + editor.save(); + this.props.onChange?.(); + this.forceUpdate(); + return true; + } + + let canonical = this.canonicalValue(focus.type, target.value); + if (canonical === null) { + target.classList.add('incomplete'); + canonical = target.value; + } else { + target.classList.remove('incomplete'); + } + + if (focus.type === 'pokemon') { + if (!canonical) return true; + const set = (editor.sets[focus.setIndex] ||= { species: '', moves: [] }); + editor.changeSpecies(set, canonical); + target.value = set.species; + } else { + const set = editor.sets[focus.setIndex]; + if (!set) return true; + switch (focus.type) { + case 'item': + if (canonical) set.item = canonical; + else delete set.item; + target.value = canonical; + break; + case 'ability': + if (canonical) set.ability = canonical; + else delete set.ability; + target.value = canonical; + break; + case 'move': + if (focus.typeIndex >= set.moves.length && !canonical) return true; + while (set.moves.length <= focus.typeIndex) set.moves.push(''); + set.moves[focus.typeIndex] = canonical; + target.value = canonical; + break; + } + } + + editor.save(); + this.props.onChange?.(); + this.forceUpdate(); + if (selectNext) this.focusAdjacentField(focus, !!reverse); + return true; + } + inputField = (ev: Event) => { + const target = ev.currentTarget as HTMLInputElement; + target.classList.remove('incomplete'); + const type = target.name as InnerFocusType | 'nickname'; + if (type === 'nickname') { + this.commitField(target); + return; + } + let focus = this.props.editor.innerFocus; + if (!focus) { + this.openFocusTextbox(target); + focus = this.pendingFocus; + } + if (focus?.type === 'move' && focus.typeIndex >= 0 && !target.value) { + // blank out move + this.props.editor.search.prependResults = [['move', `_${focus.typeIndex + 1}_` as ID]]; + this.props.editor.search.results = null; + } else if (focus?.type === 'item' && !target.value) { + // blank out item + this.props.editor.search.prependResults = [['item', '' as ID]]; + this.props.editor.search.results = null; + } + this.props.editor.setSearchValue(target.value); + this.resetScroll(); + this.forceUpdate(); + }; + blurField = (ev: Event) => { + this.commitField(ev.currentTarget as HTMLInputElement); + }; + getFocusedSetField() { + const { editor } = this.props; + const focus = editor.innerFocus; + if (!focus || focus.type === 'details' || focus.type === 'stats' || focus.type === 'import') { + return null; + } + if (focus.type === 'move' && focus.typeIndex === -1) return null; + return this.base!.querySelector( + `input.set-field[data-focus="${editor.stringifyFocus(focus)}"]` + ); + } + focusFocusedSetField() { + const input = this.getFocusedSetField(); + if (!input) return false; + const focus = this.props.editor.parseFocus(input.getAttribute('data-focus')); + if (!focus) return false; + input.value = this.getInputValue(focus.setIndex, focus.type, focus.typeIndex); + input.classList.remove('incomplete'); + input.focus(); + input.select(); + return true; + } + clearSearchFilters() { + while (true) { + if (!this.props.editor.search.removeFilter()) return; + } + } + keyDownSearchInput(ev: KeyboardEvent, inSearchBox: boolean) { + const { editor } = this.props; + const input = ev.currentTarget as HTMLInputElement; + switch (ev.keyCode) { + case 8: // backspace + if (input.selectionStart === 0 && input.selectionEnd === 0) { + if (!editor.search.removeFilter() && inSearchBox && !input.value) { + if (this.focusFocusedSetField()) ev.preventDefault(); + break; + } + editor.setSearchValue(input.value); + this.resetScroll(); + this.forceUpdate(); + } + break; + case 27: // escape + if (inSearchBox) { + input.value = ''; + this.clearSearchFilters(); + editor.setSearchValue(''); + this.resetScroll(); + if (!this.focusFocusedSetField()) break; + this.forceUpdate(); + ev.preventDefault(); + ev.stopImmediatePropagation(); + } + break; + case 38: // up + editor.upSearchValue(); + this.scrollSelectedResult(); + this.forceUpdate(); + ev.preventDefault(); + break; + case 40: // down + editor.downSearchValue(); + this.scrollSelectedResult(); + this.forceUpdate(); + ev.preventDefault(); + break; + case 37: // left + case 39: // right + ev.stopImmediatePropagation(); + break; + case 13: // enter + case 9: { // tab + if (ev.keyCode === 9 && ev.shiftKey) { + this.commitField(input, true, true); + this.tryDeleteEmptyMoveSlot(input); + ev.preventDefault(); + return; + } + const value = editor.selectSearchValue(); + if (value === '' && input.value) { + // value not found + this.commitField(input, true); + } else if (value !== null) { + // selected a value + const [name, moveSlot] = value.split('|'); + if (editor.innerFocus?.type === 'move' && editor.innerFocus.typeIndex === -1) { + this.setMoveResult(name, moveSlot); + } else { + this.setFocusedValue(name, ev.shiftKey); + } + this.tryDeleteEmptyMoveSlot(input); + if (inSearchBox) input.value = ''; + } else { + // added a filter + if (inSearchBox) { + input.value = ''; + } else { + // restore focused input + const focus = editor.parseFocus(input.getAttribute('data-focus')); + if (focus) input.value = this.getInputValue(focus.setIndex, focus.type, focus.typeIndex); + input.classList.remove('incomplete'); + + // clear and focus search box + const searchBox = this.base!.querySelector('input[name=value]'); + if (searchBox) { + searchBox.value = ''; + searchBox.focus(); + } + } + editor.setSearchValue(''); + this.resetScroll(); + this.forceUpdate(); + } + ev.preventDefault(); + break; + } + } + } + tryDeleteEmptyMoveSlot(input: HTMLInputElement) { + if (input.value) return false; + const { editor } = this.props; + const focus = editor.parseFocus(input.getAttribute('data-focus')); + if (focus?.type !== 'move' || focus.typeIndex < 0) return false; + + const moves = editor.sets[focus.setIndex]?.moves; + if (!moves) return false; + if (moves[focus.typeIndex]) return false; + moves.splice(focus.typeIndex, 1); + if (editor?.innerFocus?.type === 'move' && editor.innerFocus.typeIndex > focus.typeIndex) { + editor.innerFocus.typeIndex--; + } + // easier than guarding against the blur handler clobbering moves + input.value = moves[focus.typeIndex] || ''; + this.forceUpdate(); + return true; + } + keyDownField = (ev: KeyboardEvent) => { + if (!this.props.editor.innerFocus && ev.keyCode === 9) { + const target = ev.currentTarget as HTMLInputElement; + const focus = this.props.editor.parseFocus(target.getAttribute('data-focus')!); + if (!this.commitField(target)) { + ev.preventDefault(); + return; + } + if (this.focusAdjacentField(focus, ev.shiftKey)) { + ev.preventDefault(); + } + return; + } + this.keyDownSearchInput(ev, false); + }; + keyDownNickname = (ev: KeyboardEvent) => { + if (ev.keyCode !== 9) return; + const target = ev.currentTarget as HTMLInputElement; + this.commitField(target); + const focus = this.props.editor.parseFocus(target.getAttribute('data-focus')!); + if (this.focusAdjacentField(focus, ev.shiftKey)) { + ev.preventDefault(); + } + }; + override keyDownSearch = (ev: KeyboardEvent) => { + this.keyDownSearchInput(ev, true); + }; + clickPanelButton = (ev: Event) => { + const { editor } = this.props; + if (editor.readonly) return; + const target = ev.currentTarget as HTMLButtonElement; + const focus = editor.parseFocus(target.value) as InnerFocusState; + if (editor.stringifyFocus(editor.innerFocus) === target.value) { + this.pendingFocus = focus; + this.pendingFocusButton = false; + this.forceUpdate(); + return; + } + this.startFocusAnimation(target); + this.changeFocus(focus, false); + }; + keyDownPanelButton = (ev: KeyboardEvent) => { + if (ev.keyCode !== 9) return; + const target = ev.currentTarget as HTMLButtonElement; + const focus = this.props.editor.parseFocus(target.value); + if (!focus) return; + this.focusAdjacentField(focus, ev.shiftKey); + ev.preventDefault(); + }; + override closeInnerFocus = (ev?: Event) => { + const focus = this.props.editor.innerFocus; + if (!focus) return; + const expectedTop = this.base!.querySelector( + '.team-focus-editor .set-button' + )?.getBoundingClientRect().top ?? null; + const restoreNickname = document.activeElement?.getAttribute('name') === 'nickname'; + this.props.editor.innerFocus = null; + this.pendingFocus = null; + this.forceUpdate(() => { + const target = this.getOuterFocusTarget(focus, restoreNickname); + const setButton = this.getOuterSetButton(focus); + if (target && ((target as HTMLInputElement).name === 'nickname' || !target.classList.contains('set-field'))) { + target.focus({ preventScroll: true }); + (target as HTMLInputElement).select?.(); + } + this.restoreOuterSetScroll(setButton || target || null, expectedTop); + }); + this.props.onUpdate(); + ev?.stopImmediatePropagation(); + ev?.preventDefault(); + }; + handleKeyDown = (ev: KeyboardEvent) => { + if (ev.keyCode !== 27) return; + this.closeInnerFocus(ev); + return false; + }; + restoreOuterSetScroll(target: HTMLElement | null, expectedTop: number | null) { + if (!target || expectedTop === null) return; + const setButton = target.closest('.set-button') || target; + const dy = setButton.getBoundingClientRect().top - expectedTop; + if (!dy) return; + const scrollParent = this.getScrollParent(setButton); + if (scrollParent) { + scrollParent.scrollTop += dy; + } else { + window.scrollBy(0, dy); + } + } + getScrollParent(elem: HTMLElement) { + for (let parent = elem.parentElement; parent; parent = parent.parentElement) { + const style = getComputedStyle(parent); + if (!/(auto|scroll)/.test(style.overflowY)) continue; + if (parent.scrollHeight <= parent.clientHeight) continue; + return parent; + } + return null; + } + getOuterFocusTarget(focus: NonNullable, restoreNickname = false) { + if (restoreNickname) { + return this.base!.querySelector( + `input[data-focus="set-${focus.setIndex}-nickname"]` + ); + } + if (focus.type === 'details' || focus.type === 'stats' || focus.type === 'import') { + return this.base!.querySelector( + `button[name="${focus.type}"][value="${this.props.editor.stringifyFocus(focus)}"]` + ); + } + if (focus.type === 'move' && focus.typeIndex === -1) { + return this.base!.querySelector( + `button[value="${this.props.editor.stringifyFocus(focus)}"]` + ); + } + // return this.base!.querySelector( + // `button[name=addpokemon][value="set-${focus.setIndex}-pokemon"]` + // ); + } + getOuterSetButton(focus: NonNullable) { + if (focus.setIndex >= this.props.editor.sets.length) return null; + return this.base!.querySelectorAll('.teameditor > .set-button.set-form')[focus.setIndex] || null; + } + scrollSelectedResult() { + const results = this.base!.querySelector('.wizardsearchresults'); + if (results) { + results.scrollTop = Math.max(0, this.props.editor.searchIndex * 33 - Math.trunc((window.innerHeight - 300) / 2)); + } + } + removeDuplicateMove(name: string) { + const { editor } = this.props; + const focus = editor.innerFocus; + if (!name) return false; + if (focus?.type !== 'move') return false; + const set = editor.sets[focus.setIndex]; + if (!set) return false; + const moveIndex = set.moves.indexOf(name); + if (moveIndex < 0 || moveIndex === focus.typeIndex) return false; + + set.moves.splice(moveIndex, 1); + let emptyIndex = 0; + while (emptyIndex < 4 && set.moves[emptyIndex]) emptyIndex++; + if (emptyIndex >= 4) emptyIndex = focus.typeIndex; + this.changeFocus({ + setIndex: focus.setIndex, + type: 'move', + typeIndex: emptyIndex, + }); + editor.save(); + this.props.onChange?.(); + this.forceUpdate(); + return true; + } + setFocusedValue(name: string, reverse?: boolean) { + const focus = this.props.editor.innerFocus; + if (!focus) return; + if (this.removeDuplicateMove(name)) return; + const input = this.base!.querySelector( + `input.set-field[data-focus="${this.props.editor.stringifyFocus(focus)}"]` + ); + if (!input) return; + input.value = name; + this.commitField(input, true, reverse); + } + setMoveResult(name: string, slot?: string, reverse?: boolean) { + this.selectMoveResult(name, slot, reverse); + this.props.editor.save(); + this.props.onChange?.(); + this.forceUpdate(); + } + override selectResult = (type: string | null, name: string, slot?: string, reverse?: boolean) => { + if (type === null) { + this.resetScroll(); + this.forceUpdate(); + } else if (!type) { + const searchBox = this.base!.querySelector('input[name=value]'); + if (searchBox) { + searchBox.value = ''; + searchBox.focus(); + } + this.props.editor.setSearchValue(''); + this.resetScroll(); + this.forceUpdate(); + } else if (type === 'move' && this.props.editor.innerFocus?.typeIndex === -1) { + this.setMoveResult(name, slot, reverse); + } else { + this.setFocusedValue(name); + } + }; + focusAdjacentField(focus: FocusState, reverse: boolean): boolean { + const set = this.props.editor.sets[focus.setIndex]; + const curField = `${focus.type}${focus.typeIndex === -1 ? '' : focus.typeIndex}`; + const fields: string[] = ['pokemon']; + if (set) { + if (this.showAbility(set)) fields.push('ability'); + if (this.showItem(set)) fields.push('item'); + for (let i = 0; i < Math.max(4, set.moves.length); i++) fields.push(`move${i}`); + fields.push('stats'); + fields.push('details'); + fields.push('nickname'); + } + const fieldIndex = fields.indexOf(curField); + if (fieldIndex < 0) return false; + + const next = fields[fieldIndex + (reverse ? -1 : 1)]; + if (!next && reverse && focus.type === 'pokemon') { + const prevButton = this.base!.querySelector( + `.team-focus-editor .set-button.set-form button[name=delete][value="${focus.setIndex}"]` + ) || this.base!.querySelector( + `.team-focus-editor .tabbar button[name=addpokemon]` + ) || this.base!.querySelector( + `.teameditor > .set-button.set-form button[name=delete][value="${focus.setIndex}"]` + ) || this.base!.querySelector( + `.teameditor button[name=addpokemon]` + ); + prevButton?.focus(); + return !!prevButton; + } + if (!next) return false; + if (next === 'nickname') { + const input = this.base!.querySelector( + `input[data-focus="set-${focus.setIndex}-nickname"]` + ); + input?.focus(); + input?.select(); + return !!input; + } + const nextType = next.startsWith('move') ? 'move' : next as InnerFocusType; + const nextTypeIndex = parseInt(next.slice(nextType.length) || '-1'); + this.changeFocus({ setIndex: focus.setIndex, type: nextType, typeIndex: nextTypeIndex }, true); + return true; + } + showItem(set: Dex.PokemonSet) { + const { editor } = this.props; + return !!(editor.gen > 1 && !editor.isLetsGo || set.item); + } + showAbility(set: Dex.PokemonSet) { + const { editor } = this.props; + return !!(editor.gen > 2 && !editor.isLetsGo || set.ability); + } + cur(type: InnerFocusType, setIndex: number, typeIndex = -1) { + const focus = this.props.editor.innerFocus; + return this.props.editor.readonly || ( + focus?.type === type && focus.setIndex === setIndex && focus.typeIndex === typeIndex + ) ? ' cur' : ''; + } + renderInput( + setIndex: number, type: InnerFocusType, value: string | undefined, + typeIndex = -1, placeholder = '' + ) { + const { editor } = this.props; + return ; + } + renderNicknameInput(setIndex: number) { + const { editor } = this.props; + const set = editor.sets[setIndex]; + const species = editor.dex.species.get(set.species); + return ; + } + override renderSet(set: Dex.PokemonSet | undefined, i: number) { + const { editor } = this.props; + const sprite = Dex.getTeambuilderSprite(set, editor.dex); + const spriteClass = set && Dex.getTeambuilderSpriteData(set, editor.dex).pixelated ? ' pixelated' : ''; + if (!set) { + return
+
+ {editor.deletedSet ? ( + + ) : ( + + )} +
+ + + + + + + + + + + +
+ + Pokemon {} + {this.renderInput(i, 'pokemon', '')} + +
+
; + } + while (set.moves.length < 4) set.moves.push(''); + + const species = editor.dex.species.get(set.species); + const tintClass = ` tint-${species.types[0]}`; + const isCur = TeamEditorState.clipboard?.teams?.[editor.team.key]?.sets[i] ? ' cur' : ''; + const overfull = set.moves.length > 5 ? ' overfull' : set.moves.length > 4 ? ' overfull overfull5' : ''; + return
+
+ {} + {} + {!(TeamEditorState.clipboard || editor.readonly) && } +
+ + + + + + + + + + + +
+ + + +
+ +
+ {species.types.map(type => <> )} +
+
+ {} + {[...set.moves, ...['', '', '', ''].slice(set.moves.length)].map((move, moveIndex) => ( +
{this.renderInput(i, 'move', move, moveIndex)}
+ ))} +
+ +
+ {this.showAbility(set) && } +
+ {this.showItem(set) && <> + {set.item && } + + } +
+
+ +
+
; + } +} + +function SetSourceButtons(props: { + editor: TeamEditorState, + set: Dex.PokemonSet, + onLoadSampleSet: (setName: string) => void, + onLoadUserSet: (setName: string) => void, +}) { + const { editor, set } = props; + const sampleSets = editor.getSampleSets(set); + const userSets = editor.getUserSets(set); + return <> + {sampleSets?.length !== 0 && ( +
+

Sample sets

+ {sampleSets ? ( +
+ {sampleSets.map(setName => <> + {} + )} +
+ ) : ( +
Loading...
+ )} +
+ )} + {userSets !== null && ( +
+

Box sets

+ {Object.keys(userSets).length > 0 ? ( +
+ {Object.keys(userSets).map(setName => <> + {} + )} +
+ ) : ( +
No {set.species} sets found in boxes
+ )} +
+ )} + ; +} + +class SetImportForm extends preact.Component<{ + editor: TeamEditorState, + set: Dex.PokemonSet, + setIndex: number, + onChange: () => void, +}, { + error: string, + copied: boolean, + dirty: boolean, + }> { + override state = { + error: '', + copied: false, + dirty: false, + }; + textbox: HTMLTextAreaElement | null = null; + revertText = ''; + getExportText() { + return Teams.exportSet(this.props.set, this.props.editor.dex, true).trim(); + } + override componentDidMount() { + this.setRevertPoint(); + } + override componentDidUpdate(prevProps: this['props']) { + if (prevProps.setIndex === this.props.setIndex) return; + this.setRevertPoint(); + } + setTextbox = (el: HTMLTextAreaElement | null) => { + this.textbox = el; + }; + setRevertPoint() { + if (!this.textbox) return; + this.revertText = this.getExportText(); + this.refreshText(this.revertText, false); + } + refreshText(text = this.getExportText(), dirty = text !== this.revertText) { + if (!this.textbox) return; + this.textbox.value = text; + this.textbox.focus(); + this.textbox.select(); + this.setState({ error: '', copied: false, dirty }); + } + revertTextToLastOpenedSet = () => { + const { editor, setIndex } = this.props; + if (editor.readonly || !this.textbox) return; + this.textbox.value = this.revertText; + const set = Teams.import(this.revertText)[0]; + if (!set) return; + editor.sets[setIndex] = set; + editor.save(); + this.props.onChange(); + this.textbox.focus(); + this.textbox.select(); + this.setState({ error: '', copied: false, dirty: false }); + }; + copyText = () => { + if (!this.textbox) return; + this.textbox.select(); + document.execCommand('copy'); + this.setState({ copied: true }); + }; + loadSampleSet = (setName: string) => { + const { editor, setIndex } = this.props; + if (!editor.loadSampleSet(setIndex, setName)) return; + this.refreshText(); + this.props.onChange(); + }; + loadUserSet = (setName: string) => { + const { editor, setIndex } = this.props; + if (!editor.loadUserSet(setIndex, setName)) return; + this.refreshText(); + this.props.onChange(); + }; + inputText = () => { + const { editor, setIndex } = this.props; + if (editor.readonly || !this.textbox) return; + const dirty = this.textbox.value !== this.revertText; + const set = Teams.import(this.textbox.value)[0]; + if (!set) { + this.setState({ error: 'No Pokemon set found.', copied: false, dirty }); + return; + } + editor.sets[setIndex] = set; + editor.save(); + this.props.onChange(); + this.setState({ error: '', copied: false, dirty }); + }; + override render() { + const { editor } = this.props; + return ; + } +} + class StatForm extends preact.Component<{ editor: TeamEditorState, set: Dex.PokemonSet, @@ -2468,12 +3550,12 @@ class StatForm extends preact.Component<{ const statName = editor.gen === 1 && statID === 'spa' ? 'Spc' : BattleStatNames[statID]; if (evs && !ev && !set.evs && statID === 'hp') ev = 'EVs'; return - {} + {statName} {} {} - {!evs && {stat}} - {evs && {ev || ''}} + {!evs && {stat}} + {evs && {ev || ''}} {evs && (BattleNatures[set.nature!]?.plus === statID ? ( + ) : BattleNatures[set.nature!]?.minus === statID ? ( @@ -2787,6 +3869,25 @@ class StatForm extends preact.Component<{ this.props.onChange(); }; + keyDownStatInput = (ev: KeyboardEvent) => { + // rearranges tab order to be all EVs, then all IVs + // (column-major instead of row-major) + if (ev.keyCode !== 9) return; + const target = ev.currentTarget as HTMLInputElement; + + const unsortedInputs = Array.from(this.base!.querySelectorAll('.stat-input')); + const evInputs = unsortedInputs.filter(input => input.name.startsWith('ev-')); + const ivInputs = unsortedInputs.filter(input => input.name.startsWith('iv-')); + const inputs = [...evInputs, ...ivInputs]; + + const inputIndex = inputs.indexOf(target); + if (inputIndex < 0) return; + const nextInput = inputs[inputIndex + (ev.shiftKey ? -1 : 1)]; + if (!nextInput) return; + nextInput.focus(); + nextInput.select(); + ev.preventDefault(); + }; updateNatureFromPlusMinus = () => { const { set } = this.props; set.nature = Teams.getNatureFromPlusMinus(this.plus, this.minus) || undefined; @@ -2914,8 +4015,8 @@ class StatForm extends preact.Component<{
{this.renderStatbar(stat, statID)} {stat}