Skip to content

Commit 904bb59

Browse files
Merge pull request #2404 from chidiadi01/main
Added the announcement banner for the weekly Office Hours
2 parents 0effebe + 9ec531d commit 904bb59

3 files changed

Lines changed: 257 additions & 7 deletions

File tree

components/AnnouncementBanner.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { useState, useEffect } from 'react';
2+
3+
type AnnouncementBannerProps = {
4+
bannerRef?: React.RefObject<HTMLDivElement>;
5+
onHeightChange?: (height: number) => void;
6+
};
7+
8+
export default function AnnouncementBanner({
9+
bannerRef,
10+
onHeightChange,
11+
}: AnnouncementBannerProps) {
12+
const [visible, setVisible] = useState(false);
13+
const [textVisible, setTextVisible] = useState(false);
14+
const [dismissed, setDismissed] = useState(false);
15+
16+
useEffect(() => {
17+
const updateHeight = () => {
18+
onHeightChange?.(bannerRef?.current?.getBoundingClientRect().height ?? 0);
19+
};
20+
21+
updateHeight();
22+
window.addEventListener('resize', updateHeight);
23+
24+
return () => window.removeEventListener('resize', updateHeight);
25+
}, [bannerRef, onHeightChange, visible, dismissed]);
26+
27+
useEffect(() => {
28+
const visibleTimer = window.setTimeout(() => setVisible(true), 100);
29+
const textTimer = window.setTimeout(() => setTextVisible(true), 400);
30+
31+
return () => {
32+
window.clearTimeout(visibleTimer);
33+
window.clearTimeout(textTimer);
34+
};
35+
}, []);
36+
37+
if (dismissed) return null;
38+
39+
return (
40+
<div
41+
ref={bannerRef}
42+
data-testid='announcement-banner'
43+
className={`sticky top-0 z-50 bg-blue-700 text-white px-4 py-3 text-center transition-all duration-500 ease-in-out ${visible ? 'opacity-100' : 'opacity-0'}`}
44+
>
45+
<span
46+
data-testid='announcement-text'
47+
className={`inline-block transition-all duration-500 ease-out ${textVisible ? 'translate-x-0 opacity-100' : '-translate-x-8 opacity-0'}`}
48+
>
49+
The JSON Schema Office Hours Now Runs Weekly!{' '}
50+
<a
51+
href='https://github.com/orgs/json-schema-org/discussions/34'
52+
className='underline'
53+
>
54+
Join Us!
55+
</a>
56+
</span>
57+
58+
<button
59+
onClick={() => {
60+
setVisible(false);
61+
setTimeout(() => setDismissed(true), 500);
62+
}}
63+
aria-label='Dismiss banner'
64+
className='absolute right-3 top-1/2 -translate-y-1/2 text-white text-lg leading-none hover:text-violet-300 transition-colors'
65+
>
66+
67+
</button>
68+
</div>
69+
);
70+
}

components/Layout.tsx

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/* eslint-disable linebreak-style */
22
/* istanbul ignore file */
3-
import React, { useContext, useEffect, useState, useRef } from 'react';
3+
import React, {
4+
useCallback,
5+
useContext,
6+
useEffect,
7+
useState,
8+
useRef,
9+
} from 'react';
410
import Head from 'next/head';
511
import Link from 'next/link';
612
import classnames from 'classnames';
@@ -13,6 +19,7 @@ import DarkModeToggle from './DarkModeToggle';
1319
import ScrollButton from './ScrollButton';
1420
import Image from 'next/image';
1521
import { Button } from '@/components/ui/button';
22+
import AnnouncementBanner from './AnnouncementBanner';
1623

1724
type Props = {
1825
children: React.ReactNode;
@@ -37,12 +44,41 @@ export default function Layout({
3744
const router = useRouter();
3845

3946
const mobileNavRef = useRef<HTMLDivElement>(null);
47+
const announcementRef = useRef<HTMLDivElement>(null);
48+
const [announcementHeight, setAnnouncementHeight] = useState(0);
49+
const headerRef = useRef<HTMLDivElement>(null);
50+
const [headerHeight, setHeaderHeight] = useState(0);
51+
52+
useEffect(() => {
53+
const updateHeaderHeight = () => {
54+
setHeaderHeight(headerRef.current?.getBoundingClientRect().height ?? 0);
55+
};
56+
57+
updateHeaderHeight();
58+
window.addEventListener('resize', updateHeaderHeight);
59+
return () => window.removeEventListener('resize', updateHeaderHeight);
60+
}, [announcementHeight]);
61+
62+
const updateAnnouncementHeight = useCallback(() => {
63+
setAnnouncementHeight(
64+
announcementRef.current?.getBoundingClientRect().height ?? 0,
65+
);
66+
}, []);
4067

4168
React.useEffect(
4269
() => useStore.setState({ overlayNavigation: null }),
4370
[router.asPath],
4471
);
4572

73+
useEffect(() => {
74+
updateAnnouncementHeight();
75+
window.addEventListener('resize', updateAnnouncementHeight);
76+
77+
return () => {
78+
window.removeEventListener('resize', updateAnnouncementHeight);
79+
};
80+
}, [updateAnnouncementHeight]);
81+
4682
useEffect(() => {
4783
// Check if the URL contains "community"
4884
if (window.location.hash === 'community') {
@@ -92,22 +128,27 @@ export default function Layout({
92128
<main
93129
className={classnames(
94130
mainClassName,
95-
'z-10 h-screen xl:rounded-xl pt-4 mx-auto',
131+
'z-10 h-screen xl:rounded-xl mx-auto',
96132
// 'z-10 h-screen xl:rounded-xl pt-4 mx-auto',
97133
)}
98134
>
99135
<header
136+
ref={headerRef}
100137
className={classnames(
101138
'w-full bg-white dark:bg-slate-800 fixed top-0 z-[170] shadow-xl drop-shadow-lg',
102139
)}
103140
>
141+
<AnnouncementBanner
142+
bannerRef={announcementRef}
143+
onHeightChange={setAnnouncementHeight}
144+
/>
104145
<div className='flex w-full md:justify-between items-center ml-8 2xl:px-12 py-4'>
105146
<Logo />
106147
<MainNavigation />
107148
</div>
108149
</header>
109-
<div ref={mobileNavRef}>
110-
{showMobileNav && <MobileNav />}
150+
<div ref={mobileNavRef} style={{ paddingTop: headerHeight }}>
151+
{showMobileNav && <MobileNav topOffset={headerHeight} />}
111152
{children}
112153
</div>
113154
<ScrollButton />
@@ -261,7 +302,7 @@ const MainNavigation = () => {
261302
style={{
262303
backgroundImage: closeMenu,
263304
}}
264-
className='h-6 w-6 lg:hidden bg-center bg-[length:22px_22px] bg-no-repeat transition-all cursor-pointer dark:text-slate-300'
305+
className='h-6 w-6 lg:hidden bg-center bg-[length:22px_22px] bg-no-repeat transition-all cursor-pointer dark:text-slate-300 z-2[200]'
265306
onClick={() => useStore.setState({ overlayNavigation: null })}
266307
/>
267308
)}
@@ -298,11 +339,14 @@ const MainNavigation = () => {
298339
);
299340
};
300341

301-
const MobileNav = () => {
342+
const MobileNav = ({ topOffset }: { topOffset: number }) => {
302343
const section = useContext(SectionContext);
303344

304345
return (
305-
<div className='flex flex-col lg:hidden shadow-xl justify-end fixed bg-white w-full z-[190] top-16 left-0 pl-8 dark:bg-slate-800'>
346+
<div
347+
style={{ top: `${topOffset}px` }}
348+
className='flex flex-col lg:hidden shadow-xl justify-end fixed bg-white w-full z-[190] left-0 pl-8 dark:bg-slate-800'
349+
>
306350
<MainNavLink
307351
uri='/specification'
308352
label='Specification'
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React from 'react';
2+
import AnnouncementBanner from '~/components/AnnouncementBanner';
3+
4+
describe('AnnouncementBanner', () => {
5+
beforeEach(() => {
6+
cy.mount(<AnnouncementBanner />);
7+
});
8+
9+
describe('Initial render', () => {
10+
it('renders the banner on page load', () => {
11+
cy.get('[data-testid="announcement-banner"]', { timeout: 1000 })
12+
.should('exist')
13+
.and('be.visible');
14+
});
15+
16+
it('starts invisible then fades in', () => {
17+
// Re-mount to capture the initial opacity-0 state before the 100ms timeout
18+
cy.mount(<AnnouncementBanner />);
19+
20+
// Immediately after mount the banner should be in the DOM but opacity-0
21+
cy.get('[data-testid="announcement-banner"]')
22+
.should('exist')
23+
.and('have.class', 'opacity-0');
24+
25+
// After the 100ms delay it becomes visible
26+
cy.get('[data-testid="announcement-banner"]', { timeout: 1000 }).should(
27+
'have.class',
28+
'opacity-100',
29+
);
30+
});
31+
32+
it('displays the announcement text', () => {
33+
cy.contains('The JSON Schema Office Hours Now Runs Weekly!').should(
34+
'be.visible',
35+
);
36+
});
37+
38+
it('renders a "Join Us!" link with the correct href', () => {
39+
cy.get('a')
40+
.contains('Join Us!')
41+
.should('be.visible')
42+
.and(
43+
'have.attr',
44+
'href',
45+
'https://github.com/orgs/json-schema-org/discussions/34',
46+
);
47+
});
48+
49+
it('renders the dismiss button', () => {
50+
cy.get('button[aria-label="Dismiss banner"]').should('be.visible');
51+
});
52+
});
53+
54+
describe('Text slide-in animation', () => {
55+
it('text starts off-screen then slides into view', () => {
56+
cy.mount(<AnnouncementBanner />);
57+
58+
// Before 400ms the text span should be translated left and invisible
59+
cy.get('[data-testid="announcement-text"]')
60+
.should('have.class', '-translate-x-8')
61+
.and('have.class', 'opacity-0');
62+
63+
// After 400ms it should be in its natural position
64+
cy.get('[data-testid="announcement-text"]', { timeout: 1000 })
65+
.should('have.class', 'translate-x-0')
66+
.and('have.class', 'opacity-100');
67+
});
68+
});
69+
70+
describe('Dismiss behaviour', () => {
71+
it('hides the banner when the dismiss button is clicked', () => {
72+
// Wait for banner to be fully visible first
73+
cy.get('[data-testid="announcement-banner"]', { timeout: 1000 }).should(
74+
'have.class',
75+
'opacity-100',
76+
);
77+
78+
cy.get('button[aria-label="Dismiss banner"]').click();
79+
80+
// Banner fades out (opacity-0) before being removed
81+
cy.get('[data-testid="announcement-banner"]').should(
82+
'have.class',
83+
'opacity-0',
84+
);
85+
86+
// After the 500ms transition the component is removed from the DOM
87+
cy.get('[data-testid="announcement-banner"]', { timeout: 1000 }).should(
88+
'not.exist',
89+
);
90+
});
91+
92+
it('does not re-appear after being dismissed', () => {
93+
cy.get('[data-testid="announcement-banner"]', { timeout: 1000 }).should(
94+
'have.class',
95+
'opacity-100',
96+
);
97+
98+
cy.get('button[aria-label="Dismiss banner"]').click();
99+
100+
cy.get('[data-testid="announcement-banner"]', { timeout: 1000 }).should(
101+
'not.exist',
102+
);
103+
104+
// Wait an extra moment to confirm it stays gone
105+
cy.get('[data-testid="announcement-banner"]', { timeout: 1000 }).should(
106+
'not.exist',
107+
);
108+
});
109+
});
110+
111+
describe('Accessibility', () => {
112+
it('dismiss button has an accessible aria-label', () => {
113+
cy.get('button[aria-label="Dismiss banner"]').should('exist');
114+
});
115+
});
116+
117+
describe('onHeightChange callback', () => {
118+
it('calls onHeightChange when the banner mounts', () => {
119+
const onHeightChange = cy.stub().as('onHeightChange');
120+
cy.mount(<AnnouncementBanner onHeightChange={onHeightChange} />);
121+
122+
// The callback should be invoked at least once on mount
123+
cy.get('@onHeightChange').should('have.been.called');
124+
});
125+
126+
it('calls onHeightChange with a numeric height value', () => {
127+
const onHeightChange = cy.stub().as('onHeightChange');
128+
cy.mount(<AnnouncementBanner onHeightChange={onHeightChange} />);
129+
130+
cy.get('@onHeightChange').should(
131+
'have.been.calledWithMatch',
132+
Cypress.sinon.match.number,
133+
);
134+
});
135+
});
136+
});

0 commit comments

Comments
 (0)