Skip to content

Commit dc974b7

Browse files
committed
💫 UPDATE: convert expandable, heading, expandable-heading components to TypeScript
1 parent ab026c8 commit dc974b7

3 files changed

Lines changed: 94 additions & 70 deletions

File tree

client/layout/sidebar/expandable-heading.jsx renamed to client/layout/sidebar/expandable-heading.tsx

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
11
import { Count, Gridicon, MaterialIcon } from '@automattic/components';
22
import { Button } from '@wordpress/components';
33
import { Icon, chevronDown } from '@wordpress/icons';
4-
import { useTranslate } from 'i18n-calypso';
5-
import PropTypes from 'prop-types';
6-
import TranslatableString from 'calypso/components/translatable/proptype';
4+
import { useTranslate, type TranslateResult } from 'i18n-calypso';
75
import SidebarHeading from 'calypso/layout/sidebar/heading';
86
import { decodeEntities } from 'calypso/lib/formatting';
7+
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react';
8+
9+
interface ExpandableSidebarHeadingProps {
10+
title: TranslateResult;
11+
count: number;
12+
compactCount?: boolean;
13+
onClick?: ( event?: MouseEvent< HTMLAnchorElement > ) => void;
14+
customIcon?: ReactNode;
15+
icon?: string | null;
16+
materialIcon?: string | null;
17+
materialIconStyle?: string | null;
18+
expanded?: boolean;
19+
menuId?: string;
20+
hideExpandableIcon?: boolean;
21+
inlineText?: ReactNode;
22+
expandableIconClick?: () => void;
23+
prependContent?: ReactNode;
24+
appendContent?: ReactNode;
25+
navigationLabel?: string;
26+
url?: string;
27+
moreMenuActions?: JSX.Element;
28+
}
929

1030
const ExpandableSidebarHeading = ( {
1131
title,
@@ -24,8 +44,10 @@ const ExpandableSidebarHeading = ( {
2444
appendContent,
2545
moreMenuActions,
2646
...props
27-
} ) => {
47+
}: ExpandableSidebarHeadingProps ) => {
2848
const translate = useTranslate();
49+
const renderedTitle = typeof title === 'string' ? decodeEntities( title ) : title;
50+
2951
return (
3052
<SidebarHeading
3153
aria-controls={ menuId }
@@ -38,12 +60,12 @@ const ExpandableSidebarHeading = ( {
3860
<MaterialIcon
3961
className="sidebar__menu-icon"
4062
icon={ materialIcon }
41-
style={ materialIconStyle }
63+
style={ materialIconStyle ?? undefined }
4264
/>
4365
) }
4466
{ undefined !== customIcon && customIcon }
4567
<span className="sidebar__expandable-title">
46-
{ decodeEntities( title ) }
68+
{ renderedTitle }
4769
<span className="sidebar__actions-and-count">
4870
{ moreMenuActions }
4971
{ count > 0 && <Count count={ count } compact={ compactCount } /> }
@@ -56,11 +78,11 @@ const ExpandableSidebarHeading = ( {
5678
<Button
5779
variant="link"
5880
className="sidebar__expandable-button"
59-
onClick={ ( ev ) => {
81+
onClick={ ( ev: MouseEvent< HTMLButtonElement > ) => {
6082
ev.stopPropagation();
6183
expandableIconClick();
6284
} }
63-
onKeyDown={ ( ev ) => {
85+
onKeyDown={ ( ev: KeyboardEvent< HTMLButtonElement > ) => {
6486
// Prevent bubbling or the SidebarHeading's onClick will also trigger.
6587
if ( ev.key === 'Enter' ) {
6688
ev.stopPropagation();
@@ -76,20 +98,4 @@ const ExpandableSidebarHeading = ( {
7698
);
7799
};
78100

79-
ExpandableSidebarHeading.propTypes = {
80-
title: PropTypes.oneOfType( [ TranslatableString, PropTypes.element ] ).isRequired,
81-
count: PropTypes.number,
82-
compactCount: PropTypes.bool,
83-
onClick: PropTypes.func,
84-
customIcon: PropTypes.node,
85-
icon: PropTypes.string,
86-
materialIcon: PropTypes.string,
87-
materialIconStyle: PropTypes.string,
88-
hideExpandableIcon: PropTypes.bool,
89-
expandableIconClick: PropTypes.func,
90-
prependContent: PropTypes.node,
91-
appendContent: PropTypes.node,
92-
moreMenuActions: PropTypes.node,
93-
};
94-
95101
export default ExpandableSidebarHeading;
Lines changed: 52 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,72 @@
11
import clsx from 'clsx';
2-
import PropTypes from 'prop-types';
3-
import { Children, createRef, useMemo, useState, useRef, useLayoutEffect } from 'react';
4-
import TranslatableString from 'calypso/components/translatable/proptype';
2+
import {
3+
Children,
4+
createRef,
5+
isValidElement,
6+
useMemo,
7+
useState,
8+
useRef,
9+
useLayoutEffect,
10+
} from 'react';
511
import SidebarMenu from 'calypso/layout/sidebar/menu';
612
import HoverIntent from 'calypso/lib/hover-intent';
713
import { hasTouch } from 'calypso/lib/touch-detect';
814
import ExpandableSidebarHeading from './expandable-heading';
15+
import type { TranslateResult } from 'i18n-calypso';
16+
import type { MouseEvent, ReactNode } from 'react';
917

1018
const isTouch = hasTouch();
1119

12-
function containsSelectedSidebarItem( children ) {
20+
interface SidebarChildProps {
21+
selected?: boolean;
22+
children?: ReactNode;
23+
}
24+
25+
interface ExpandableSidebarMenuProps {
26+
className?: string;
27+
title: TranslateResult;
28+
count: number;
29+
compactCount?: boolean;
30+
onClick?: ( event?: MouseEvent< HTMLAnchorElement > ) => void;
31+
icon?: string | null;
32+
materialIcon?: string | null;
33+
materialIconStyle?: string | null;
34+
customIcon?: ReactNode;
35+
children?: ReactNode;
36+
disableFlyout?: boolean;
37+
expanded?: boolean | null;
38+
expandableIconClick?: () => void;
39+
prependContent?: ReactNode;
40+
appendContent?: ReactNode;
41+
moreMenuActions?: JSX.Element;
42+
}
43+
44+
function containsSelectedSidebarItem( children: ReactNode ): boolean {
1345
let selectedItemFound = false;
1446

1547
Children.forEach( children, ( child ) => {
1648
if ( selectedItemFound ) {
17-
return true;
49+
return;
1850
}
1951

20-
if ( child?.props?.selected ) {
21-
selectedItemFound = true;
22-
} else {
23-
const descendants = child?.props?.children;
52+
const props = isValidElement< SidebarChildProps >( child ) ? child.props : undefined;
2453

25-
if ( descendants ) {
26-
selectedItemFound = containsSelectedSidebarItem( descendants );
27-
}
54+
if ( props?.selected ) {
55+
selectedItemFound = true;
56+
} else if ( props?.children ) {
57+
selectedItemFound = containsSelectedSidebarItem( props.children );
2858
}
2959
} );
3060

3161
return selectedItemFound;
3262
}
3363

34-
const offScreen = ( submenu ) => {
64+
const offScreen = ( submenu: HTMLElement ) => {
3565
const rect = submenu.getBoundingClientRect();
3666
return rect.y + rect.height > window.innerHeight;
3767
};
3868

39-
export const ExpandableSidebarMenu = ( menuProps ) => {
69+
export const ExpandableSidebarMenu = ( menuProps: ExpandableSidebarMenuProps ) => {
4070
const {
4171
className,
4272
title,
@@ -49,24 +79,22 @@ export const ExpandableSidebarMenu = ( menuProps ) => {
4979
customIcon,
5080
children,
5181
disableFlyout,
82+
expanded: expandedProp,
5283
prependContent,
5384
appendContent,
5485
moreMenuActions,
5586
...props
5687
} = menuProps;
57-
let { expanded } = props;
58-
const menu = createRef(); // Needed for HoverIntent.
59-
const submenu = useRef();
88+
// A `null` `expanded` prop means "auto-detect" from whether a child is selected.
89+
const expanded = null === expandedProp ? containsSelectedSidebarItem( children ) : expandedProp;
90+
const menu = createRef< HTMLUListElement >(); // Needed for HoverIntent.
91+
const submenu = useRef< HTMLLIElement >( null );
6092
const [ submenuHovered, setSubmenuHovered ] = useState( false );
6193

6294
if ( submenu.current ) {
6395
// Sets flyout to expand towards bottom.
6496
submenu.current.style.bottom = 'auto';
65-
submenu.current.style.top = 0;
66-
}
67-
68-
if ( null === expanded ) {
69-
expanded = containsSelectedSidebarItem( children );
97+
submenu.current.style.top = '0';
7098
}
7199

72100
const classes = clsx( className, {
@@ -95,9 +123,9 @@ export const ExpandableSidebarMenu = ( menuProps ) => {
95123
const menuId = useMemo( () => 'menu' + crypto.randomUUID(), [] );
96124

97125
useLayoutEffect( () => {
98-
if ( submenuHovered && offScreen( submenu.current ) ) {
126+
if ( submenuHovered && submenu.current && offScreen( submenu.current ) ) {
99127
// Sets flyout to expand towards top.
100-
submenu.current.style.bottom = 0;
128+
submenu.current.style.bottom = '0';
101129
submenu.current.style.top = 'auto';
102130
}
103131
}, [ submenuHovered ] );
@@ -148,23 +176,4 @@ export const ExpandableSidebarMenu = ( menuProps ) => {
148176
);
149177
};
150178

151-
ExpandableSidebarMenu.propTypes = {
152-
className: PropTypes.string,
153-
title: PropTypes.oneOfType( [ TranslatableString, PropTypes.element ] ).isRequired,
154-
count: PropTypes.number,
155-
compactCount: PropTypes.bool,
156-
onClick: PropTypes.func,
157-
customIcon: PropTypes.node,
158-
icon: PropTypes.string,
159-
materialIcon: PropTypes.string,
160-
materialIconStyle: PropTypes.string,
161-
expanded: PropTypes.bool,
162-
disableFlyout: PropTypes.bool,
163-
expandableIconClick: PropTypes.func,
164-
prependContent: PropTypes.node,
165-
appendContent: PropTypes.node,
166-
moreMenuActions: PropTypes.node,
167-
children: PropTypes.node,
168-
};
169-
170179
export default ExpandableSidebarMenu;
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
const SidebarHeading = ( { children, onClick, ...props } ) => {
1+
import type { KeyboardEvent, KeyboardEventHandler, MouseEvent } from 'react';
2+
3+
interface SidebarHeadingProps {
4+
children?: React.ReactNode;
5+
navigationLabel?: string;
6+
url?: string;
7+
onClick?: ( event?: MouseEvent< HTMLAnchorElement > ) => void;
8+
}
9+
10+
const SidebarHeading = ( { children, onClick, ...props }: SidebarHeadingProps ) => {
211
const tabIndex = onClick ? 0 : -1;
312

4-
let onKeyDown = null;
13+
let onKeyDown: KeyboardEventHandler< HTMLAnchorElement > | undefined;
514

615
if ( onClick ) {
7-
onKeyDown = ( event ) => {
16+
onKeyDown = ( event: KeyboardEvent< HTMLAnchorElement > ) => {
817
// Trigger click for enter, similarly to default brower behavior for <a> or <button>
918
if ( 13 === event.keyCode ) {
1019
event.preventDefault();

0 commit comments

Comments
 (0)