-
Notifications
You must be signed in to change notification settings - Fork 381
Expand file tree
/
Copy pathNavItem.tsx
More file actions
304 lines (277 loc) · 9.77 KB
/
NavItem.tsx
File metadata and controls
304 lines (277 loc) · 9.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
import {
cloneElement,
Fragment,
isValidElement,
useContext,
useEffect,
useRef,
useState,
forwardRef,
MutableRefObject
} from 'react';
import styles from '@patternfly/react-styles/css/components/Nav/nav';
import menuStyles from '@patternfly/react-styles/css/components/Menu/menu';
import dividerStyles from '@patternfly/react-styles/css/components/Divider/divider';
import { css } from '@patternfly/react-styles';
import { NavContext, NavContextProps, NavSelectClickHandler } from './Nav';
import { PageSidebarContext } from '../Page/PageSidebar';
import { PageContext } from '../Page/PageContext';
import { useOUIAProps, OUIAProps } from '../../helpers';
import { Popper } from '../../helpers/Popper/Popper';
import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon';
export interface NavItemProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'onClick'>, OUIAProps {
/** Content rendered inside the nav item. */
children?: React.ReactNode;
/** Whether to set className on children when React.isValidElement(children) */
styleChildren?: boolean;
/** Additional classes added to the nav item */
className?: string;
/** Target navigation link. Should not be used if the flyout prop is defined. */
to?: string;
/** Flag indicating whether the item is active */
isActive?: boolean;
/** Group identifier, will be returned with the onToggle and onSelect callback passed to the Nav component */
groupId?: string | number | null;
/** Item identifier, will be returned with the onToggle and onSelect callback passed to the Nav component */
itemId?: string | number | null;
/** If true prevents the default anchor link action to occur. Set to true if you want to handle navigation yourself. */
preventDefault?: boolean;
/** Callback for item click */
onClick?: NavSelectClickHandler;
/** Component used to render NavItems if React.isValidElement(children) is false */
component?: React.ElementType<any> | React.ComponentType<any>;
/** Flyout of a nav item. This should be a Menu component. Should not be used if the to prop is defined. */
flyout?: React.ReactElement<any>;
/** Callback when flyout is opened or closed */
onShowFlyout?: () => void;
/** z-index of the flyout nav item */
zIndex?: number;
/** Icon added before the nav item children. */
icon?: React.ReactNode;
/** Value to overwrite the randomly generated data-ouia-component-id.*/
ouiaId?: number | string;
/** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */
ouiaSafe?: boolean;
/** React ref for the anchor element within the nav item. */
anchorRef?: React.Ref<HTMLAnchorElement>;
/** @hide Forwarded ref */
innerRef?: React.Ref<HTMLLIElement>;
}
const NavItemBase: React.FunctionComponent<NavItemProps> = ({
children,
styleChildren = true,
className,
to,
isActive = false,
groupId = null as string,
itemId = null as string,
preventDefault = false,
onClick,
component = 'a',
flyout,
onShowFlyout,
ouiaId,
ouiaSafe,
zIndex = 9999,
icon,
innerRef,
anchorRef,
...props
}: NavItemProps) => {
const { flyoutRef, setFlyoutRef, navRef } = useContext(NavContext);
const { isSidebarOpen } = useContext(PageSidebarContext);
const { isManagedSidebar, isMobile, onSidebarToggle } = useContext(PageContext);
const [flyoutTarget, setFlyoutTarget] = useState(null);
const [isHovered, setIsHovered] = useState(false);
const _ref = useRef<HTMLLIElement>(undefined);
const ref = (innerRef as MutableRefObject<HTMLLIElement>) || _ref;
const flyoutVisible = ref === flyoutRef;
const popperRef = useRef<HTMLDivElement>(undefined);
const hasFlyout = flyout !== undefined;
const Component = hasFlyout ? 'button' : (component as any);
// A NavItem should not be both a link and a flyout
if (to && hasFlyout) {
// eslint-disable-next-line no-console
console.error('NavItem cannot have both "to" and "flyout" props.');
}
const showFlyout = (show: boolean, override?: boolean) => {
if ((!flyoutVisible || override) && show) {
setFlyoutRef(ref);
} else if ((flyoutVisible || override) && !show) {
setFlyoutRef(null);
}
onShowFlyout && show && onShowFlyout();
};
const onMouseOver = (event: React.MouseEvent) => {
const evtContainedInFlyout = (event.target as HTMLElement).closest(`.${styles.navItem}.pf-m-flyout`);
if (hasFlyout && !flyoutVisible) {
showFlyout(true);
} else if (flyoutRef !== null && !evtContainedInFlyout) {
setFlyoutRef(null);
}
};
const onFlyoutClick = (event: MouseEvent) => {
const target = event.target;
const closestItem = (target as HTMLElement).closest('.pf-m-flyout');
if (!closestItem) {
if (hasFlyout) {
showFlyout(false, true);
} else if (flyoutRef !== null) {
setFlyoutRef(null);
}
}
};
const handleFlyout = (event: KeyboardEvent) => {
const key = event.key;
const target = event.target as HTMLElement;
if ((key === ' ' || key === 'Enter' || key === 'ArrowRight') && hasFlyout && ref?.current?.contains(target)) {
event.stopPropagation();
event.preventDefault();
if (!flyoutVisible) {
showFlyout(true);
setFlyoutTarget(target as HTMLElement);
}
}
// We only want the NavItem to handle closing a flyout menu if only the first level flyout is open.
// Otherwise, MenuItem should handle closing its flyouts
if (
(key === 'Escape' || key === 'ArrowLeft') &&
popperRef?.current?.querySelectorAll(`.${menuStyles.menu}`).length === 1
) {
if (flyoutVisible) {
event.stopPropagation();
event.preventDefault();
showFlyout(false);
}
}
};
useEffect(() => {
if (hasFlyout) {
window.addEventListener('click', onFlyoutClick);
}
return () => {
if (hasFlyout) {
window.removeEventListener('click', onFlyoutClick);
}
};
}, []);
useEffect(() => {
if (flyoutTarget) {
if (flyoutVisible) {
const flyoutItems = Array.from(
(popperRef.current as HTMLElement).getElementsByTagName('UL')[0].children
).filter((el) => !(el.classList.contains('pf-m-disabled') || el.classList.contains(dividerStyles.divider)));
(flyoutItems[0].firstChild as HTMLElement).focus();
} else {
flyoutTarget.focus();
}
}
}, [flyoutVisible, flyoutTarget]);
const flyoutButton = (
<span className={css(styles.navToggle)}>
<span className={css(styles.navToggleIcon)}>
<AngleRightIcon />
</span>
</span>
);
const ariaFlyoutProps = {
'aria-haspopup': 'menu',
'aria-expanded': flyoutVisible
};
const tabIndex = isSidebarOpen ? null : -1;
const handleNavItemClick = (event: any, context: NavContextProps, preventLinkDefault: boolean) => {
context.onSelect?.(event, groupId, itemId, to, preventLinkDefault, onClick);
if (isManagedSidebar && isMobile && isSidebarOpen && !hasFlyout) {
onSidebarToggle();
}
};
const renderDefaultLink = (context: NavContextProps): React.ReactNode => {
const preventLinkDefault = preventDefault || !to;
return (
<Component
ref={anchorRef}
href={to}
onClick={(e: any) => handleNavItemClick(e, context, preventLinkDefault)}
className={css(
styles.navLink,
isActive && styles.modifiers.current,
isHovered && styles.modifiers.hover,
className
)}
aria-current={isActive ? 'page' : null}
tabIndex={tabIndex}
{...(hasFlyout && { ...ariaFlyoutProps })}
{...props}
>
{icon && <span className={css(styles.navLinkIcon)}>{icon}</span>}
<span className={css(`${styles.nav}__link-text`)}>{children}</span>
{flyout && flyoutButton}
</Component>
);
};
const renderClonedChild = (context: NavContextProps, child: React.ReactElement<any>): React.ReactNode =>
cloneElement(child, {
onClick: (e: MouseEvent) => handleNavItemClick(e, context, preventDefault),
'aria-current': isActive ? 'page' : null,
...(styleChildren && {
className: css(styles.navLink, isActive && styles.modifiers.current, child.props && child.props.className)
}),
tabIndex: child.props.tabIndex || tabIndex,
ref: anchorRef,
children: hasFlyout ? (
<Fragment>
{child.props.children}
{flyoutButton}
</Fragment>
) : (
child.props.children
)
});
const ouiaProps = useOUIAProps(NavItem.displayName, ouiaId, ouiaSafe);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const flyoutPopper = (
<Popper
triggerRef={ref}
popper={
<div ref={popperRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{flyout}
</div>
}
popperRef={popperRef}
placement="right-start"
isVisible={flyoutVisible}
onDocumentKeyDown={handleFlyout}
zIndex={zIndex}
appendTo={navRef?.current}
/>
);
const navItem = (
<>
<li
onMouseOver={onMouseOver}
className={css(styles.navItem, hasFlyout && styles.modifiers.flyout, className)}
ref={ref}
{...ouiaProps}
>
<NavContext.Consumer>
{(context) =>
isValidElement(children)
? renderClonedChild(context, children as React.ReactElement<any>)
: renderDefaultLink(context)
}
</NavContext.Consumer>
</li>
{flyout && flyoutPopper}
</>
);
return navItem;
};
export const NavItem = forwardRef<HTMLLIElement, NavItemProps>((props, ref) => (
<NavItemBase {...props} innerRef={ref} />
));
NavItem.displayName = 'NavItem';