Skip to content

Commit c24f752

Browse files
committed
fix: sub nav
1 parent ad09d37 commit c24f752

File tree

4 files changed

+126
-45
lines changed

4 files changed

+126
-45
lines changed

src/components/AppLayout/context.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,17 @@ export interface AppLayoutContextType {
2626
// track if sidebar was expanded by hover vs manual toggle
2727
_expandedByHover: boolean
2828
_setExpandedByHover: (expandedByHover: boolean) => void
29+
30+
// Used to auto-collapse other NavItems which have sub items when a new one is opened
31+
_activeNavItem: string | null
32+
_setActiveNavItem: (activeNavItem: string | null) => void
2933
}
3034

3135
export const AppLayoutContext = createContext<AppLayoutContextType>({
3236
collapsed: false,
3337
setCollapsed: () => {},
38+
_activeNavItem: null,
39+
_setActiveNavItem: () => {},
3440
keybinds: {
3541
toggle: {
3642
key: 'L',

src/components/AppLayout/index.stories.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,7 @@ const FakeLink = ({
503503
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
504504
return <a href={to} {...props} />
505505
}
506+
506507
const CustomLink = ({
507508
to,
508509
...props
@@ -583,6 +584,11 @@ export const WithHomeNavigationOnBrandLogo: Story = {
583584
</AppLayout.Surface>,
584585
],
585586
},
587+
render: (args) => (
588+
<AppLayoutProvider defaultCollapsed>
589+
<AppLayout {...args} />
590+
</AppLayoutProvider>
591+
),
586592
}
587593

588594
export const WithSubNavItems: Story = {
@@ -591,11 +597,23 @@ export const WithSubNavItems: Story = {
591597
children: [
592598
<AppLayout.Sidebar key="sidebar">
593599
<AppLayout.Nav>
594-
<AppLayout.NavItem title="SDKS" icon="package">
595-
<AppLayout.SubNavItem title="Overview" icon="house" />
596-
<AppLayout.SubNavItem title="Tests" icon="beaker" />
597-
<AppLayout.SubNavItem title="Documentation" icon="book" />
598-
</AppLayout.NavItem>
600+
<AppLayout.NavItemGroup name="Generate">
601+
<AppLayout.NavItem
602+
defaultSubNavItemsOpen
603+
title="SDKS"
604+
icon="package"
605+
>
606+
<AppLayout.SubNavItem title="Overview" active />
607+
<AppLayout.SubNavItem title="Tests" />
608+
<AppLayout.SubNavItem title="Documentation" />
609+
</AppLayout.NavItem>
610+
611+
<AppLayout.NavItem title="Terraform" icon="server-cog">
612+
<AppLayout.SubNavItem title="Overview" />
613+
<AppLayout.SubNavItem title="Tests" />
614+
<AppLayout.SubNavItem title="Documentation" />
615+
</AppLayout.NavItem>
616+
</AppLayout.NavItemGroup>
599617

600618
<AppLayout.NavItem title="Users" icon="users" />
601619
</AppLayout.Nav>
@@ -613,4 +631,9 @@ export const WithSubNavItems: Story = {
613631
</AppLayout.Surface>,
614632
],
615633
},
634+
render: (args) => (
635+
<AppLayoutProvider defaultCollapsed={false}>
636+
<AppLayout {...args} />
637+
</AppLayoutProvider>
638+
),
616639
}

src/components/AppLayout/index.tsx

Lines changed: 87 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import React, {
77
useRef,
88
useEffect,
99
useState,
10+
useCallback,
1011
} from 'react'
1112
import { Slot } from '@radix-ui/react-slot'
1213
import { Icon } from '../Icon'
1314
import { useAppLayout } from '@/hooks/useAppLayout'
14-
import { AnimatePresence, motion } from 'framer-motion'
15+
import { motion } from 'framer-motion'
1516
import { Logo } from '../Logo'
1617
import {
1718
Tooltip,
@@ -249,7 +250,7 @@ const AppLayoutSidebar = ({
249250
ref={sidebarRef}
250251
transition={{ duration: 0.25, type: 'spring', bounce: 0 }}
251252
>
252-
<div className="flex flex-col gap-4 px-2">
253+
<div className="flex flex-col gap-3 px-2">
253254
<Logo
254255
variant={collapsed ? 'icon' : 'wordmark'}
255256
className={cn('cursor-pointer', !collapsed && 'min-w-[140px]')}
@@ -468,10 +469,7 @@ interface AppLayoutNavProps extends HTMLAttributes<HTMLDivElement> {
468469

469470
const AppLayoutNav = ({ children, className, ...props }: AppLayoutNavProps) => {
470471
return (
471-
<nav
472-
className={cn('mt-3 flex flex-col items-start gap-3', className)}
473-
{...props}
474-
>
472+
<nav className={cn('mt-3 flex flex-col items-start', className)} {...props}>
475473
{children}
476474
</nav>
477475
)
@@ -497,6 +495,7 @@ export interface AppLayoutNavItemProps
497495
active?: boolean
498496
disabled?: boolean
499497
asChild?: boolean
498+
defaultSubNavItemsOpen?: boolean
500499
}
501500

502501
const AppLayoutNavItem = React.forwardRef<
@@ -513,6 +512,7 @@ const AppLayoutNavItem = React.forwardRef<
513512
active,
514513
disabled,
515514
children,
515+
defaultSubNavItemsOpen = false,
516516
...rest
517517
},
518518
ref
@@ -544,46 +544,91 @@ const AppLayoutNavItem = React.forwardRef<
544544
return type.displayName === 'AppLayout.SubNavItem'
545545
})
546546

547-
const [isSubNavItemsOpen, setIsSubNavItemsOpen] = useState(false)
547+
const [isSubNavItemsOpen, setIsSubNavItemsOpen] = useState(
548+
defaultSubNavItemsOpen
549+
)
550+
551+
const { _setActiveNavItem, _activeNavItem } = useAppLayout()
552+
553+
// Close this nav item when another nav item becomes active
554+
useEffect(() => {
555+
if (
556+
_activeNavItem !== null &&
557+
_activeNavItem !== title &&
558+
isSubNavItemsOpen
559+
) {
560+
setIsSubNavItemsOpen(false)
561+
}
562+
}, [_activeNavItem, title, isSubNavItemsOpen])
563+
564+
const toggleSubNavItems = useCallback(() => {
565+
const newOpenState = !isSubNavItemsOpen
566+
setIsSubNavItemsOpen(newOpenState)
567+
568+
// Only set as active if we're opening, not closing
569+
if (newOpenState) {
570+
_setActiveNavItem(title)
571+
} else {
572+
_setActiveNavItem(null)
573+
}
574+
}, [isSubNavItemsOpen, title, _setActiveNavItem])
548575

549576
const content = asChild ? (
550577
children
551578
) : (
552-
<div className="flex w-full flex-col items-start gap-2">
553-
<div className="flex w-full items-center gap-2">
579+
<motion.div className="flex w-full flex-col items-start gap-1">
580+
<div
581+
className={cn(
582+
'flex w-full items-center gap-2',
583+
subNavItems.length > 0 && !collapsed && 'cursor-pointer'
584+
)}
585+
onClick={
586+
subNavItems.length > 0 && !collapsed ? toggleSubNavItems : undefined
587+
}
588+
>
554589
{iconElement}
555590
{titleElement}
556591

557-
{subNavItems.length > 0 && (
558-
<button
559-
className="text-muted-foreground hover:text-foreground ml-auto"
560-
onClick={() => setIsSubNavItemsOpen(!isSubNavItemsOpen)}
561-
>
562-
<Icon
563-
name={isSubNavItemsOpen ? 'chevron-up' : 'chevron-down'}
564-
className="size-4 text-current"
565-
/>
566-
</button>
592+
{subNavItems.length > 0 && !collapsed && (
593+
<Icon
594+
name={isSubNavItemsOpen ? 'chevron-up' : 'chevron-down'}
595+
className="text-muted-foreground ml-auto size-4"
596+
/>
567597
)}
568598
</div>
569-
<AnimatePresence>
570-
{subNavItems.length > 0 && isSubNavItemsOpen && (
571-
<div className="mb-2 ml-8 flex flex-col gap-1">
572-
{subNavItems.map((child, index) => (
573-
<motion.div
574-
key={index}
575-
initial={{ opacity: 0 }}
576-
animate={{ opacity: 1 }}
577-
exit={{ opacity: 0 }}
578-
transition={{ duration: 0.2 }}
579-
>
580-
{child}
581-
</motion.div>
582-
))}
583-
</div>
584-
)}
585-
</AnimatePresence>
586-
</div>
599+
<motion.div
600+
className="ml-8 overflow-hidden"
601+
initial={false}
602+
animate={{
603+
height: subNavItems.length > 0 && isSubNavItemsOpen ? 'auto' : 0,
604+
marginBottom: subNavItems.length > 0 && isSubNavItemsOpen ? 8 : 0,
605+
}}
606+
transition={{
607+
duration: 0.2,
608+
ease: [0.25, 0.46, 0.45, 0.94],
609+
}}
610+
>
611+
<div className="flex flex-col gap-1">
612+
{subNavItems.map((child, index) => (
613+
<motion.div
614+
key={index}
615+
initial={false}
616+
animate={{
617+
opacity: isSubNavItemsOpen ? 1 : 0,
618+
y: isSubNavItemsOpen ? 0 : -4,
619+
}}
620+
transition={{
621+
duration: 0.15,
622+
delay: isSubNavItemsOpen ? index * 0.03 : 0,
623+
ease: [0.25, 0.46, 0.45, 0.94],
624+
}}
625+
>
626+
{child}
627+
</motion.div>
628+
))}
629+
</div>
630+
</motion.div>
631+
</motion.div>
587632
)
588633

589634
return (
@@ -649,9 +694,11 @@ const AppLayoutNavItemGroup = ({
649694
}: AppLayoutNavItemGroupProps) => {
650695
const { collapsed } = useAppLayout()
651696
return (
652-
<div className={cn('mb-4 flex flex-col', className)} {...props}>
653-
{!collapsed && <div className="text-codeline-sm uppercase">{name}</div>}
654-
{children}
697+
<div className={cn('mb-3 flex w-full flex-col', className)} {...props}>
698+
{!collapsed && (
699+
<div className="text-codeline-sm mb-2 uppercase">{name}</div>
700+
)}
701+
<div className="flex flex-col gap-1">{children}</div>
655702
</div>
656703
)
657704
}

src/components/AppLayout/provider.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export const AppLayoutProvider = ({
2424
const [collapsed, setCollapsed] = useState(defaultCollapsed)
2525
const [expandedByHover, setExpandedByHover] = useState(false)
2626

27+
// Used to auto-collapse other NavItems which have sub items when a new one is opened
28+
const [activeNavItem, setActiveNavItem] = useState<string | null>(null)
29+
2730
// respond to defaultCollapsed changes
2831
useEffect(() => {
2932
setCollapsed(defaultCollapsed)
@@ -39,6 +42,8 @@ export const AppLayoutProvider = ({
3942
hoverExpandsSidebar,
4043
_expandedByHover: expandedByHover,
4144
_setExpandedByHover: setExpandedByHover,
45+
_activeNavItem: activeNavItem,
46+
_setActiveNavItem: setActiveNavItem,
4247
}}
4348
>
4449
{children}

0 commit comments

Comments
 (0)