From 25bcdff3a79a80ed6e957739224d8b6cefdb1ee9 Mon Sep 17 00:00:00 2001 From: Xinglan233 Date: Thu, 8 Oct 2026 18:41:36 +0800 Subject: [PATCH 1/7] feedback7 G-UI: reselecting the same operator keeps its draft skill and module (PR #306 by @Xinglan233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. Clicking the operator already in the 自选 draft no longer rebuilds the draft from the saved pick or the default, so an in-progress skill or module edit stays. Choosing a different operator still loads that operator's saved pick or defaultPick. Tests: node --test test/ui/diy-ui.test.js test/ui/feedback5-module-trait.test.js test/ui/diy.e2e.test.js — 25 pass, 0 fail. The diy.e2e suites skipped because SP_E2E is unset. --- public/js/screens/diy.js | 5 ++- test/ui/diy-ui.test.js | 44 +++++++++++++++++++++ test/ui/diy.e2e.test.js | 82 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/public/js/screens/diy.js b/public/js/screens/diy.js index 381788c3..d3a3ec85 100644 --- a/public/js/screens/diy.js +++ b/public/js/screens/diy.js @@ -168,7 +168,10 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto) && (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q)))); const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null; - const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D)); + const sel = (charId) => { + if (draft?.charId === charId) return; + setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D)); + }; const lk = ch?.locked || null; const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null; const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null; diff --git a/test/ui/diy-ui.test.js b/test/ui/diy-ui.test.js index 3b91f0eb..4355cb16 100644 --- a/test/ui/diy-ui.test.js +++ b/test/ui/diy-ui.test.js @@ -197,6 +197,50 @@ test('diy sync: welcome brings the kit list and sends room.diy; edits are deboun // ---- the tab -------------------------------------------------------------------------------------------------------------- describe('自选编队 tab (screens/diy.js)', () => { + for (const slotId of [T5A, T6A]) { + for (const saved of [null, SIEGE_PICK]) { + test(`the picker: ${slotId}, ${saved ? 'saved' : 'empty'} slot retains edits when reselecting its operator`, () => { + const picks = saved ? { [slotId]: saved } : {}; + const done = []; + let draft = saved ? { ...saved } : null; + let filter = 'all'; + let query = ''; + const view = () => expand(DiyPickerView({ m: null, slot: { slotId }, picks, kitted: KIT, + onDone: (p) => done.push(p), onClose() {}, filter, query, draft, + onFilter: (f) => { filter = f; }, onQuery: (q) => { query = q; }, onDraft: (d) => { draft = d; } })); + const click = (key, value) => { + const node = [...walk(view())].find((v) => v.props?.[key] === value && typeof v.props.onClick === 'function'); + assert.ok(node, `${key}=${value} is clickable`); + node.props.onClick(); + }; + click('data-char', SIEGE); + click('data-skill', 0); + click('data-module', 'none'); + const edited = { charId: SIEGE, skillIndex: 0, uniEquipId: null }; + assert.deepEqual(draft, edited); + click('data-char', SIEGE); + assert.deepEqual(draft, edited, 'reselecting keeps both the skill and explicit no-module choice'); + // Filtering/searching the operator out and back in must not change the draft either. + filter = 'owned'; + query = 'no matching operator'; + assert.ok(![...walk(view())].some((v) => hasClass(v, 'diy-opt'))); + query = '推进'; + click('data-char', SIEGE); + assert.deepEqual(draft, edited); + click('data-testid', 'diy-confirm'); + assert.deepEqual(done, [edited]); + assert.deepEqual(picks, saved ? { [slotId]: saved } : {}, 'the saved picks stay unchanged until the parent saves'); + // Switching operators still initializes the new pick, and returning restores the saved/default pick. + filter = 'all'; + query = ''; + click('data-char', SHARP); + assert.deepEqual(draft, M.defaultPick(SHARP, slotId, D)); + click('data-char', SIEGE); + assert.deepEqual(draft, saved || M.defaultPick(SIEGE, slotId, D)); + }); + } + } + test('four slots by tier: an empty slot to fill, a filled one shows the operator, its tag, skill and module', () => { const tree = expand(DiyPanelView({ m: data.get('assets'), picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, legal: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, kitted: KIT, onSet() {}, picking: null, onPicking() {} })); const slots = [...walk(tree)].filter((v) => hasClass(v, 'diy-slot')); diff --git a/test/ui/diy.e2e.test.js b/test/ui/diy.e2e.test.js index 3976721c..00127367 100644 --- a/test/ui/diy.e2e.test.js +++ b/test/ui/diy.e2e.test.js @@ -23,6 +23,88 @@ const SLOT = 'chess_char_5_diy1_a'; const SIEGE = 'char_112_siege'; const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }; +describe('自选编队 picker: reselecting retains the draft (real server, no art needed)', { skip: !UI_ENABLED && 'set SP_E2E=1 (and have Chrome)' }, () => { + for (const slot of [SLOT, 'chess_char_6_diy1_a']) { + test(`${slot}: same operator retains skill/module edits; cancel, confirm and reload keep their semantics`, { timeout: 90000 }, async () => { + const srv = await startRealServer(); + const P = (await import('puppeteer-core')).default; + const c = new Client(P, srv.base, 'diy-reselect', { prefix: `diy-reselect-${slot}`, w: 1280, h: 720 }); + const picker = '[data-testid="diy-picker"]'; + const operator = `${picker} .diy-opt[data-char="${SIEGE}"]`; + const saved = () => c.page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.diy') || 'null')?.picks?.[id] ?? null, slot); + const choices = () => c.page.evaluate((selector) => { + const el = document.querySelector(selector); + return { skill: el?.querySelector('[data-skill][aria-checked="true"]')?.dataset.skill, + module: el?.querySelector('[data-module][aria-checked="true"]')?.dataset.module }; + }, picker); + const edited = { charId: SIEGE, skillIndex: 0, uniEquipId: null }; + const changed = { charId: SIEGE, skillIndex: 1, uniEquipId: 'uniequip_003_siege' }; + const open = async () => { + await c.click(`.diy-slot[data-slot="${slot}"] :is(.diy-slot__fill, [data-testid="diy-change"])`); + await c.page.waitForSelector(picker, { visible: true, timeout: 8000 }); + }; + const confirm = async (expected) => { + await c.click('[data-testid="diy-confirm"]'); + await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 }); + assert.deepEqual(await saved(), expected); + }; + try { + await c.open(); + await c.enter('自选配置回归'); + await c.click('.lobby-screen [data-testid="loadout-open"]'); + await c.click('.lo .lo-tab[data-tab="diy"]'); + await open(); + await c.click(operator); + await c.click(`${picker} [data-skill="0"]`); + await c.click(`${picker} [data-module="none"]`); + await c.shot('empty-edited'); + await c.click(operator); + await c.shot('empty-reselected'); + assert.deepEqual(await choices(), { skill: '0', module: 'none' }, 'an empty slot retains its edited draft'); + // Hide the current operator, then find and reselect it through the filter/search controls. + await c.click(`${picker} [data-filter="proto"]`); + assert.equal(await c.page.$(operator), null); + await c.click(`${picker} [data-filter="owned"]`); + await c.click(`${picker} input[type="search"]`); + await c.page.keyboard.type('推进'); + await c.click(operator); + assert.deepEqual(await choices(), { skill: '0', module: 'none' }); + await confirm(edited); + await c.shot('empty-saved'); + // A saved non-default pick must not overwrite new edits on a repeated operator click. + await open(); + await c.click(`${picker} [data-skill="1"]`); + await c.click(`${picker} [data-module="uniequip_003_siege"]`); + await c.click(operator); + assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' }); + await c.shot('saved-reselected'); + await c.click(`${picker} .diy-pick__foot button`, '取消'); + await c.page.waitForSelector(picker, { hidden: true, timeout: 3000 }); + assert.deepEqual(await saved(), edited, 'cancel leaves the saved pick unchanged'); + await open(); + assert.deepEqual(await choices(), { skill: '0', module: 'none' }); + await c.click(`${picker} [data-skill="1"]`); + await c.click(`${picker} [data-module="uniequip_003_siege"]`); + await c.click(operator); + await confirm(changed); + await c.page.reload({ waitUntil: 'domcontentloaded' }); + await c.page.waitForSelector('.lobby-screen', { timeout: 20000 }); + await c.click('.lobby-screen [data-testid="loadout-open"]'); + await c.click('.lo .lo-tab[data-tab="diy"]'); + await open(); + assert.deepEqual(await choices(), { skill: '1', module: 'uniequip_003_siege' }); + assert.deepEqual(await saved(), changed, 'confirm persists the current draft across reload'); + await c.shot('reload'); + assert.deepEqual(c.problems.filter((p) => p.startsWith('pageerror:')), []); + } finally { + console.log(JSON.stringify({ slot, problems: problemsOf([c]) })); + await c.close(); + await srv.stop(); + } + }); + } +}); + // GitHub #284 (idea from PR #286): Esc in the picker cancels only the picker, like its 取消, also from its search // field — the 干员调配 overlay and its four slots stay, and the saved picks do not change; the next Esc closes the overlay describe('自选编队 picker: Esc closes only the picker (real server, no art needed)', { skip: !UI_ENABLED && 'set SP_E2E=1 (and have Chrome)' }, () => { From d07d97dc4c2c1dd764826b144372fe232b90cf3e Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 18:42:00 +0800 Subject: [PATCH 2/7] feedback7 G-UI: selling a summoner counts the bench slots its summon stacks free (PR #309 by @siruimei07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. The sale pre-check now treats each of the operator's own hand or temp summon stacks as one empty slot, independent of stack.count, before the equipment returns. Board summons and another player's tokens still occupy their slots. A sale that does not fit still returns HAND_FULL before anything is detached. Tests: node --test test/match/sell-capacity.test.js test/match/economy.test.js test/match/playtest6_summons.test.js — 28 pass, 0 fail. eslint on the touched files: 0 errors (one pre-existing unused-arg warning in economy.js). --- server/match/player/economy.js | 4 +- test/match/sell-capacity.test.js | 153 +++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 test/match/sell-capacity.test.js diff --git a/server/match/player/economy.js b/server/match/player/economy.js index bd0686bd..67a7a58f 100644 --- a/server/match/player/economy.js +++ b/server/match/player/economy.js @@ -209,7 +209,9 @@ export class PlayerEconomy { if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold'); const piece = loc.piece; // its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room - const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0); + // Removing the owner's summon stacks frees one slot per stack, regardless of its summon count. + const freed = (x) => x == null || (x.kind === 'token' && x.ownerUid === piece.uid); + const room = this.hand.filter(freed).length + this.temp.filter(freed).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0); if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment'); this._detach(loc); this.removeTokensOf(piece.uid); diff --git a/test/match/sell-capacity.test.js b/test/match/sell-capacity.test.js new file mode 100644 index 00000000..961bdd10 --- /dev/null +++ b/test/match/sell-capacity.test.js @@ -0,0 +1,153 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ERR } from '../../shared/constants.js'; +import { makeMatch, legalTileFor, checkInvariants } from './harness.js'; + +const SILENCE = 'chess_char_2_02_a'; +const CATHY = 'chess_char_4_11_a'; +const EQUIPMENT = 'chess_item_1_02_e_b'; // Elite equipment does not merge. + +function setup(t) { + const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 1, fake: true }).start(); + t.after(() => h.m.dispose()); + h.toPrep(1); + return { h, m: h.m, ps: h.ps('p_0') }; +} + +function deploy(m, ps, piece, chessId = piece.id) { + const tile = legalTileFor(m, ps, chessId); + assert.ok(tile); + assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true }); +} + +function equip(m, ps, owner, count) { + return Array.from({ length: count }, () => { + const item = ps.acquireItem(EQUIPMENT, { silent: true }); + assert.ok(item); + assert.deepEqual(m.handle(ps.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: owner.uid }), { ok: true }); + return item; + }); +} + +function fillStorage(ps) { + while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem(EQUIPMENT, { silent: true })); +} + +function swapIntoTemp(m, ps, piece) { + const idx = ps.hand.indexOf(piece); + const filler = ps.temp.find((p) => p?.kind === 'item'); + assert.ok(idx >= 0 && filler); + assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: filler.uid, to: { area: 'hand', idx } }), { ok: true }); + assert.equal(ps.find(piece.uid).area, 'temp'); +} + +function snapshot(ps) { + return structuredClone({ + hand: ps.hand, temp: ps.temp, board: ps.board, funds: ps.funds, pendingFunds: ps.pendingFunds, + stats: ps.stats, round: ps.round, tempDue: ps._tempDue, + }); +} + +const inventory = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean); +const tokensOf = (ps, owner) => inventory(ps).filter((p) => p.kind === 'token' && p.ownerUid === owner.uid); + +for (const area of ['hand', 'temp']) { + for (const itemCount of [1, 2]) { + test(`sale uses the freed ${area} summon slot to return ${itemCount} equipment item(s)`, (t) => { + const { h, m, ps } = setup(t); + const owner = ps.acquireChess(SILENCE, { silent: true }); + deploy(m, ps, owner); + const items = equip(m, ps, owner, itemCount); + const other = ps.acquireChess(SILENCE, { silent: true }); + deploy(m, ps, other); + const [stack] = tokensOf(ps, owner); + fillStorage(ps); + if (area === 'temp') swapIntoTemp(m, ps, stack); + assert.equal(ps.find(stack.uid).area, area); + // Two items need one existing free slot as well as the slot released by the stack. + if (itemCount === 2) { + const filler = ps.temp.find((p) => p?.kind === 'item'); + assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true }); + } + const untouched = structuredClone(inventory(ps).filter((p) => p !== owner && p !== stack)); + const funds = ps.funds; + const poolBefore = m.pool.left(SILENCE); + const copies = owner.poolCopies; + assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true }); + assert.equal(ps.find(owner.uid), null); + assert.deepEqual(tokensOf(ps, owner), []); + assert.equal(ps.funds, funds + 1); + assert.equal(m.pool.left(SILENCE), poolBefore + copies); + assert.deepEqual(owner.items, []); + for (const item of items) { + assert.equal(ps.find(item.uid).piece, item, 'the original equipment object and UID return'); + assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area)); + } + assert.equal(ps.find(items[0].uid).area, area, 'hand is preferred, with overflow into temp'); + for (const piece of untouched) assert.deepEqual(ps.find(piece.uid).piece, piece); + assert.equal(inventory(ps).length, untouched.length + items.length); + assert.deepEqual(h.logs.warn, []); + checkInvariants(m); + }); + } +} + +for (const { name, ownerId, area, itemCount, stackCount } of [ + { name: 'one hand stack cannot hold two equipment items', ownerId: SILENCE, area: 'hand', itemCount: 2, stackCount: 1 }, + { name: 'one temp stack cannot hold two equipment items', ownerId: SILENCE, area: 'temp', itemCount: 2, stackCount: 1 }, + { name: 'a two-summon stack frees only one slot', ownerId: CATHY, area: 'hand', itemCount: 2, stackCount: 2 }, + { name: 'board summons and foreign stacks free no usable slot', ownerId: SILENCE, area: 'board', itemCount: 1, stackCount: 1 }, +]) { + test(`sale rejects atomically when ${name}`, (t) => { + const { m, ps } = setup(t); + const owner = ps.acquireChess(ownerId, { silent: true }); + deploy(m, ps, owner); + const items = equip(m, ps, owner, itemCount); + const other = ps.acquireChess(SILENCE, { silent: true }); + deploy(m, ps, other); + const [stack] = tokensOf(ps, owner); + assert.equal(stack.count, stackCount); + if (area === 'board') deploy(m, ps, stack, owner.id); + fillStorage(ps); + if (area === 'temp') swapIntoTemp(m, ps, stack); + assert.equal(ps.find(stack.uid).area, area); + const before = snapshot(ps); + const poolBefore = m.pool.left(ownerId); + assert.equal(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }).error, ERR.HAND_FULL); + assert.deepEqual(snapshot(ps), before, 'pieces, equipment, funds, and bookkeeping remain unchanged'); + assert.equal(m.pool.left(ownerId), poolBefore); + for (const item of items) assert.equal(ps.find(item.uid).area, 'equipped'); + checkInvariants(m); + // Free exactly one additional slot and retry the same public intent. + const filler = ps.temp.find((p) => p?.kind === 'item'); + assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true }); + assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true }); + assert.equal(ps.find(owner.uid), null); + assert.deepEqual(tokensOf(ps, owner), []); + assert.equal(ps.funds, before.funds + 1); + for (const item of items) { + assert.equal(ps.find(item.uid).piece, item); + assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area)); + } + assert.equal(tokensOf(ps, other).length, 1); + checkInvariants(m); + }); +} + +for (const area of ['hand', 'temp']) { + test(`sale still counts the operator's own ${area} slot`, (t) => { + const { m, ps } = setup(t); + const owner = ps.acquireChess(SILENCE, { silent: true }); + const [item] = equip(m, ps, owner, 1); + fillStorage(ps); + if (area === 'temp') swapIntoTemp(m, ps, owner); + assert.equal(ps.find(owner.uid).area, area); + const funds = ps.funds; + assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true }); + assert.equal(ps.find(owner.uid), null); + assert.equal(ps.find(item.uid).piece, item); + assert.equal(ps.find(item.uid).area, area); + assert.equal(ps.funds, funds + 1); + checkInvariants(m); + }); +} From e2503c7f609c0753f0e8c650b56dbfaeef76bfc0 Mon Sep 17 00:00:00 2001 From: Xinglan233 Date: Thu, 8 Oct 2026 18:42:38 +0800 Subject: [PATCH 3/7] feedback7 G-UI: the import dialog traps keyboard focus and yields while the guide is open (PR #341 by @Xinglan233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. The shared Modal keeps Tab inside the topmost dialog, pulls focus back if it leaves, skips disabled, hidden, inert and zero-size controls, and does not trap while a .guide overlay is open. Escape still closes the dialog. Enter is unchanged, so a focused button keeps its native click. Tests: node --test test/ui/modal.e2e.test.js test/ui/loadout.e2e.test.js test/ui/leftovers.test.js test/ui/match-info.test.js test/ui/loadout.test.js test/ui/playtest3.test.js test/ui/playtest4.test.js test/ui/uiFixes.test.js test/ui/localArt.test.js — 114 pass, 0 fail. The modal and loadout e2e suites skipped because SP_E2E is unset. eslint on the touched files: 0 errors. --- public/js/ui/components.js | 44 +++++++--- test/ui/loadout.e2e.test.js | 58 +++++++++++++ test/ui/modal.e2e.test.js | 168 ++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 test/ui/modal.e2e.test.js diff --git a/public/js/ui/components.js b/public/js/ui/components.js index 3d8bff30..25716906 100644 --- a/public/js/ui/components.js +++ b/public/js/ui/components.js @@ -386,7 +386,7 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN // ---- Modal & dialogs --------------------------------------------------------------------------- -const modalStack = []; // open modals, topmost last: only the topmost reacts to Escape +const modalStack = []; // open modals, topmost last: only the topmost handles keyboard focus / Escape /** * Modal dialog (declarative). Esc / backdrop click call onClose. @@ -397,24 +397,46 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid const boxRef = useRef(null); const closeRef = useRef(onClose); closeRef.current = onClose; - useEffect(() => { + useLayoutEffect(() => { if (!open) return undefined; const token = {}; modalStack.push(token); const prevFocus = typeof document !== 'undefined' ? document.activeElement : null; + const box = boxRef.current; + // The independent guide can open above SettingsModal (its z-index is higher than --z-modal). + const topmost = () => modalStack[modalStack.length - 1] === token && !document.querySelector('.guide'); + const available = (el) => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length > 0 + && getComputedStyle(el).visibility !== 'hidden'; + const tabbable = () => [...box.querySelectorAll('button, [href], input, select, textarea, [tabindex], summary')] + .filter((el) => el.tabIndex >= 0 && available(el)); + const focusFirst = () => (tabbable()[0] || box).focus(); const onKey = (e) => { - if (e.key !== 'Escape' || modalStack[modalStack.length - 1] !== token || !closeRef.current) return; - e.stopPropagation(); - closeRef.current(); + if (!topmost()) return; + if (e.key === 'Escape' && closeRef.current) { + e.stopPropagation(); + closeRef.current(); + } else if (e.key === 'Tab') { + const els = tabbable(); + const first = els[0] || box, last = els[els.length - 1] || box; + if (!els.includes(document.activeElement) || document.activeElement === (e.shiftKey ? first : last)) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } + } + }; + const onFocus = (e) => { + if (topmost() && !box.contains(e.target)) focusFirst(); }; window.addEventListener('keydown', onKey); - const t = setTimeout(() => { - const el = boxRef.current?.querySelector('[data-autofocus]') || boxRef.current?.querySelector('button, input'); - el?.focus?.(); - }, 30); + window.addEventListener('focusin', onFocus); + const autofocus = box.querySelector('[data-autofocus]'); + if (topmost()) { + if (autofocus && available(autofocus)) autofocus.focus(); + else focusFirst(); + } return () => { window.removeEventListener('keydown', onKey); - clearTimeout(t); + window.removeEventListener('focusin', onFocus); const i = modalStack.indexOf(token); if (i >= 0) modalStack.splice(i, 1); prevFocus?.focus?.(); @@ -423,7 +445,7 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid if (!open) return null; return html`