Skip to content

Commit a6a763c

Browse files
Port layouts/default files to TypeScript (CORE-1259)
- Convert 22 JavaScript files to TypeScript with comprehensive type definitions - Add proper prop typing for all React components following Roy's guidelines - Maintain existing functionality while adding type safety throughout - Create comprehensive test file with TypeScript validation tests Key improvements: - StickyData types for campaign/donation data structures - MenuStructure types for CMS-driven navigation menus - Safe localStorage wrapper types with incognito mode handling - Context and hook return types for shared state management - Proper event handler types for keyboard navigation and user interactions Files converted: - shared.js -> shared.tsx (core utilities and hooks) - header/header.js -> header.tsx (main header component) - All menu components with proper prop typing - Dialog and popup systems with context management - Welcome components with enhanced type safety - All supporting utility components Test coverage: - TypeScript type validation tests - Component integration tests - Hook behavior verification - Complex type interaction tests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent d54e5ca commit a6a763c

File tree

23 files changed

+866
-98
lines changed

23 files changed

+866
-98
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from 'react';
2+
import JITLoad from '~/helpers/jit-load';
3+
import {useStickyData} from '../shared';
4+
import Menus from './menus/menus';
5+
import './header.scss';
6+
7+
export default function Header() {
8+
const stickyData = useStickyData();
9+
10+
return (
11+
<div className="page-header">
12+
<JITLoad importFn={() => import('./sticky-note/sticky-note.js')} stickyData={stickyData} />
13+
<Menus />
14+
</div>
15+
);
16+
}

src/app/layouts/default/header/menus/give-button/give-button.js renamed to src/app/layouts/default/header/menus/give-button/give-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ export default function GiveButton() {
1212
return (
1313
<a href={giveData.give_link} className="give-button medium" data-analytics-link>Give</a>
1414
);
15-
}
15+
}

src/app/layouts/default/header/menus/logo/logo.js renamed to src/app/layouts/default/header/menus/logo/logo.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ export default function Logo() {
1717
<span className="logo-quote">Access. The future of education.</span>
1818
</span>
1919
);
20-
}
20+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import React, {useRef} from 'react';
2+
import RawHTML from '~/components/jsx-helpers/raw-html';
3+
import useDropdownContext from '../../dropdown-context';
4+
import useWindowContext from '~/contexts/window';
5+
import useNavigateByKey from './use-navigate-by-key';
6+
import useMenuControls from './use-menu-controls';
7+
import {treatSpaceOrEnterAsClick} from '~/helpers/events';
8+
import {useLocation} from 'react-router-dom';
9+
import usePortalContext from '~/contexts/portal';
10+
import cn from 'classnames';
11+
import './dropdown.scss';
12+
13+
// Using ARIA Disclosure pattern rather than Menubar pattern
14+
// because menubar requires complexity that is not necessary
15+
// for ordinary website navigations, per
16+
// https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/
17+
18+
export function MenuItem({label, url, local = undefined}: {
19+
label: string;
20+
url: string;
21+
local?: string;
22+
}) {
23+
const {innerWidth: _} = useWindowContext();
24+
const urlPath = url.replace('/view-all', '');
25+
const {pathname} = useLocation();
26+
const {portalPrefix} = usePortalContext();
27+
28+
return (
29+
<RawHTML
30+
Tag="a"
31+
html={label}
32+
href={`${portalPrefix}${url}`}
33+
tabIndex={0}
34+
data-local={local}
35+
{...(urlPath === pathname ? {'aria-current': 'page'} : {})}
36+
/>
37+
);
38+
}
39+
40+
function OptionalWrapper({isWrapper = true, children}: {
41+
isWrapper?: boolean;
42+
children: React.ReactNode;
43+
}) {
44+
return isWrapper ? (
45+
<div className="nav-menu-item dropdown">{children}</div>
46+
) : (
47+
children
48+
);
49+
}
50+
51+
type DropdownProps = {
52+
Tag?: keyof JSX.IntrinsicElements;
53+
className?: string;
54+
label: string;
55+
children: React.ReactNode;
56+
excludeWrapper?: boolean;
57+
navAnalytics?: string;
58+
};
59+
60+
export default function Dropdown({
61+
Tag = 'li',
62+
className = undefined,
63+
label,
64+
children,
65+
excludeWrapper = false,
66+
navAnalytics
67+
}: DropdownProps) {
68+
const topRef = useRef<HTMLAnchorElement>(null);
69+
const dropdownRef = useRef<HTMLDivElement>(null);
70+
const ddId = `ddId-${label}`;
71+
const {
72+
closeMenu, closeDesktopMenu, openMenu, openDesktopMenu
73+
} = useMenuControls({topRef, label});
74+
const navigateByKey = useNavigateByKey({
75+
topRef,
76+
dropdownRef,
77+
closeMenu,
78+
closeDesktopMenu
79+
});
80+
81+
return (
82+
<Tag
83+
className={className}
84+
onMouseEnter={openDesktopMenu}
85+
onMouseLeave={closeDesktopMenu}
86+
onKeyDown={navigateByKey}
87+
>
88+
<OptionalWrapper isWrapper={!excludeWrapper}>
89+
<DropdownController
90+
ddId={ddId}
91+
closeDesktopMenu={closeDesktopMenu}
92+
closeMenu={closeMenu}
93+
openMenu={openMenu}
94+
label={label}
95+
topRef={topRef}
96+
/>
97+
<DropdownContents
98+
id={ddId}
99+
dropdownRef={dropdownRef}
100+
navAnalytics={navAnalytics}
101+
label={label}
102+
>
103+
{children}
104+
</DropdownContents>
105+
</OptionalWrapper>
106+
</Tag>
107+
);
108+
}
109+
110+
function DropdownController({
111+
ddId,
112+
closeDesktopMenu,
113+
topRef,
114+
closeMenu,
115+
openMenu,
116+
label
117+
}: {
118+
ddId: string;
119+
closeDesktopMenu: () => void;
120+
topRef: React.RefObject<HTMLAnchorElement>;
121+
closeMenu: () => void;
122+
openMenu: (event: React.MouseEvent | React.KeyboardEvent) => void;
123+
label: string;
124+
}) {
125+
const {activeDropdown, prefix} = useDropdownContext();
126+
const isOpen = activeDropdown === topRef;
127+
const labelId = `${prefix}-${label}`;
128+
const toggleMenu = React.useCallback(
129+
(event: React.MouseEvent | React.KeyboardEvent) => {
130+
if (activeDropdown === topRef) {
131+
event.preventDefault();
132+
closeMenu();
133+
} else {
134+
openMenu(event);
135+
}
136+
},
137+
[openMenu, closeMenu, activeDropdown, topRef]
138+
);
139+
const closeOnBlur = React.useCallback(
140+
(event: React.FocusEvent<HTMLAnchorElement>) => {
141+
if (event.currentTarget.parentNode?.contains(event.relatedTarget as Node)) {
142+
return;
143+
}
144+
closeDesktopMenu();
145+
},
146+
[closeDesktopMenu]
147+
);
148+
149+
return (
150+
<a
151+
role="button"
152+
href="."
153+
aria-expanded={isOpen}
154+
aria-controls={ddId}
155+
onBlur={closeOnBlur}
156+
ref={topRef}
157+
onClick={toggleMenu}
158+
onKeyDown={treatSpaceOrEnterAsClick}
159+
className={cn({'is-open': isOpen})}
160+
data-analytics-link={isOpen ? 'false' : ''}
161+
data-analytics-label={label}
162+
>
163+
<span id={labelId}>{label}</span>
164+
<svg
165+
className="chevron"
166+
xmlns="http://www.w3.org/2000/svg"
167+
viewBox="0 0 18 30"
168+
aria-hidden="true"
169+
>
170+
<title> arrow</title>
171+
<path
172+
d="M12,1L26,16,12,31,8,27,18,16,8,5Z"
173+
transform="translate(-8 -1)"
174+
/>
175+
</svg>
176+
</a>
177+
);
178+
}
179+
180+
function DropdownContents({id, label, dropdownRef, navAnalytics, children}: {
181+
id: string;
182+
label: string;
183+
dropdownRef: React.RefObject<HTMLDivElement>;
184+
navAnalytics?: string;
185+
children: React.ReactNode;
186+
}) {
187+
return (
188+
<div className="dropdown-container">
189+
<div
190+
className="dropdown-menu"
191+
id={id}
192+
aria-label={`${label} menu`}
193+
ref={dropdownRef}
194+
data-analytics-nav={navAnalytics || undefined}
195+
>
196+
{children}
197+
</div>
198+
</div>
199+
);
200+
}

src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.js renamed to src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ export default function LoginMenuWithDropdown() {
4949
<MenuItem label="Log out" url={linkHelper.logoutLink()} local="true" />
5050
</Dropdown>
5151
);
52-
}
52+
}

src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.js renamed to src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ function LoginLink() {
88
// It's not used directly, but loginLink changes when it does
99
useLocation();
1010
const addressHinkyQAIssue = React.useCallback(
11-
(e) => {
12-
if (e.defaultPrevented) {
13-
e.defaultPrevented = false;
11+
(e: React.MouseEvent<HTMLAnchorElement>) => {
12+
if ((e as React.MouseEvent & {defaultPrevented: boolean}).defaultPrevented) {
13+
(e as React.MouseEvent & {defaultPrevented: boolean}).defaultPrevented = false;
1414
}
1515
},
1616
[]
@@ -37,4 +37,4 @@ export default function LoginMenu() {
3737
<JITLoad importFn={() => import('./login-menu-with-dropdown')} /> :
3838
<LoginLink />
3939
);
40-
}
40+
}

src/app/layouts/default/header/menus/main-menu/main-menu.js renamed to src/app/layouts/default/header/menus/main-menu/main-menu.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,32 @@ import GiveButton from '../give-button/give-button';
1414
import {treatSpaceOrEnterAsClick} from '~/helpers/events';
1515
import './main-menu.scss';
1616

17-
function DropdownOrMenuItem({item}) {
17+
type MenuItemData = {
18+
name?: string;
19+
label?: string;
20+
partial_url?: string;
21+
menu?: MenuItemData[];
22+
};
23+
24+
function DropdownOrMenuItem({item}: {item: MenuItemData}) {
1825
if (! item.name && ! item.label) {
1926
return null;
2027
}
21-
if ('menu' in item) {
28+
if ('menu' in item && item.menu) {
2229
return (
2330
<Dropdown
24-
label={item.name}
31+
label={item.name!}
2532
navAnalytics={`Main Menu (${item.name})`}
2633
>
2734
<MenusFromStructure structure={item.menu} />
2835
</Dropdown>
2936
);
3037
}
3138

32-
return <MenuItem label={item.label} url={item.partial_url} />;
39+
return <MenuItem label={item.label!} url={item.partial_url!} />;
3340
}
3441

35-
function MenusFromStructure({structure}) {
42+
function MenusFromStructure({structure}: {structure: MenuItemData[]}) {
3643
return (
3744
<React.Fragment>
3845
{structure.map((item) => (
@@ -43,7 +50,7 @@ function MenusFromStructure({structure}) {
4350
}
4451

4552
function MenusFromCMS() {
46-
const structure = useDataFromSlug('oxmenus');
53+
const structure = useDataFromSlug('oxmenus') as MenuItemData[] | null;
4754

4855
if (!structure) {
4956
return null;
@@ -60,7 +67,7 @@ function SubjectsMenu() {
6067
const categories = useSubjectCategoryContext();
6168
const {language} = useLanguageContext();
6269
// This will have to be revisited if/when we implement more languages
63-
const otherLocale = ['en', 'es'].filter((la) => la !== language)[0];
70+
const otherLocale = (['en', 'es'] as const).filter((la) => la !== language)[0];
6471
const {pathname} = useLocation();
6572

6673
if (!categories.length) {
@@ -104,23 +111,24 @@ function SubjectsMenu() {
104111
);
105112
}
106113

107-
function navigateWithArrows(event) {
114+
function navigateWithArrows(event: React.KeyboardEvent<HTMLUListElement>) {
115+
const target = event.target as HTMLElement;
108116
switch (event.key) {
109117
case 'ArrowRight':
110118
event.preventDefault();
111119
event.stopPropagation();
112-
event.target
120+
(target
113121
.closest('li')
114-
.nextElementSibling?.querySelector('a')
115-
.focus();
122+
?.nextElementSibling?.querySelector('a') as HTMLAnchorElement)
123+
?.focus();
116124
break;
117125
case 'ArrowLeft':
118126
event.preventDefault();
119127
event.stopPropagation();
120-
event.target
128+
(target
121129
.closest('li')
122-
.previousElementSibling?.querySelector('a')
123-
.focus();
130+
?.previousElementSibling?.querySelector('a') as HTMLAnchorElement)
131+
?.focus();
124132
break;
125133
default:
126134
break;
@@ -151,4 +159,4 @@ export default function MainMenu() {
151159
<MainMenuItems />
152160
</ul>
153161
);
154-
}
162+
}

0 commit comments

Comments
 (0)