diff --git a/public/css/components.css b/public/css/components.css
index a1f8d4f2..89a00160 100644
--- a/public/css/components.css
+++ b/public/css/components.css
@@ -508,6 +508,7 @@
white-space: pre-line;
}
.tooltip.is-shown { opacity: 1; }
+.tooltip--aside { max-width: 3.2rem; white-space: normal; }
.tooltip--top.is-shown { animation: tip-up var(--t-fast) var(--ease-out); }
.tooltip--bottom.is-shown { animation: tip-down var(--t-fast) var(--ease-out); }
@keyframes tip-up { from { transform: translateY(4px); } }
diff --git a/public/css/screens/briefing.css b/public/css/screens/briefing.css
index a6e7fc23..fe8b15fd 100644
--- a/public/css/screens/briefing.css
+++ b/public/css/screens/briefing.css
@@ -81,6 +81,15 @@
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: calc(.12 * var(--t)); font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
.brief-bond__ban .icon { width: .12rem; height: .12rem; }
.brief-bond.is-partial .bond__core { box-shadow: 0 0 0 2px rgba(255, 84, 84, .5); }
+/* the box beside a bond's tooltip (matchInfo.js BondBannedAside): its banned members with avatar, name and bonds */
+.brief-bantip { display: flex; flex-direction: column; gap: .06rem; }
+.brief-bantip__h { font-size: calc(.12 * var(--t)); font-weight: 700; letter-spacing: .06em; color: var(--red); }
+.brief-bantip__op { display: flex; align-items: center; gap: .08rem; }
+.brief-bantip__op .uthumb { flex: none; }
+.brief-bantip__txt { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
+.brief-bantip__txt b { font-size: calc(.14 * var(--t)); color: var(--text-hi); }
+.brief-bantip__txt > span { font-size: calc(.12 * var(--t)); color: var(--text-lo); }
+.brief-bantip__txt .is-this { color: var(--mint-400); }
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: calc(.14 * var(--t)); color: var(--text-dim); margin-top: -.06rem; }
.brief-legend__off { width: .16rem; height: .16rem; border-radius: 50%; background: #3a403d; border: 2px solid #555; }
.brief-legend__ban { display: inline-grid; place-items: center; width: .2rem; height: .18rem; background: var(--red); color: #fff; }
diff --git a/public/js/ui/components.js b/public/js/ui/components.js
index ec4d57aa..f6dfedd6 100644
--- a/public/js/ui/components.js
+++ b/public/js/ui/components.js
@@ -534,24 +534,27 @@ export function DialogHost() {
// ---- Tooltip -----------------------------------------------------------------------------------
-let tipState = null; // { id, content, rect, placement }
+let tipState = null; // { id, content, aside, rect, placement }
let tipSeq = 0;
const tipListeners = new Set();
const setTip = (s) => { tipState = s; for (const fn of [...tipListeners]) fn(); };
/**
* Hover/focus tooltip around its children. Content renders in (fixed, viewport-clamped).
- * @param {{ text: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
+ * `aside`: an optional second box in the same style, beside the tooltip (right of it; left when there is no room).
+ * @param {{ text: any, aside?: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
*/
const TOUCH_TIP_MS = 450;
-export function Tooltip({ text, placement = 'top', delay = 120, block = false, class: cls, children }) {
+export function Tooltip({ text, aside = null, placement = 'top', delay = 120, block = false, class: cls, children }) {
const ref = useRef(null);
const idRef = useRef(0);
const timer = useRef(null);
const textRef = useRef(text);
+ const asideRef = useRef(aside);
const pressed = useRef(false); // pointer pressed inside: the focus that follows is not a keyboard focus
textRef.current = text;
+ asideRef.current = aside;
if (!idRef.current) idRef.current = ++tipSeq;
const show = () => {
clearTimeout(timer.current);
@@ -560,7 +563,7 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
const el = ref.current;
const content = textRef.current; // the text of the render current when the delay ends (a click may change it)
if (!el || !el.isConnected || content == null || content === '') return;
- setTip({ id: idRef.current, content, rect: el.getBoundingClientRect(), placement });
+ setTip({ id: idRef.current, content, aside: asideRef.current, rect: el.getBoundingClientRect(), placement });
}, delay);
};
const hide = () => {
@@ -596,7 +599,7 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
useEffect(() => {
if (tipState?.id !== idRef.current) return;
if (text == null || text === '') setTip(null);
- else setTip({ ...tipState, content: text });
+ else setTip({ ...tipState, content: text, aside: asideRef.current });
}, [text]);
return html` c + 1, 0);
const boxRef = useRef(null);
+ const asideRef = useRef(null);
const [pos, setPos] = useState(null);
useEffect(() => {
tipListeners.add(force);
@@ -628,13 +632,31 @@ export function TooltipLayer() {
if (place === 'bottom' && top + b.height > vh - m) { place = 'top'; top = Math.max(m, r.top - b.height - m); }
let left = r.left + r.width / 2 - b.width / 2;
left = Math.max(m, Math.min(vw - b.width - m, left));
- const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id };
- if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place) setPos(next);
+ // the aside: right of the box (left when it does not fit), growing away from the anchor (bottom-aligned above it,
+ // top-aligned below it), kept in the viewport
+ let aTop = null, aLeft = null;
+ const a = asideRef.current?.getBoundingClientRect();
+ if (a) {
+ const gap = 4;
+ aLeft = left + b.width + gap;
+ if (aLeft + a.width > vw - m) aLeft = left - gap - a.width;
+ aLeft = Math.max(m, Math.min(vw - a.width - m, aLeft));
+ aTop = place === 'top' ? top + b.height - a.height : top;
+ aTop = Math.max(m, Math.min(vh - a.height - m, aTop));
+ }
+ const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id,
+ aTop: aTop == null ? null : Math.round(aTop), aLeft: aLeft == null ? null : Math.round(aLeft) };
+ if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place
+ || pos.aTop !== next.aTop || pos.aLeft !== next.aLeft) setPos(next);
});
if (!tipState) return null;
const ready = pos && pos.id === tipState.id;
+ const hasAside = tipState.aside != null && tipState.aside !== '';
+ const off = 'top:-9999px;left:-9999px';
return html`${tipState.content}
`;
+ style=${ready ? `top:${pos.top}px;left:${pos.left}px` : off}>${tipState.content}
+ ${hasAside ? html`${tipState.aside}
` : null}`;
}
// ---- ProgressBar, Tabs, Spinner ----------------------------------------------------------------
diff --git a/public/js/ui/matchInfo.js b/public/js/ui/matchInfo.js
index 96a44c0f..5bf4908c 100644
--- a/public/js/ui/matchInfo.js
+++ b/public/js/ui/matchInfo.js
@@ -69,14 +69,38 @@ export function DiyBannedLine({ model, class: cls = 'brief-banned__diy' }) {
${t('{names}的盟约本局全部禁用,不会出现在你的商店', { names: list.map((x) => dn(x.name)) })}
`;
}
+/**
+ * The box beside a bond's tooltip: its banned members (the badge's count), each with avatar, name and bonds (the hovered
+ * one highlighted); null when none are banned.
+ * @param {any} bond @param {MatchInfoModel} model @param {Set} banned
+ */
+function BondBannedAside(bond, model, banned) {
+ const ids = (Array.isArray(bond.visibleMembers) ? bond.visibleMembers : []).filter((id) => banned.has(id));
+ if (!ids.length) return null;
+ const nameOf = new Map(model.bonds.map((b) => [b.bondId, b.name]));
+ return html`
+
${t('本局禁用干员')}
+ ${ids.map((id) => {
+ const c = data.lookup('chess', id);
+ const bonds = (Array.isArray(c?.bonds) ? c.bonds : []).filter((b) => nameOf.has(b));
+ return html`
+ <${UnitThumb} kind="chess" id=${id} size="xs" showTier=${false} />
+
${c?.name || id}
+ ${bonds.map((b, i) => html`${i ? ' · ' : ''}${nameOf.get(b)}`)}
+
`;
+ })}
+
`;
+}
+
/**
* One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never
* activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says
- * which (briefingBondTip).
+ * which (briefingBondTip). A bond with banned members lists them in a second box beside the tooltip (BondBannedAside).
* @param {{ title: string, micro: string, bonds: any[], model: MatchInfoModel }} props
*/
export function MatchBondRow({ title, micro, bonds, model }) {
const m = data.get('assets');
+ const banned = new Set(model.banned);
return html`
${title}<${MicroLabel}>${micro}${MicroLabel}>
@@ -84,7 +108,8 @@ export function MatchBondRow({ title, micro, bonds, model }) {
const state = model.stateOf(b.bondId);
const off = !!state;
const bannedN = model.perBond.get(b.bondId) || 0;
- return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}>
+ return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}
+ aside=${bannedN > 0 ? BondBannedAside(b, model, banned) : null}>
0 && 'is-partial')} data-bond=${b.bondId}>
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
diff --git a/test/ui/match-info.test.js b/test/ui/match-info.test.js
index d4d17391..9e035a44 100644
--- a/test/ui/match-info.test.js
+++ b/test/ui/match-info.test.js
@@ -106,6 +106,18 @@ test('MatchBondRow: greyed by state (✕ disc), the banned-member badge, the red
assert.equal(badge('deputShip'), undefined);
assert.equal(tipOf('deputShip').props.text, '助力');
assert.equal(disc('deputShip').props.tier, DATA.bonds.deputShip.thresholds.length);
+ // the box beside the tip: one line per banned member (avatar, name, its bonds with this one highlighted); none ⇒ null
+ const aside = tipOf('preciShip').props.aside;
+ const ops = [...walk(aside)].filter((v) => hasClass(v, 'brief-bantip__op'));
+ const bannedPreci = DATA.bonds.preciShip.visibleMembers.filter((id) => PUB.bannedChess.includes(id));
+ assert.deepEqual(ops.map((v) => v.props['data-chess']), bannedPreci);
+ for (const op of ops) {
+ const c = DATA.chess[op.props['data-chess']];
+ assert.ok([...walk(op)].some((v) => v.type === UnitThumb && v.props.id === c.chessId));
+ assert.match(textOf(op), new RegExp('^' + c.name));
+ assert.ok([...walk(op)].some((v) => hasClass(v, 'is-this') && textOf(v) === '精准'));
+ }
+ assert.equal(tipOf('deputShip').props.aside, null);
});
test('MatchLegend: the grey and the badge; "或本模式禁用" only when the mode switches bonds off', () => {