Skip to content

Commit

Permalink
[PAY-3719] Add ExpandableNavItem component (#10808)
Browse files Browse the repository at this point in the history
  • Loading branch information
faridsalau authored Dec 22, 2024
1 parent c50408f commit 246fd88
Show file tree
Hide file tree
Showing 11 changed files with 253 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Canvas, Meta, Title, Primary, Controls } from '@storybook/blocks'

import { Heading } from 'storybook/components'

import * as ExpandableNavItemStories from './ExpandableNavItem.stories'

<Meta of={ExpandableNavItemStories} />

<Title />

- [Overview](#overview)
- [Props](#props)
- [Configuration Options](#configuration-options)
- [Use cases and examples](#use-cases-and-examples)

<Heading>
## Overview The ExpandableNavItem component is a collapsible navigation item
that can contain other items or nested folders. It's commonly used in
navigation menus and sidebars to organize content hierarchically.
</Heading>

<Primary />

## Props

<Controls />

## Configuration Options

### With Icons

<Canvas of={ExpandableNavItemStories.WithIcons} />

### Nested Folders

<Canvas of={ExpandableNavItemStories.NestedFolders} />

### Disabled State

<Canvas of={ExpandableNavItemStories.Disabled} />

## Use cases and examples

- Use for organizing navigation items hierarchically in sidebars or menus
- Create nested folder structures for content organization
- Group related navigation items together
- Implement collapsible sections in a navigation interface
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/react'

import { IconFolder, IconKebabHorizontal, IconPlaylists } from '../../icons'

import { ExpandableNavItem } from './ExpandableNavItem'

const meta: Meta<typeof ExpandableNavItem> = {
title: 'Navigation/ExpandableNavItem',
component: ExpandableNavItem,
parameters: {
layout: 'centered'
}
}

export default meta

type Story = StoryObj<typeof ExpandableNavItem>

export const Default: Story = {
args: {
label: 'Folder Name',
leftIcon: IconFolder,
rightIcon: <IconKebabHorizontal color='subdued' />
}
}

export const WithIcons: Story = {
args: {
label: 'Folder Name',
leftIcon: IconFolder,
rightIcon: <IconKebabHorizontal color='subdued' />
}
}

export const NestedFolders: Story = {
args: {
label: 'Parent Folder',
leftIcon: IconFolder,
rightIcon: <IconKebabHorizontal color='subdued' />,
defaultIsOpen: true,
nestedItems: (
<>
<ExpandableNavItem label='Nested Item 1' leftIcon={IconPlaylists} />
<ExpandableNavItem label='Nested Item 2' leftIcon={IconPlaylists} />
</>
)
}
}

export const Disabled: Story = {
args: {
label: 'Disabled Folder',
leftIcon: IconFolder,
rightIcon: <IconKebabHorizontal color='subdued' />,
css: { opacity: 0.5, pointerEvents: 'none' }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useMemo, useState } from 'react'

import { useTheme, CSSObject } from '@emotion/react'

import { HarmonyTheme } from '../../foundations/theme'
import { IconCaretDown, IconCaretRight } from '../../icons'
import { Flex } from '../layout/Flex'
import { Text } from '../text'

import type { ExpandableNavItemProps } from './types'

const getStyles = (
theme: HarmonyTheme,
isOpen: boolean,
isHovered: boolean
): CSSObject => {
const baseStyles: CSSObject = {
transition: `background-color ${theme.motion.hover}`,
cursor: 'pointer',
border: `1px solid ${isOpen ? theme.color.border.default : 'transparent'}`
}

const hoverStyles: CSSObject = {
backgroundColor: theme.color.background.surface2
}

return {
...baseStyles,
...(isHovered && hoverStyles)
}
}

export const ExpandableNavItem = ({
label,
leftIcon: LeftIcon,
rightIcon,
defaultIsOpen = false,
nestedItems,
shouldPersistRightIcon = false,
...props
}: ExpandableNavItemProps) => {
const [isOpen, setIsOpen] = useState(defaultIsOpen)
const [isHovered, setIsHovered] = useState(false)
const theme = useTheme()

const handleMouseEnter = () => setIsHovered(true)
const handleMouseLeave = () => setIsHovered(false)
const handleClick = () => setIsOpen(!isOpen)

const styles = useMemo(
() => getStyles(theme, isOpen, isHovered),
[theme, isOpen, isHovered]
)

const IconComponent = isHovered
? isOpen
? IconCaretDown
: IconCaretRight
: LeftIcon

return (
<Flex direction='column' role='navigation' {...props}>
<Flex
alignItems='center'
gap='s'
pl='s'
pr='s'
css={{
width: '240px',
cursor: 'pointer',
transition: `background-color ${theme.motion.hover}`
}}
>
<Flex
alignItems='center'
flex={1}
gap='m'
p='s'
borderRadius='m'
css={styles}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
role='button'
aria-expanded={isOpen}
aria-controls={`${label}-content`}
aria-label={`${label} navigation section`}
>
<Flex
alignItems='center'
gap='m'
flex={1}
css={{
maxWidth: '240px'
}}
>
{IconComponent ? <IconComponent color='default' size='m' /> : null}
<Text
variant='title'
size='l'
strength='weak'
lineHeight='single'
color='default'
ellipses
maxLines={1}
>
{label}
</Text>
</Flex>
{isOpen || shouldPersistRightIcon ? rightIcon : null}
</Flex>
</Flex>
{isOpen && nestedItems ? (
<Flex
direction='column'
id={`${label}-content`}
role='region'
aria-label={`${label} content`}
>
{nestedItems}
</Flex>
) : null}
</Flex>
)
}
2 changes: 2 additions & 0 deletions packages/harmony/src/components/expandable-nav-item/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ExpandableNavItem } from './ExpandableNavItem'
export type { ExpandableNavItemProps } from './types'
19 changes: 19 additions & 0 deletions packages/harmony/src/components/expandable-nav-item/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'

import type { WithCSS } from '../../foundations/theme'
import type { IconComponent } from '../icon'

export type ExpandableNavItemProps = WithCSS<{
/** The label text of the navigation item. */
label: string
/** The icon component to display on the left side of the label. */
leftIcon?: IconComponent
/** Optional ReactNode to render on the right */
rightIcon?: ReactNode
/** Whether the folder is open by default. */
defaultIsOpen?: boolean
/** Nested items to render when the folder is open. */
nestedItems?: ReactNode
/** Whether the right icon should persist regardless of the open state. */
shouldPersistRightIcon?: boolean
}>
2 changes: 2 additions & 0 deletions packages/harmony/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export * from './scrubber'
export * from './skeleton'
export * from './artwork'
export * from './music-badge'
export * from './expandable-nav-item'
export * from './nav-item'
export { default as LoadingSpinner } from './loading-spinner/LoadingSpinner'
export * from './pill'
export * from './common/HiddenInput'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { IconFeed, IconVolumeLevel3 } from '../../icons'
import { NavItem } from './NavItem'

const meta: Meta<typeof NavItem> = {
title: 'Components/NavItem',
title: 'Navigation/NavItem',
component: NavItem,
parameters: {
design: {
Expand Down

0 comments on commit 246fd88

Please sign in to comment.