Skip to content

Commit 24cc8da

Browse files
refactor(promote-banner): improve footer interaction and visibility states
Refactor banner visibility logic to use explicit state management instead of CSS offset calculations. Hide banner completely while footer is visible and restore it smoothly after scrolling away. Changes: - Add footer-hidden state with opacity and visibility transitions - Remove bottom-offset CSS variable in favor of ARIA attributes - Pause rotation timer while footer-hidden to prevent unnecessary updates - Add inert attribute and blur active element when footer overlaps - Update tests to verify footer-hidden behavior and state persistence - Ensure dismissed banner remains hidden regardless of footer state Co-Authored-By: Hagicode <noreply@hagicode.com> Signed-off-by: newbe36524 <newbe36524@qq.com>
1 parent a84110f commit 24cc8da

3 files changed

Lines changed: 170 additions & 28 deletions

File tree

src/components/DocsPromoteInfoBanner.astro

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ const localizedText = currentLocale === 'en'
139139

140140
.docs-promote-banner__shell {
141141
--docs-promote-banner-base-bottom: max(1rem, env(safe-area-inset-bottom));
142-
--docs-promote-banner-bottom-offset: 0px;
143142
--docs-promote-banner-border: color-mix(in srgb, var(--sl-color-accent) 24%, var(--sl-color-gray-5));
144143
--docs-promote-banner-surface:
145144
linear-gradient(
@@ -163,9 +162,14 @@ const localizedText = currentLocale === 'en'
163162
--docs-promote-banner-count-color: color-mix(in srgb, var(--sl-color-white) 70%, var(--sl-color-gray-3));
164163
position: fixed;
165164
inset-inline: 0;
166-
bottom: calc(var(--docs-promote-banner-base-bottom) + var(--docs-promote-banner-bottom-offset));
165+
bottom: var(--docs-promote-banner-base-bottom);
167166
z-index: 40;
168167
pointer-events: none;
168+
opacity: 1;
169+
visibility: visible;
170+
transition:
171+
opacity 0.24s ease,
172+
visibility 0s linear 0s;
169173
}
170174

171175
[data-theme='light'] .docs-promote-banner__shell {
@@ -216,18 +220,33 @@ const localizedText = currentLocale === 'en'
216220
box-shadow: var(--docs-promote-banner-shadow);
217221
backdrop-filter: blur(18px);
218222
transition:
223+
opacity 0.24s ease,
219224
transform 0.2s ease,
220225
border-color 0.2s ease,
221226
box-shadow 0.2s ease;
222227
}
223228

224-
.docs-promote-banner__inner:hover,
225-
.docs-promote-banner__inner:focus-within {
229+
.docs-promote-banner-host:not([data-state='footer-hidden']) .docs-promote-banner__inner:hover,
230+
.docs-promote-banner-host:not([data-state='footer-hidden']) .docs-promote-banner__inner:focus-within {
226231
transform: translateY(-2px);
227232
border-color: color-mix(in srgb, var(--sl-color-accent-high) 32%, var(--docs-promote-banner-border));
228233
box-shadow: 0 28px 86px color-mix(in srgb, var(--sl-color-black) 26%, transparent);
229234
}
230235

236+
.docs-promote-banner-host[data-state='footer-hidden'] .docs-promote-banner__shell {
237+
opacity: 0;
238+
visibility: hidden;
239+
transition:
240+
opacity 0.24s ease,
241+
visibility 0s linear 0.24s;
242+
}
243+
244+
.docs-promote-banner-host[data-state='footer-hidden'] .docs-promote-banner__inner {
245+
pointer-events: none;
246+
opacity: 0;
247+
transform: translateY(0.5rem);
248+
}
249+
231250
.docs-promote-banner__close {
232251
position: absolute;
233252
inset-block-start: 0.85rem;

src/components/__tests__/DocsPromoteInfoBanner.test.tsx

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ describe('DocsPromoteBannerController', () => {
339339
expect(root.querySelector('[data-promote-banner-controls]')).toHaveAttribute('hidden');
340340
});
341341

342-
it('keeps the banner visible on ordinary Starlight page shells and docks above the footer instead of hiding', async () => {
342+
it('hides the banner while the footer is visible and restores it after scrolling away', async () => {
343343
const matchMedia = createMatchMedia(false);
344344
vi.stubGlobal('matchMedia', vi.fn(() => matchMedia));
345345
const { footer, root, shell } = renderBannerShell({
@@ -362,14 +362,57 @@ describe('DocsPromoteBannerController', () => {
362362
expect(root).not.toHaveAttribute('hidden');
363363
expect(root.dataset.mode).toBe('fallback');
364364
expect(root.dataset.state).toBe('ready');
365-
expect(shell.style.getPropertyValue('--docs-promote-banner-bottom-offset')).toBe('0px');
365+
expect(getActiveSlideTitle(root)).toBe('Explore the HagiCode Product Overview');
366+
expect(shell.style.getPropertyValue('--docs-promote-banner-bottom-offset')).toBe('');
366367

367368
setFooterRect(footer, 760);
368369
syncLayout(controller);
369370

370371
expect(root).not.toHaveAttribute('hidden');
371-
expect(root.dataset.state).toBe('docked');
372-
expect(shell.style.getPropertyValue('--docs-promote-banner-bottom-offset')).toBe('140px');
372+
expect(root.dataset.state).toBe('footer-hidden');
373+
expect(shell).toHaveAttribute('aria-hidden', 'true');
374+
expect(shell).toHaveAttribute('inert');
375+
expect(shell.style.getPropertyValue('--docs-promote-banner-bottom-offset')).toBe('');
376+
377+
setFooterRect(footer, 1200);
378+
syncLayout(controller);
379+
380+
expect(root).not.toHaveAttribute('hidden');
381+
expect(root.dataset.state).toBe('ready');
382+
expect(shell).not.toHaveAttribute('aria-hidden');
383+
expect(shell).not.toHaveAttribute('inert');
384+
expect(getActiveSlideTitle(root)).toBe('Explore the HagiCode Product Overview');
385+
});
386+
387+
it('keeps the dismissed banner hidden when footer visibility changes for the same promotion set', async () => {
388+
const matchMedia = createMatchMedia(false);
389+
vi.stubGlobal('matchMedia', vi.fn(() => matchMedia));
390+
const { footer, root } = renderBannerShell({
391+
locale: 'en',
392+
path: '/en/product-overview/',
393+
});
394+
395+
const controller = new DocsPromoteBannerController(root, {
396+
footer,
397+
fetchImpl: createFetchMock([]) as typeof fetch,
398+
refreshIntervalMs: 60_000,
399+
});
400+
401+
await controller.connect();
402+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss promotion message' }));
403+
404+
expect(root).toHaveAttribute('hidden');
405+
expect(root.dataset.state).toBe('dismissed');
406+
407+
setFooterRect(footer, 760);
408+
syncLayout(controller);
409+
expect(root).toHaveAttribute('hidden');
410+
expect(root.dataset.state).toBe('dismissed');
411+
412+
setFooterRect(footer, 1200);
413+
syncLayout(controller);
414+
expect(root).toHaveAttribute('hidden');
415+
expect(root.dataset.state).toBe('dismissed');
373416
});
374417

375418
it('keeps fallback dismissal persisted for the same payload but allows a later remote promotion to reappear', async () => {
@@ -474,6 +517,60 @@ describe('DocsPromoteBannerController', () => {
474517
expect(screen.getByText('2 / 2')).toBeInTheDocument();
475518
});
476519

520+
it('pauses automatic rotation while footer-hidden and resumes after the footer leaves the viewport', async () => {
521+
const matchMedia = createMatchMedia(false);
522+
vi.stubGlobal('matchMedia', vi.fn(() => matchMedia));
523+
const { footer, root } = renderBannerShell({
524+
locale: 'en',
525+
path: '/en/guides/skills/',
526+
});
527+
528+
const controller = new DocsPromoteBannerController(root, {
529+
footer,
530+
fetchImpl: createFetchMock([
531+
{
532+
id: 'main-game',
533+
titleZh: '立即添加到愿望单',
534+
titleEn: 'Wishlist Now',
535+
descriptionZh: '游戏将于 2026-04-29 发售,立即前往 Steam 添加愿望单。',
536+
descriptionEn: 'Coming April 29, 2026. Add to your Steam wishlist now!',
537+
link: 'https://store.steampowered.com/app/4625540/Hagicode/',
538+
platform: 'steam',
539+
},
540+
{
541+
id: 'builder',
542+
titleZh: '体验部署生成器',
543+
titleEn: 'Try the Builder',
544+
descriptionZh: '使用 Docker Compose Builder 快速生成部署配置。',
545+
descriptionEn: 'Generate Docker Compose deployment files with the Builder.',
546+
link: 'https://builder.hagicode.com/',
547+
platform: 'web',
548+
},
549+
]) as typeof fetch,
550+
rotationIntervalMs: 1000,
551+
refreshIntervalMs: 60_000,
552+
});
553+
554+
await controller.connect();
555+
556+
expect(getActiveSlideTitle(root)).toBe('Wishlist Now');
557+
setFooterRect(footer, 760);
558+
syncLayout(controller);
559+
expect(root.dataset.state).toBe('footer-hidden');
560+
561+
await vi.advanceTimersByTimeAsync(2000);
562+
expect(getActiveSlideTitle(root)).toBe('Wishlist Now');
563+
expect(screen.getByText('1 / 2')).toBeInTheDocument();
564+
565+
setFooterRect(footer, 1200);
566+
syncLayout(controller);
567+
expect(root.dataset.state).toBe('ready');
568+
569+
await vi.advanceTimersByTimeAsync(1000);
570+
expect(getActiveSlideTitle(root)).toBe('Try the Builder');
571+
expect(screen.getByText('2 / 2')).toBeInTheDocument();
572+
});
573+
477574
it('supports pause and resume while automatic rotation is available', async () => {
478575
const matchMedia = createMatchMedia(false);
479576
vi.stubGlobal('matchMedia', vi.fn(() => matchMedia));

src/lib/docs-promote-banner.ts

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface DocsPromoteBannerControllerOptions extends LoadActivePromotions
4848
refreshIntervalMs?: number;
4949
}
5050

51+
type BannerVisibilityState = 'dismissed' | 'footer-hidden' | 'hidden' | 'ready';
52+
5153
function defaultBadgeText(locale: PromoteLocale): string {
5254
return locale === 'en' ? 'Promoted' : '推荐';
5355
}
@@ -590,10 +592,13 @@ export class DocsPromoteBannerController {
590592
private syncRotationTimer(): void {
591593
this.stopRotationTimer();
592594

595+
const visibilityState = this.getVisibilityState();
596+
593597
if (
594598
this.promotions.length < 2 ||
595599
this.isPaused ||
596-
this.prefersReducedMotion
600+
this.prefersReducedMotion ||
601+
visibilityState !== 'ready'
597602
) {
598603
return;
599604
}
@@ -659,15 +664,6 @@ export class DocsPromoteBannerController {
659664
}
660665

661666
this.setSpacerHeight(0);
662-
663-
let bottomOffset = 0;
664-
if (this.footer) {
665-
const footerRect = this.footer.getBoundingClientRect();
666-
bottomOffset = Math.max(window.innerHeight - footerRect.top, 0);
667-
bottomOffset = Math.min(bottomOffset, footerRect.height);
668-
}
669-
670-
this.shell.style.setProperty('--docs-promote-banner-bottom-offset', `${bottomOffset}px`);
671667
this.syncRotationTimer();
672668
}
673669

@@ -691,29 +687,59 @@ export class DocsPromoteBannerController {
691687
}
692688

693689
private applyVisibilityState(): boolean {
694-
const hasPromotions = this.promotions.length > 0;
695-
const dismissed = this.isCurrentPromotionDismissed();
696-
const footerVisible = this.isFooterVisible();
697-
const shouldShow = hasPromotions && !dismissed;
690+
const visibilityState = this.getVisibilityState();
691+
const shouldShow = visibilityState === 'ready' || visibilityState === 'footer-hidden';
692+
const footerHidden = visibilityState === 'footer-hidden';
698693

699694
setElementHidden(this.root, !shouldShow);
700695
setElementHidden(this.shell, !shouldShow);
701696
setElementHidden(this.spacer, !shouldShow);
702697
this.root.dataset.mode = this.promotions[0]?.source ?? 'hidden';
703-
this.root.dataset.state = shouldShow
704-
? (footerVisible ? 'docked' : 'ready')
705-
: dismissed
706-
? 'dismissed'
707-
: 'hidden';
698+
this.root.dataset.state = visibilityState;
699+
this.syncFooterHiddenState(footerHidden);
708700

709701
if (!shouldShow) {
710702
this.setSpacerHeight(0);
711-
this.shell?.style.removeProperty('--docs-promote-banner-bottom-offset');
712703
}
713704

714705
return shouldShow;
715706
}
716707

708+
private syncFooterHiddenState(footerHidden: boolean): void {
709+
if (!this.shell) {
710+
return;
711+
}
712+
713+
if (footerHidden) {
714+
this.shell.setAttribute('aria-hidden', 'true');
715+
this.shell.setAttribute('inert', '');
716+
717+
if (
718+
document.activeElement instanceof HTMLElement &&
719+
this.shell.contains(document.activeElement)
720+
) {
721+
document.activeElement.blur();
722+
}
723+
724+
return;
725+
}
726+
727+
this.shell.removeAttribute('aria-hidden');
728+
this.shell.removeAttribute('inert');
729+
}
730+
731+
private getVisibilityState(): BannerVisibilityState {
732+
if (this.promotions.length === 0) {
733+
return 'hidden';
734+
}
735+
736+
if (this.isCurrentPromotionDismissed()) {
737+
return 'dismissed';
738+
}
739+
740+
return this.isFooterVisible() ? 'footer-hidden' : 'ready';
741+
}
742+
717743
private isFooterVisible(): boolean {
718744
if (!this.footer) {
719745
return false;

0 commit comments

Comments
 (0)