Skip to content

Commit 194fffc

Browse files
feat(SideNav): add description and onVisibleLevelChange callback (#2488)
* docs: add comment * feat: add description * feat: add jsdocs for onLevelChange * Create lucky-toys-hunt.md * test: update snapshots for sidenav * feat: rename prop to onVisibleLevelChange * Update lucky-toys-hunt.md
1 parent 4b36c3d commit 194fffc

File tree

7 files changed

+770
-499
lines changed

7 files changed

+770
-499
lines changed

.changeset/lucky-toys-hunt.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@razorpay/blade": minor
3+
---
4+
5+
feat(SideNav): add `description` on SideNavLink and `onVisibleLevelChange` callback on `SideNav`

packages/blade/src/components/SideNav/SideNav.web.tsx

+40-18
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ const BannerContainer = styled(BaseBox)((props) => {
123123
*
124124
*/
125125
const _SideNav = (
126-
{ children, isOpen, onDismiss, banner, testID, ...rest }: SideNavProps,
126+
{ children, isOpen, onDismiss, onVisibleLevelChange, banner, testID, ...rest }: SideNavProps,
127127
ref: React.Ref<BladeElementRef>,
128128
): React.ReactElement => {
129129
const l2PortalContainerRef = React.useRef(null);
@@ -143,6 +143,7 @@ const _SideNav = (
143143
if (isMobile) {
144144
setIsMobileL2Open(false);
145145
onDismiss?.();
146+
onVisibleLevelChange?.({ visibleLevel: 0 });
146147
}
147148
};
148149

@@ -155,6 +156,36 @@ const _SideNav = (
155156
timeoutIdsRef.current.push(clearTransitionTimeout);
156157
};
157158

159+
const collapseL1 = (title: string): void => {
160+
if (isMobile) {
161+
setL2DrawerTitle(title);
162+
setIsMobileL2Open(true);
163+
onVisibleLevelChange?.({ visibleLevel: 2 });
164+
return;
165+
}
166+
167+
if (!isL1Collapsed) {
168+
setIsL1Collapsed(true);
169+
onVisibleLevelChange?.({ visibleLevel: 2 });
170+
}
171+
};
172+
173+
const expandL1 = (): void => {
174+
if (isMobile) {
175+
setIsMobileL2Open(false);
176+
onVisibleLevelChange?.({ visibleLevel: 1 });
177+
return;
178+
}
179+
// Ensures that if Normal L1 item is clicked, the L1 stays expanded
180+
if (isL1Collapsed) {
181+
setIsL1Collapsed(false);
182+
// We want to avoid calling onVisibleLevelChange twice when L1 is hovered and then item on L1 is selected
183+
if (!isL1Hovered) {
184+
onVisibleLevelChange?.({ visibleLevel: 1 });
185+
}
186+
}
187+
};
188+
158189
/**
159190
* Handles L1 -> L2 menu changes based on active item
160191
*/
@@ -164,13 +195,7 @@ const _SideNav = (
164195
if (isL1ItemActive) {
165196
if (args.isL2Trigger) {
166197
// Click on L2 Trigger
167-
if (isMobile) {
168-
setL2DrawerTitle(args.title);
169-
setIsMobileL2Open(true);
170-
return;
171-
}
172-
173-
setIsL1Collapsed(true);
198+
collapseL1(args.title);
174199

175200
// `args.isFirstRender` checks if the item that triggered this change, triggered it during first render or during subsequent change
176201
if (!args.isFirstRender) {
@@ -186,12 +211,7 @@ const _SideNav = (
186211
}
187212
} else {
188213
// Click on normal L1 Item
189-
// eslint-disable-next-line no-lonely-if
190-
if (isMobile) {
191-
setIsMobileL2Open(false);
192-
}
193-
// Ensures that if Normal L1 item is clicked, the L1 stays expanded
194-
setIsL1Collapsed(false);
214+
expandL1();
195215
}
196216
}
197217
};
@@ -205,7 +225,7 @@ const _SideNav = (
205225
setIsL1Collapsed,
206226
}),
207227
// eslint-disable-next-line react-hooks/exhaustive-deps
208-
[isL1Collapsed, isMobile, isMobileL2Open],
228+
[isL1Collapsed, isMobile, isMobileL2Open, isL1Hovered],
209229
);
210230

211231
React.useEffect(() => {
@@ -222,7 +242,7 @@ const _SideNav = (
222242
{isMobile && onDismiss ? (
223243
<>
224244
{/* L1 */}
225-
<Drawer isOpen={isOpen ?? false} onDismiss={onDismiss}>
245+
<Drawer isOpen={isOpen ?? false} onDismiss={closeMobileNav}>
226246
<DrawerHeader title="Main Menu" />
227247
<DrawerBody>
228248
<MobileL1Container
@@ -241,7 +261,7 @@ const _SideNav = (
241261
</DrawerBody>
242262
</Drawer>
243263
{/* L2 */}
244-
<Drawer isOpen={isMobileL2Open} onDismiss={() => setIsMobileL2Open(false)} isLazy={false}>
264+
<Drawer isOpen={isMobileL2Open} onDismiss={() => expandL1()} isLazy={false}>
245265
<DrawerHeader title={l2DrawerTitle} />
246266
<DrawerBody>
247267
<BaseBox ref={l2PortalContainerRef} />
@@ -320,8 +340,9 @@ const _SideNav = (
320340
if (mouseOverTimeoutRef.current) {
321341
clearTimeout(mouseOverTimeoutRef.current);
322342
}
323-
if (isL1Collapsed && isHoverAgainEnabled) {
343+
if (isL1Collapsed && isHoverAgainEnabled && !isL1Hovered) {
324344
setIsL1Hovered(true);
345+
onVisibleLevelChange?.({ visibleLevel: 1 });
325346
}
326347
}}
327348
onMouseLeave={() => {
@@ -330,6 +351,7 @@ const _SideNav = (
330351
setIsL1Hovered(false);
331352
setIsTransitioning(true);
332353
cleanupTransition();
354+
onVisibleLevelChange?.({ visibleLevel: 2 });
333355
}, L1_EXIT_HOVER_DELAY);
334356
}
335357
}}

packages/blade/src/components/SideNav/SideNavItems/SideNavLink.web.tsx

+89-34
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import { getFocusRingStyles } from '~utils/getFocusRingStyles';
1818
import { useIsomorphicLayoutEffect } from '~utils/useIsomorphicLayoutEffect';
1919
import { throwBladeError } from '~utils/logger';
2020
import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute';
21+
import { Text } from '~components/Typography';
2122

2223
const { SHOW_ON_LINK_HOVER, HIDE_WHEN_COLLAPSED, STYLED_NAV_LINK } = classes;
2324

24-
const StyledNavLinkContainer = styled(BaseBox)((props) => {
25+
const StyledNavLinkContainer = styled(BaseBox)<{ $hasDescription: boolean }>((props) => {
2526
return {
2627
width: '100%',
2728
[`.${SHOW_ON_LINK_HOVER}`]: {
@@ -45,14 +46,15 @@ const StyledNavLinkContainer = styled(BaseBox)((props) => {
4546
display: 'flex',
4647
flexDirection: 'row',
4748
alignItems: 'center',
48-
justifyContent: 'space-between',
49-
height: makeSize(NAV_ITEM_HEIGHT),
49+
height: props.$hasDescription ? undefined : makeSize(NAV_ITEM_HEIGHT),
5050
width: '100%',
5151
textDecoration: 'none',
5252
overflow: 'hidden',
5353
flexWrap: 'nowrap',
5454
cursor: 'pointer',
55-
padding: `${makeSpace(props.theme.spacing[0])} ${makeSpace(props.theme.spacing[4])}`,
55+
padding: `${makeSpace(props.theme.spacing[props.$hasDescription ? 3 : 0])} ${makeSpace(
56+
props.theme.spacing[4],
57+
)}`,
5658
margin: `${makeSpace(props.theme.spacing[1])} ${makeSpace(props.theme.spacing[0])}`,
5759
color: props.theme.colors.interactive.text.gray.subtle,
5860
borderRadius: props.theme.border.radius.medium,
@@ -77,40 +79,66 @@ const StyledNavLinkContainer = styled(BaseBox)((props) => {
7779
const NavLinkIconTitle = ({
7880
icon: Icon,
7981
title,
82+
description,
8083
titleSuffix,
84+
isActive,
85+
trailing,
8186
isL1Item,
82-
}: Pick<SideNavLinkProps, 'title' | 'icon' | 'titleSuffix'> & {
87+
}: Pick<
88+
SideNavLinkProps,
89+
'title' | 'isActive' | 'trailing' | 'description' | 'icon' | 'titleSuffix'
90+
> & {
8391
isL1Item: boolean;
8492
}): React.ReactElement => {
8593
return (
86-
<Box display="flex" flexDirection="row" gap="spacing.3">
87-
{Icon ? (
88-
<BaseBox display="flex" flexDirection="row" alignItems="center" justifyContent="center">
89-
<Icon size="medium" color="currentColor" />
90-
</BaseBox>
91-
) : null}
92-
<BaseText
93-
truncateAfterLines={1}
94-
color="currentColor"
95-
fontWeight="medium"
96-
fontSize={100}
97-
lineHeight={100}
98-
as="p"
99-
className={isL1Item ? HIDE_WHEN_COLLAPSED : ''}
100-
>
101-
{title}
102-
</BaseText>
103-
{titleSuffix ? (
104-
<BaseBox display="flex" alignItems="center">
105-
{titleSuffix}
106-
</BaseBox>
94+
<Box width="100%" textAlign="left">
95+
<Box display="flex" justifyContent="space-between" width="100%">
96+
<Box display="flex" flexDirection="row" gap="spacing.3" alignItems="center">
97+
{Icon ? (
98+
<BaseBox display="flex" flexDirection="row" alignItems="center">
99+
<Icon size="medium" color="currentColor" />
100+
</BaseBox>
101+
) : null}
102+
<BaseText
103+
truncateAfterLines={1}
104+
color="currentColor"
105+
fontWeight="medium"
106+
fontSize={100}
107+
lineHeight={100}
108+
as="p"
109+
className={isL1Item ? HIDE_WHEN_COLLAPSED : ''}
110+
>
111+
{title}
112+
</BaseText>
113+
{titleSuffix ? (
114+
<BaseBox display="flex" alignItems="center">
115+
{titleSuffix}
116+
</BaseBox>
117+
) : null}
118+
</Box>
119+
<Box display="flex" alignItems="center">
120+
{trailing}
121+
</Box>
122+
</Box>
123+
{!isL1Item && description ? (
124+
<Text
125+
size="small"
126+
marginLeft="spacing.7"
127+
textAlign="left"
128+
weight="medium"
129+
color={isActive ? 'interactive.text.primary.muted' : 'interactive.text.gray.muted'}
130+
truncateAfterLines={1}
131+
>
132+
{description}
133+
</Text>
107134
) : null}
108135
</Box>
109136
);
110137
};
111138

112139
const L3Trigger = ({
113140
title,
141+
description,
114142
icon,
115143
as,
116144
href,
@@ -120,7 +148,15 @@ const L3Trigger = ({
120148
onClick,
121149
}: Pick<
122150
SideNavLinkProps,
123-
'title' | 'icon' | 'as' | 'href' | 'titleSuffix' | 'tooltip' | 'target' | 'onClick'
151+
| 'title'
152+
| 'description'
153+
| 'icon'
154+
| 'as'
155+
| 'href'
156+
| 'titleSuffix'
157+
| 'tooltip'
158+
| 'target'
159+
| 'onClick'
124160
>): React.ReactElement => {
125161
const { onExpandChange, isExpanded, collapsibleBodyId } = useCollapsible();
126162

@@ -135,7 +171,7 @@ const L3Trigger = ({
135171

136172
return (
137173
<TooltipifyNavItem tooltip={tooltip}>
138-
<StyledNavLinkContainer>
174+
<StyledNavLinkContainer $hasDescription={Boolean(description)}>
139175
<BaseBox
140176
className={STYLED_NAV_LINK}
141177
as={href ? as : 'button'}
@@ -144,10 +180,16 @@ const L3Trigger = ({
144180
onClick={(e: React.MouseEvent) => toggleCollapse(e)}
145181
{...makeAccessible({ expanded: isExpanded, controls: collapsibleBodyId })}
146182
>
147-
<NavLinkIconTitle title={title} icon={icon} isL1Item={false} titleSuffix={titleSuffix} />
148-
<BaseBox display="flex" alignItems="center">
149-
{isExpanded ? <ChevronUpIcon {...iconProps} /> : <ChevronDownIcon {...iconProps} />}
150-
</BaseBox>
183+
<NavLinkIconTitle
184+
title={title}
185+
description={description}
186+
icon={icon}
187+
isL1Item={false}
188+
titleSuffix={titleSuffix}
189+
trailing={
190+
isExpanded ? <ChevronUpIcon {...iconProps} /> : <ChevronDownIcon {...iconProps} />
191+
}
192+
/>
151193
</BaseBox>
152194
</StyledNavLinkContainer>
153195
</TooltipifyNavItem>
@@ -178,6 +220,7 @@ const CurvedVerticalLine = styled(BaseBox)((props) => {
178220

179221
const SideNavLink = ({
180222
title,
223+
description,
181224
href,
182225
children,
183226
titleSuffix,
@@ -211,6 +254,13 @@ const SideNavLink = ({
211254
moduleName: 'SideNavLink',
212255
});
213256
}
257+
258+
if (currentLevel === 1 && Boolean(description)) {
259+
throwBladeError({
260+
message: 'Description is not supported for L1 items',
261+
moduleName: 'SideNavLink',
262+
});
263+
}
214264
}
215265

216266
const isFirstRender = useFirstRender();
@@ -240,6 +290,7 @@ const SideNavLink = ({
240290
>
241291
<L3Trigger
242292
title={title}
293+
description={description}
243294
icon={icon}
244295
as={as}
245296
href={href}
@@ -252,7 +303,10 @@ const SideNavLink = ({
252303
</Collapsible>
253304
) : (
254305
<>
255-
<StyledNavLinkContainer position="relative">
306+
<StyledNavLinkContainer
307+
$hasDescription={currentLevel !== 1 && Boolean(description)}
308+
position="relative"
309+
>
256310
<TooltipifyNavItem tooltip={tooltip}>
257311
<BaseBox
258312
className={STYLED_NAV_LINK}
@@ -296,6 +350,8 @@ const SideNavLink = ({
296350
<NavLinkIconTitle
297351
icon={icon}
298352
title={title}
353+
description={description}
354+
isActive={isActive}
299355
isL1Item={currentLevel === 1}
300356
titleSuffix={titleSuffix}
301357
/>
@@ -321,7 +377,6 @@ const SideNavLink = ({
321377
) : null}
322378
{currentLevel === 3 && isActive ? <CurvedVerticalLine /> : null}
323379
</StyledNavLinkContainer>
324-
325380
{children ? (
326381
<FloatingPortal root={l2PortalContainerRef}>
327382
{isActive && isL1Collapsed ? (

0 commit comments

Comments
 (0)