Skip to content

Commit d501929

Browse files
evanpelleclaude
andcommitted
Open bundle contents in a dialog; keep the info bubble inside the card
Activating a bundle card now opens a dialog with a preview of every item and its name underneath (the card only tiles four). The cosmetic info bubble, right-aligned under the "?" and sized to its longest line, ran past the modal's left edge on first-column cards and was clipped; its strip now spans the card and caps the bubble at the card's width. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e0acba4 commit d501929

6 files changed

Lines changed: 208 additions & 8 deletions

File tree

resources/lang/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1643,6 +1643,7 @@
16431643
"no_tribes": "You haven't bought any tribe names yet.",
16441644
"pack_already_owned": "You already own {items}. Nothing was charged.",
16451645
"pack_debt": "Your Plutonium balance is {debt} in debt. Settle it before buying.",
1646+
"pack_no_items": "None of this bundle's items are available right now.",
16461647
"pack_owned": "Owned",
16471648
"pack_partially_owned": "Already own {items}",
16481649
"pack_unavailable": "This bundle is no longer available.",

src/client/Store.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import "./components/CurrencyDisplay";
1313
import "./components/CustomCurrencyCard";
1414
import "./components/EffectsGrid";
1515
import "./components/NotLoggedInWarning";
16+
import "./components/PackContentsDialog";
1617
import "./components/PurchaseButton";
1718
import { alignPurchaseRows } from "./components/PurchaseButton";
1819
import "./components/TribesPanel";
@@ -57,6 +58,8 @@ export class StoreModal extends BaseModal {
5758
private cosmeticsSubTab: CosmeticsSubTab = "patterns";
5859
private inspected: ResolvedCosmetic | null = null;
5960
private visibleGroups: readonly (readonly ResolvedCosmetic[])[] = [];
61+
/** The bundle whose contents dialog is open, if any. */
62+
private openedPack: ResolvedCosmetic | null = null;
6063

6164
protected modalConfig() {
6265
if (this.affiliateCode) {
@@ -234,6 +237,18 @@ export class StoreModal extends BaseModal {
234237
this.requestUpdate();
235238
}
236239

240+
// Activating a bundle also opens its contents: the card only has room to
241+
// tile a few items, so the dialog is where each one is shown with its name.
242+
private activate(resolved: ResolvedCosmetic): void {
243+
if (resolved.type === "cosmeticPack") this.openedPack = resolved;
244+
this.inspect(resolved);
245+
}
246+
247+
private closePack(): void {
248+
this.openedPack = null;
249+
this.requestUpdate();
250+
}
251+
237252
private reconcileInspection(
238253
groups: readonly (readonly ResolvedCosmetic[])[],
239254
): void {
@@ -331,7 +346,7 @@ export class StoreModal extends BaseModal {
331346
.activeVariantKey=${active.key}
332347
.actionContent=${action}
333348
state=${focused ? "focused" : "idle"}
334-
.onActivate=${(resolved: ResolvedCosmetic) => this.inspect(resolved)}
349+
.onActivate=${(resolved: ResolvedCosmetic) => this.activate(resolved)}
335350
.onVariantActivate=${(resolved: ResolvedCosmetic) =>
336351
this.inspect(resolved)}
337352
></cosmetic-card>`;
@@ -522,9 +537,14 @@ export class StoreModal extends BaseModal {
522537
}
523538

524539
private renderBundleGrid(): TemplateResult {
525-
return this.renderBrowser(this.visibleGroups, {
540+
return html`${this.renderBrowser(this.visibleGroups, {
526541
emptyTranslationKey: "store.no_bundles",
527-
});
542+
})}${this.openedPack
543+
? html`<pack-contents-dialog
544+
.pack=${this.openedPack}
545+
@close=${() => this.closePack()}
546+
></pack-contents-dialog>`
547+
: ""}`;
528548
}
529549

530550
private renderSubscriptionGrid(): TemplateResult {
@@ -592,6 +612,7 @@ export class StoreModal extends BaseModal {
592612

593613
protected onClose(): void {
594614
this.affiliateCode = null;
615+
this.openedPack = null;
595616
this.selectVisible(this.groupsForTab(this.activeTab));
596617
}
597618

src/client/components/CosmeticInfo.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,25 @@ export class CosmeticInfo extends LitElement {
4040
}
4141

4242
const rarityColor = rarityColors[this.rarity ?? ""] ?? "text-white/70";
43+
// The strip spans the card so the bubble (right-aligned under the "?")
44+
// can be capped at the card's width and wrap. Sized to its longest line,
45+
// it used to run past the modal's left edge on first-column cards and
46+
// get clipped. Only the button takes pointer events.
4347
return html`<div
4448
data-cosmetic-info
45-
class="group/cosmetic-info absolute right-2 top-2 z-10"
49+
class="group/cosmetic-info pointer-events-none absolute inset-x-2 top-2 z-10 flex justify-end"
4650
@click=${(event: Event) => event.stopPropagation()}
4751
>
4852
<button
4953
type="button"
5054
aria-label="Show cosmetic details"
51-
class="flex h-7 w-7 cursor-help items-center justify-center rounded-full bg-black/55 text-xs font-black text-white/80 ring-1 ring-white/20 transition-colors hover:bg-black/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
55+
class="pointer-events-auto flex h-7 w-7 cursor-help items-center justify-center rounded-full bg-black/55 text-xs font-black text-white/80 ring-1 ring-white/20 transition-colors hover:bg-black/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
5256
>
5357
?
5458
</button>
5559
<div
5660
role="tooltip"
57-
class="pointer-events-none absolute right-0 top-9 hidden min-w-max flex-col gap-0.5 whitespace-nowrap rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-xs text-white shadow-xl group-hover/cosmetic-info:flex group-focus-within/cosmetic-info:flex"
61+
class="pointer-events-none absolute right-0 top-9 hidden w-max max-w-full flex-col gap-0.5 whitespace-normal rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-left text-xs text-white shadow-xl group-hover/cosmetic-info:flex group-focus-within/cosmetic-info:flex"
5862
>
5963
${this.rarity
6064
? html`<div class="font-bold uppercase tracking-wider ${rarityColor}">
@@ -75,13 +79,13 @@ export class CosmeticInfo extends LitElement {
7579
: nothing}
7680
${this.perks.map(
7781
(perk) =>
78-
html`<div class="w-56 whitespace-normal">
82+
html`<div class="max-w-56">
7983
<span class="font-bold text-purple-300">${perk.label}:</span>
8084
<span class="text-white/80">${perk.info}</span>
8185
</div>`,
8286
)}
8387
${this.items.length > 0
84-
? html`<div data-cosmetic-info-items class="w-56 whitespace-normal">
88+
? html`<div data-cosmetic-info-items class="max-w-56">
8589
<span class="font-bold text-purple-300"
8690
>${translateText("cosmetics.pack_includes")}</span
8791
>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { html, LitElement, render as litRender } from "lit";
2+
import { customElement, property } from "lit/decorators.js";
3+
import { CosmeticPack } from "../../core/CosmeticSchemas";
4+
import { ResolvedCosmetic } from "../Cosmetics";
5+
import { translateText } from "../Utils";
6+
import { cosmeticDisplayName } from "./CosmeticPresentation";
7+
import "./CosmeticPreview";
8+
9+
/**
10+
* Shows everything in a cosmetic bundle: a preview of each resolved item with
11+
* its name underneath. Rendered into a body portal (like confirm-dialog) so it
12+
* sits above the store modal. Set `.pack`; dispatches `close`.
13+
*/
14+
@customElement("pack-contents-dialog")
15+
export class PackContentsDialog extends LitElement {
16+
@property({ attribute: false }) pack: ResolvedCosmetic | null = null;
17+
18+
private portal: HTMLDivElement | null = null;
19+
20+
createRenderRoot() {
21+
return this;
22+
}
23+
24+
connectedCallback() {
25+
super.connectedCallback();
26+
this.portal = document.createElement("div");
27+
document.body.appendChild(this.portal);
28+
window.addEventListener("keydown", this.onKeyDown);
29+
}
30+
31+
disconnectedCallback() {
32+
window.removeEventListener("keydown", this.onKeyDown);
33+
if (this.portal) {
34+
litRender(html``, this.portal);
35+
this.portal.remove();
36+
this.portal = null;
37+
}
38+
super.disconnectedCallback();
39+
}
40+
41+
private onKeyDown = (event: KeyboardEvent) => {
42+
if (event.key === "Escape") this.close();
43+
};
44+
45+
private close() {
46+
this.dispatchEvent(new CustomEvent("close"));
47+
}
48+
49+
render() {
50+
if (this.portal) {
51+
litRender(
52+
this.pack ? this.renderOverlay(this.pack) : html``,
53+
this.portal,
54+
);
55+
}
56+
return html``;
57+
}
58+
59+
private renderOverlay(resolved: ResolvedCosmetic) {
60+
const pack = resolved.cosmetic as CosmeticPack;
61+
const items = resolved.packItems ?? [];
62+
return html`<div
63+
class="fixed inset-0 z-[10020] flex items-center justify-center bg-black/80"
64+
@click=${(e: Event) => {
65+
if (e.target === e.currentTarget) this.close();
66+
}}
67+
>
68+
<div
69+
data-pack-contents
70+
role="dialog"
71+
aria-label=${pack.displayName}
72+
class="relative mx-4 max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/10 bg-surface p-6 shadow-2xl"
73+
>
74+
<button
75+
type="button"
76+
aria-label=${translateText("common.close")}
77+
@click=${() => this.close()}
78+
class="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-lg text-xl leading-none text-white/50 transition-all hover:bg-white/10 hover:text-white"
79+
>
80+
×
81+
</button>
82+
<h2 class="mb-1 pr-8 text-lg font-bold text-white">
83+
${pack.displayName}
84+
</h2>
85+
${pack.description
86+
? html`<p class="mb-4 text-sm text-white/60">${pack.description}</p>`
87+
: html`<div class="mb-4"></div>`}
88+
${items.length === 0
89+
? html`<p class="py-6 text-center text-sm text-white/50">
90+
${translateText("store.pack_no_items")}
91+
</p>`
92+
: html`<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
93+
${items.map(
94+
(item) =>
95+
html`<div
96+
data-pack-contents-item=${item.key}
97+
class="flex flex-col items-center gap-2 rounded-lg border border-white/10 bg-white/5 p-2"
98+
>
99+
<div
100+
class="flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg bg-black/30 p-2"
101+
>
102+
<cosmetic-preview
103+
.resolved=${item}
104+
size="card"
105+
></cosmetic-preview>
106+
</div>
107+
<span
108+
class="w-full break-words text-center text-sm font-bold leading-tight text-white"
109+
>${cosmeticDisplayName(item)}</span
110+
>
111+
</div>`,
112+
)}
113+
</div>`}
114+
</div>
115+
</div>`;
116+
}
117+
}

tests/client/CosmeticCard.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,30 @@ describe("CosmeticCard", () => {
207207
expect(info.parentElement!.contains(name)).toBe(false);
208208
});
209209

210+
it("keeps the info bubble within the card's width", async () => {
211+
installTranslations();
212+
await createCard();
213+
card!.resolved = {
214+
...red,
215+
cosmetic: { ...red.cosmetic!, artist: "A very long artist name indeed" },
216+
};
217+
await card!.updateComplete;
218+
219+
const info = card!.querySelector<HTMLElement>("[data-cosmetic-info]")!;
220+
const bubble = info.querySelector<HTMLElement>("[role=tooltip]")!;
221+
// Right-aligned under the "?", the bubble used to be as wide as its
222+
// longest line, so on a first-column card it ran past the modal's left
223+
// edge and was clipped. Its strip spans the card and caps it there.
224+
expect(info.className).toContain("inset-x-2");
225+
expect(bubble.className).toContain("max-w-full");
226+
expect(bubble.className).not.toContain("whitespace-nowrap");
227+
// The strip must not swallow clicks meant for the artwork beneath it.
228+
expect(info.className).toContain("pointer-events-none");
229+
expect(info.querySelector("button")?.className).toContain(
230+
"pointer-events-auto",
231+
);
232+
});
233+
210234
it("does not ellipsize long cosmetic names", async () => {
211235
installTranslations();
212236
await createCard();

tests/client/StoreModal.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { LitElement } from "lit";
12
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23
import {
34
fetchCosmetics,
@@ -671,6 +672,38 @@ describe("StoreModal cosmetic browser", () => {
671672
expect(purchaseCosmetic).toHaveBeenCalledWith(starterBundle, "hard");
672673
});
673674

675+
it("opens a contents dialog naming each bundle item when a bundle is activated", async () => {
676+
resolvedCatalog = [starterBundle];
677+
const modal = await openStoreOnTab("bundles");
678+
expect(document.querySelector("[data-pack-contents]")).toBeNull();
679+
680+
card(modal, starterBundle.key)!.onActivate!(starterBundle);
681+
await modal.updateComplete;
682+
const dialogHost = modal.querySelector(
683+
"pack-contents-dialog",
684+
) as LitElement;
685+
await dialogHost.updateComplete;
686+
687+
// Portaled to the body so it sits above the store modal.
688+
const dialog = document.body.querySelector<HTMLElement>(
689+
"[data-pack-contents]",
690+
)!;
691+
expect(dialog.closest("store-modal")).toBeNull();
692+
expect(dialog.getAttribute("aria-label")).toBe("Starter Pack");
693+
const items = [...dialog.querySelectorAll("[data-pack-contents-item]")];
694+
expect(
695+
items.map((item) => item.getAttribute("data-pack-contents-item")),
696+
).toEqual(["pattern:stripes", "flag:aurora"]);
697+
expect(items[1].querySelector("cosmetic-preview")).toBeTruthy();
698+
expect(items[1].textContent).toContain("Aurora");
699+
700+
dialog.querySelector<HTMLButtonElement>("button[aria-label]")!.click();
701+
await modal.updateComplete;
702+
expect(document.querySelector("[data-pack-contents]")).toBeNull();
703+
// The card stays inspected after closing.
704+
expect(card(modal, starterBundle.key)?.state).toBe("focused");
705+
});
706+
674707
it("shows owned and partially owned bundles as a status, not a sale", async () => {
675708
const owned = { ...starterBundle, relationship: "owned" as const };
676709
const partial = {

0 commit comments

Comments
 (0)