feedback7: merge fb7-g-ui — PRs #306 #309 #341 #349 #356 #358 #361 applied as-is (Grok coding run; static review p3: MERGE)
- Golden unchanged (union OK); broad node --test 5257 (5256 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
commit
f9969aeeef
15 files changed
+842
-55
No files matched your search
@@ -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;
|
||||
|
||||
+33
-11
@@ -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`<div class="modal" role="presentation"
|
||||
onMouseDown=${(e) => { if (closeOnBackdrop && e.target === e.currentTarget && onClose) onClose(); }}>
|
||||
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true"
|
||||
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true" tabindex="-1"
|
||||
style=${width ? `width:${width}` : undefined}>
|
||||
<div class="modal__stripe" aria-hidden="true"></div>
|
||||
${title || micro ? html`<header class="modal__head">
|
||||
|
||||
@@ -111,7 +111,8 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false
|
||||
].join('');
|
||||
const confirmIcon = localAsset('ui/battle', 'equip_replace_comfirm_icon');
|
||||
const swapIcon = localAsset('ui/battle', 'equip_replace_replace_icon');
|
||||
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
|
||||
// A focused button owns Enter through its native click, including Cancel and the item options.
|
||||
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && e.target?.tagName !== 'BUTTON' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
|
||||
<${Modal} open=${true} tone="red" class="eqr" width="min(7.6rem, 96vw)" micro="EQUIPMENT · REPLACE" title=${t('替换装备')} onClose=${onCancel}
|
||||
actions=${html`
|
||||
<${Button} variant="secondary" icon="close" class="eqr__cancel" onClick=${onCancel}>${t('取消')}<//>
|
||||
|
||||
@@ -301,7 +301,8 @@ function unitAllowed(ctx, piece, row, col, owner = null) {
|
||||
* must be legal on the source tile); from hand/temp the deploy cap applies unless a chess occupant swaps out.
|
||||
* board ← token: legal tile; from the hand the tile must be empty and its summoner deployed; board ↔ board swaps.
|
||||
* hand ← chess from the board: empty slot (withdraw), chess occupant (swap, legal on the source tile), otherwise
|
||||
* it goes to any free slot (HAND_FULL when none). hand ← token from the board: always (back onto its stack).
|
||||
* it uses a free slot or one freed by its own summon stack (HAND_FULL when none). hand ← token from the board:
|
||||
* always (back onto its stack).
|
||||
* hand ← hand/temp piece: move / swap. hand ← EQUIP item onto a chess: equip; onto a non-chess: swap.
|
||||
* board ← EQUIP item: equip the chess on that tile. board ← MAGIC (Arts): used on that tile.
|
||||
* @param {ReturnType<typeof placementContext>} ctx
|
||||
@@ -334,8 +335,11 @@ export function canPlace(ctx, uid, target) {
|
||||
if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', t('交换后的单位无法部署在原位置'));
|
||||
return { ok: true, action: 'swap' };
|
||||
}
|
||||
// the withdrawn operator goes to another free slot
|
||||
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => !ctx.handAt.has(i));
|
||||
// Withdrawal removes the operator's own summon stacks, freeing their hand slots too.
|
||||
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => {
|
||||
const p = ctx.handAt.get(i)?.piece;
|
||||
return !p || (p.kind === 'token' && p.ownerUid === piece.uid);
|
||||
});
|
||||
return free ? { ok: true, action: 'move' } : no('HAND_FULL', t('整备区已满'));
|
||||
}
|
||||
return { ok: true, action: 'swap' };
|
||||
|
||||
@@ -196,7 +196,7 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
|
||||
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
|
||||
|
||||
/**
|
||||
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null ('lv', nothing armed, a stale key).
|
||||
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null (a level card, nothing armed, a stale key).
|
||||
* @param {string|null} key
|
||||
* @param {any[]} slots the shop's slots
|
||||
* @param {any[]|null} rewardSlots the shown reward offer's slots
|
||||
@@ -288,10 +288,12 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
const free = Number(shop.freeRefreshes) || 0;
|
||||
const showReward = !!(reward && Array.isArray(reward.slots) && reward.slots.length);
|
||||
|
||||
// A completed upgrade changes the level, so its confirmation cannot carry into the next upgrade.
|
||||
const levelKey = `lv:${shop.level ?? 1}`;
|
||||
// two-tap: the keys that may stay armed right now
|
||||
const keys = new Set();
|
||||
if (!collapsed) {
|
||||
if (!lvReason) keys.add('lv');
|
||||
if (!lvReason) keys.add(levelKey);
|
||||
slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey(s.kind === 'item' ? 'i' : 'c', i, s)); });
|
||||
if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); });
|
||||
}
|
||||
@@ -312,7 +314,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
buy(idx);
|
||||
};
|
||||
const tapLevel = () => {
|
||||
if (armed !== 'lv') { setArmed('lv'); return; }
|
||||
if (armed !== levelKey) { setArmed(levelKey); return; }
|
||||
setArmed(null);
|
||||
onLevel();
|
||||
};
|
||||
@@ -341,7 +343,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
</button>
|
||||
</div>
|
||||
<div class="shopbar__row">
|
||||
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === 'lv'} onTap=${tapLevel} />
|
||||
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === levelKey} onTap=${tapLevel} />
|
||||
${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater}
|
||||
armed=${armed} onTap=${tapCard} offBonds=${offBonds} />`
|
||||
: html`<div class="shopbar__cards">
|
||||
|
||||
Reference in new issue
Block a user