diff --git a/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.mdx b/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.mdx new file mode 100644 index 00000000000..082fd662ccd --- /dev/null +++ b/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.mdx @@ -0,0 +1,47 @@ +import { Canvas, Meta, Title, Primary, Controls } from '@storybook/blocks' + +import { Heading } from 'storybook/components' + +import * as ExpandableNavItemStories from './ExpandableNavItem.stories' + + + + + +- [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 diff --git a/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.stories.tsx b/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.stories.tsx new file mode 100644 index 00000000000..bab0b740728 --- /dev/null +++ b/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.stories.tsx @@ -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' } + } +} diff --git a/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.tsx b/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.tsx new file mode 100644 index 00000000000..d06c99d5a0b --- /dev/null +++ b/packages/harmony/src/components/expandable-nav-item/ExpandableNavItem.tsx @@ -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> + ) +} diff --git a/packages/harmony/src/components/expandable-nav-item/index.ts b/packages/harmony/src/components/expandable-nav-item/index.ts new file mode 100644 index 00000000000..865bb5c40da --- /dev/null +++ b/packages/harmony/src/components/expandable-nav-item/index.ts @@ -0,0 +1,2 @@ +export { ExpandableNavItem } from './ExpandableNavItem' +export type { ExpandableNavItemProps } from './types' diff --git a/packages/harmony/src/components/expandable-nav-item/types.ts b/packages/harmony/src/components/expandable-nav-item/types.ts new file mode 100644 index 00000000000..93716a80fee --- /dev/null +++ b/packages/harmony/src/components/expandable-nav-item/types.ts @@ -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 +}> diff --git a/packages/harmony/src/components/index.ts b/packages/harmony/src/components/index.ts index 37d83367a8d..9698c438198 100644 --- a/packages/harmony/src/components/index.ts +++ b/packages/harmony/src/components/index.ts @@ -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' diff --git a/packages/harmony/src/components/NavItem/NavItem.mdx b/packages/harmony/src/components/nav-item/NavItem.mdx similarity index 100% rename from packages/harmony/src/components/NavItem/NavItem.mdx rename to packages/harmony/src/components/nav-item/NavItem.mdx diff --git a/packages/harmony/src/components/NavItem/NavItem.stories.tsx b/packages/harmony/src/components/nav-item/NavItem.stories.tsx similarity index 96% rename from packages/harmony/src/components/NavItem/NavItem.stories.tsx rename to packages/harmony/src/components/nav-item/NavItem.stories.tsx index 382551f8999..fb06dfff836 100644 --- a/packages/harmony/src/components/NavItem/NavItem.stories.tsx +++ b/packages/harmony/src/components/nav-item/NavItem.stories.tsx @@ -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: { diff --git a/packages/harmony/src/components/NavItem/NavItem.tsx b/packages/harmony/src/components/nav-item/NavItem.tsx similarity index 100% rename from packages/harmony/src/components/NavItem/NavItem.tsx rename to packages/harmony/src/components/nav-item/NavItem.tsx diff --git a/packages/harmony/src/components/NavItem/index.ts b/packages/harmony/src/components/nav-item/index.ts similarity index 100% rename from packages/harmony/src/components/NavItem/index.ts rename to packages/harmony/src/components/nav-item/index.ts diff --git a/packages/harmony/src/components/NavItem/types.ts b/packages/harmony/src/components/nav-item/types.ts similarity index 100% rename from packages/harmony/src/components/NavItem/types.ts rename to packages/harmony/src/components/nav-item/types.ts