Skip to content

Commit 7de74b3

Browse files
refactor(promote): wrap banner content in inner container for click-through fix
Refactor the banner DOM structure to wrap all interactive elements in an inner container, allowing the shell to have pointer-events:none while the inner container re-enables pointer-events for interaction. This fixes click-through issues where the banner was blocking interactions with page content below it. Changes: - Add docs-promote-banner__inner wrapper around all banner content - Apply pointer-events:none to shell and pointer-events:auto to inner container - Update hover and focus-within selectors to target inner container - Add pointer-events restoration to shell for responsive breakpoints - Update glow and sheen animations to apply to inner container - Adjust responsive width constraints to inner container Co-Authored-By: Hagicode <noreply@hagicode.com> Signed-off-by: newbe36524 <newbe36524@qq.com>
1 parent 67e9d13 commit 7de74b3

4 files changed

Lines changed: 380 additions & 71 deletions

File tree

src/components/DocsPromoteInfoBanner.astro

Lines changed: 68 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -42,52 +42,54 @@ const localizedText = currentLocale === 'en'
4242
hidden
4343
aria-label={localizedText.bannerLabel}
4444
>
45-
<button
46-
type="button"
47-
class="docs-promote-banner__close"
48-
data-promote-banner-close
49-
aria-label={localizedText.closeLabel}
50-
>
51-
<span aria-hidden="true">×</span>
52-
</button>
53-
54-
<div class="docs-promote-banner__viewport">
55-
<div class="docs-promote-banner__track" data-promote-banner-track></div>
56-
</div>
57-
58-
<div class="docs-promote-banner__controls">
59-
<button
60-
type="button"
61-
class="docs-promote-banner__control"
62-
data-promote-banner-previous
63-
aria-label={localizedText.previousLabel}
64-
>
65-
<span aria-hidden="true">‹</span>
66-
</button>
67-
68-
<span
69-
class="docs-promote-banner__count"
70-
data-promote-banner-count
71-
aria-label={localizedText.countLabel}
72-
></span>
73-
45+
<div class="docs-promote-banner__inner">
7446
<button
7547
type="button"
76-
class="docs-promote-banner__control"
77-
data-promote-banner-next
78-
aria-label={localizedText.nextLabel}
48+
class="docs-promote-banner__close"
49+
data-promote-banner-close
50+
aria-label={localizedText.closeLabel}
7951
>
80-
<span aria-hidden="true"></span>
52+
<span aria-hidden="true">×</span>
8153
</button>
8254

83-
<button
84-
type="button"
85-
class="docs-promote-banner__control docs-promote-banner__pause"
86-
data-promote-banner-pause
87-
aria-label={localizedText.pauseLabel}
88-
>
89-
{currentLocale === 'en' ? 'Pause' : '暂停'}
90-
</button>
55+
<div class="docs-promote-banner__viewport">
56+
<div class="docs-promote-banner__track" data-promote-banner-track></div>
57+
</div>
58+
59+
<div class="docs-promote-banner__controls">
60+
<button
61+
type="button"
62+
class="docs-promote-banner__control"
63+
data-promote-banner-previous
64+
aria-label={localizedText.previousLabel}
65+
>
66+
<span aria-hidden="true">‹</span>
67+
</button>
68+
69+
<span
70+
class="docs-promote-banner__count"
71+
data-promote-banner-count
72+
aria-label={localizedText.countLabel}
73+
></span>
74+
75+
<button
76+
type="button"
77+
class="docs-promote-banner__control"
78+
data-promote-banner-next
79+
aria-label={localizedText.nextLabel}
80+
>
81+
<span aria-hidden="true">›</span>
82+
</button>
83+
84+
<button
85+
type="button"
86+
class="docs-promote-banner__control docs-promote-banner__pause"
87+
data-promote-banner-pause
88+
aria-label={localizedText.pauseLabel}
89+
>
90+
{currentLocale === 'en' ? 'Pause' : '暂停'}
91+
</button>
92+
</div>
9193
</div>
9294

9395
<span class="docs-promote-banner__status sr-only" data-promote-banner-status aria-live="polite">
@@ -135,6 +137,12 @@ const localizedText = currentLocale === 'en'
135137
inset-inline: 0;
136138
bottom: calc(1rem + var(--docs-promote-banner-bottom-offset));
137139
z-index: 40;
140+
pointer-events: none;
141+
}
142+
143+
.docs-promote-banner__inner {
144+
position: relative;
145+
pointer-events: auto;
138146
display: grid;
139147
grid-template-columns: minmax(0, 1fr) auto;
140148
align-items: center;
@@ -163,15 +171,15 @@ const localizedText = currentLocale === 'en'
163171
display: none !important;
164172
}
165173

166-
.docs-promote-banner__shell::before,
167-
.docs-promote-banner__shell::after {
174+
.docs-promote-banner__inner::before,
175+
.docs-promote-banner__inner::after {
168176
content: '';
169177
position: absolute;
170178
pointer-events: none;
171179
z-index: 0;
172180
}
173181

174-
.docs-promote-banner__shell::before {
182+
.docs-promote-banner__inner::before {
175183
inset-block-start: -8rem;
176184
inset-inline-start: -4rem;
177185
width: 20rem;
@@ -187,7 +195,7 @@ const localizedText = currentLocale === 'en'
187195
animation: docs-promote-banner-glow 11s ease-in-out infinite alternate;
188196
}
189197

190-
.docs-promote-banner__shell::after {
198+
.docs-promote-banner__inner::after {
191199
inset: -24% auto -24% 42%;
192200
width: 42%;
193201
background: linear-gradient(
@@ -202,8 +210,8 @@ const localizedText = currentLocale === 'en'
202210
animation: docs-promote-banner-sheen 9s linear infinite;
203211
}
204212

205-
.docs-promote-banner__shell:hover,
206-
.docs-promote-banner__shell:focus-within {
213+
.docs-promote-banner__inner:hover,
214+
.docs-promote-banner__inner:focus-within {
207215
transform: translateY(-2px);
208216
border-color: color-mix(in srgb, var(--sl-color-accent-high) 34%, var(--sl-color-white) 10%);
209217
box-shadow:
@@ -371,8 +379,8 @@ const localizedText = currentLocale === 'en'
371379
transform: translateY(-1px);
372380
}
373381

374-
.docs-promote-banner__shell:hover .docs-promote-banner__cta,
375-
.docs-promote-banner__shell:focus-within .docs-promote-banner__cta {
382+
.docs-promote-banner__inner:hover .docs-promote-banner__cta,
383+
.docs-promote-banner__inner:focus-within .docs-promote-banner__cta {
376384
transform: translateX(2px);
377385
}
378386

@@ -428,6 +436,10 @@ const localizedText = currentLocale === 'en'
428436

429437
@media (max-width: 960px) {
430438
.docs-promote-banner__shell {
439+
width: 100%;
440+
}
441+
442+
.docs-promote-banner__inner {
431443
width: min(100vw - 1rem, 44rem);
432444
grid-template-columns: 1fr;
433445
gap: 0.75rem;
@@ -457,8 +469,12 @@ const localizedText = currentLocale === 'en'
457469

458470
@media (max-width: 640px) {
459471
.docs-promote-banner__shell {
460-
width: calc(100vw - 0.75rem);
461472
bottom: calc(0.5rem + var(--docs-promote-banner-bottom-offset));
473+
width: 100%;
474+
}
475+
476+
.docs-promote-banner__inner {
477+
width: calc(100vw - 0.75rem);
462478
padding: 1rem 3.45rem 0.85rem 0.85rem;
463479
border-radius: 1rem;
464480
}
@@ -498,8 +514,8 @@ const localizedText = currentLocale === 'en'
498514

499515
.docs-promote-banner__slide::before,
500516
.docs-promote-banner__slide::after,
501-
.docs-promote-banner__shell::before,
502-
.docs-promote-banner__shell::after {
517+
.docs-promote-banner__inner::before,
518+
.docs-promote-banner__inner::after {
503519
animation: none;
504520
}
505521
}

src/components/__tests__/DocsPromoteInfoBanner.test.tsx

Lines changed: 134 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ function createMatchMedia(matches = false): MatchMediaResult {
5454
function createFetchMock(promotions: Array<{
5555
id: string;
5656
on?: boolean;
57+
startTime?: string;
58+
endTime?: string;
5759
titleZh: string;
5860
titleEn: string;
5961
descriptionZh: string;
@@ -78,12 +80,14 @@ function createFetchMock(promotions: Array<{
7880
}
7981

8082
if (url.endsWith('/promote.json')) {
81-
return new Response(JSON.stringify({
82-
promotes: promotions.map((promotion) => ({
83-
id: promotion.id,
84-
on: promotion.on ?? true,
85-
})),
86-
}), { status: 200, headers: jsonHeaders });
83+
return new Response(JSON.stringify({
84+
promotes: promotions.map((promotion) => ({
85+
id: promotion.id,
86+
on: promotion.on ?? true,
87+
startTime: promotion.startTime,
88+
endTime: promotion.endTime,
89+
})),
90+
}), { status: 200, headers: jsonHeaders });
8791
}
8892

8993
if (url.endsWith('/promote_content.json')) {
@@ -109,15 +113,17 @@ function renderBannerShell(locale = 'en') {
109113
<docs-promote-banner data-locale="${locale}" hidden>
110114
<div data-promote-banner-spacer></div>
111115
<section data-promote-banner-shell hidden>
112-
<button type="button" data-promote-banner-close aria-label="${closeLabel}">×</button>
113-
<div>
114-
<div data-promote-banner-track></div>
115-
</div>
116-
<div>
117-
<button type="button" data-promote-banner-previous aria-label="Show previous promotion">‹</button>
118-
<span data-promote-banner-count></span>
119-
<button type="button" data-promote-banner-next aria-label="Show next promotion">›</button>
120-
<button type="button" data-promote-banner-pause aria-label="Pause automatic rotation">Pause</button>
116+
<div class="docs-promote-banner__inner">
117+
<button type="button" data-promote-banner-close aria-label="${closeLabel}">×</button>
118+
<div>
119+
<div data-promote-banner-track></div>
120+
</div>
121+
<div>
122+
<button type="button" data-promote-banner-previous aria-label="Show previous promotion">‹</button>
123+
<span data-promote-banner-count></span>
124+
<button type="button" data-promote-banner-next aria-label="Show next promotion">›</button>
125+
<button type="button" data-promote-banner-pause aria-label="Pause automatic rotation">Pause</button>
126+
</div>
121127
</div>
122128
<span data-promote-banner-status aria-live="polite"></span>
123129
</section>
@@ -211,6 +217,7 @@ describe('DocsPromoteBannerController', () => {
211217

212218
beforeEach(() => {
213219
vi.useFakeTimers();
220+
vi.setSystemTime(new Date('2026-04-28T23:59:59+08:00'));
214221
Object.defineProperty(window, 'innerHeight', {
215222
configurable: true,
216223
value: 900,
@@ -247,6 +254,118 @@ describe('DocsPromoteBannerController', () => {
247254
expect(spacer.style.height).toBe('0px');
248255
});
249256

257+
it('keeps pre-start promotions hidden until a refresh crosses the start time', async () => {
258+
const matchMedia = createMatchMedia(false);
259+
vi.stubGlobal('matchMedia', vi.fn(() => matchMedia));
260+
const { footer, root, shell } = renderBannerShell('en');
261+
262+
const controller = new DocsPromoteBannerController(root, {
263+
footer,
264+
fetchImpl: createFetchMock([
265+
{
266+
id: 'future',
267+
startTime: '2026-04-29T00:00:00+08:00',
268+
titleZh: '即将上线',
269+
titleEn: 'Starts Soon',
270+
descriptionZh: '预热中',
271+
descriptionEn: 'Warming up',
272+
link: 'https://example.invalid/future',
273+
platform: 'steam',
274+
},
275+
]) as typeof fetch,
276+
refreshIntervalMs: 1000,
277+
});
278+
279+
await controller.connect();
280+
281+
expect(root).toHaveAttribute('hidden');
282+
expect(shell).toHaveAttribute('hidden');
283+
284+
vi.setSystemTime(new Date('2026-04-29T00:00:00+08:00'));
285+
await (controller as unknown as { reloadPromotions: ({ forceRefresh }: { forceRefresh: boolean }) => Promise<void> })
286+
.reloadPromotions({ forceRefresh: true });
287+
(controller as unknown as { syncLayout: () => void }).syncLayout();
288+
289+
expect(root).not.toHaveAttribute('hidden');
290+
expect(getActiveSlideTitle(root)).toBe('Starts Soon');
291+
});
292+
293+
it('removes an expired promotion after refresh and hides the banner when no active entry remains', async () => {
294+
const matchMedia = createMatchMedia(false);
295+
vi.stubGlobal('matchMedia', vi.fn(() => matchMedia));
296+
const { footer, root, shell } = renderBannerShell('en');
297+
298+
const controller = new DocsPromoteBannerController(root, {
299+
footer,
300+
fetchImpl: createFetchMock([
301+
{
302+
id: 'expires-now',
303+
endTime: '2026-04-29T00:00:00+08:00',
304+
titleZh: '即将结束',
305+
titleEn: 'Ends Soon',
306+
descriptionZh: '最后一刻',
307+
descriptionEn: 'Last chance',
308+
link: 'https://example.invalid/ends',
309+
platform: 'steam',
310+
},
311+
]) as typeof fetch,
312+
refreshIntervalMs: 1000,
313+
});
314+
315+
await controller.connect();
316+
317+
expect(root).not.toHaveAttribute('hidden');
318+
319+
vi.setSystemTime(new Date('2026-04-29T00:00:00+08:00'));
320+
await vi.advanceTimersByTimeAsync(1000);
321+
322+
expect(root).toHaveAttribute('hidden');
323+
expect(shell).toHaveAttribute('hidden');
324+
expect(root.dataset.state).toBe('hidden');
325+
});
326+
327+
it('swaps promotions at the exact boundary on refresh', async () => {
328+
const matchMedia = createMatchMedia(false);
329+
vi.stubGlobal('matchMedia', vi.fn(() => matchMedia));
330+
const { footer, root } = renderBannerShell('en');
331+
332+
const controller = new DocsPromoteBannerController(root, {
333+
footer,
334+
fetchImpl: createFetchMock([
335+
{
336+
id: 'main-game-2026-04-29',
337+
endTime: '2026-04-29T00:00:00+08:00',
338+
titleZh: '愿望单',
339+
titleEn: 'Wishlist Now',
340+
descriptionZh: '旧推广',
341+
descriptionEn: 'Old promotion',
342+
link: 'https://example.invalid/wishlist',
343+
platform: 'steam',
344+
},
345+
{
346+
id: 'main-game-steam-ea-2026-04-29',
347+
startTime: '2026-04-29T00:00:00+08:00',
348+
titleZh: '抢先体验',
349+
titleEn: 'Early Access Is Live',
350+
descriptionZh: '新推广',
351+
descriptionEn: 'New promotion',
352+
link: 'https://example.invalid/ea',
353+
platform: 'steam',
354+
},
355+
]) as typeof fetch,
356+
refreshIntervalMs: 1000,
357+
});
358+
359+
await controller.connect();
360+
361+
expect(getActiveSlideTitle(root)).toBe('Wishlist Now');
362+
363+
vi.setSystemTime(new Date('2026-04-29T00:00:00+08:00'));
364+
await vi.advanceTimersByTimeAsync(1000);
365+
366+
expect(getActiveSlideTitle(root)).toBe('Early Access Is Live');
367+
});
368+
250369
it('keeps the entire banner hidden when promotion fetch fails', async () => {
251370
const matchMedia = createMatchMedia(false);
252371
vi.stubGlobal('matchMedia', vi.fn(() => matchMedia));

0 commit comments

Comments
 (0)