diff --git a/packages/harmony/src/components/NavItem/NavItem.mdx b/packages/harmony/src/components/NavItem/NavItem.mdx new file mode 100644 index 00000000000..389ed66d5ca --- /dev/null +++ b/packages/harmony/src/components/NavItem/NavItem.mdx @@ -0,0 +1,95 @@ +import { + Meta, + Title, + Description, + Controls, + Canvas, + Unstyled +} from '@storybook/blocks' + +import { RelatedFoundations } from 'storybook/components' + +import { ThemeProvider } from '../../foundations/theme' +import { Flex } from '../layout/Flex' + +import { NavItem } from './NavItem' +import * as NavItemStories from './NavItem.stories' + + + + + +<Description of={NavItem} /> + +A navigation item component, typically used in sidebars or navigation menus. Supports default, hover, and selected states, as well as optional left and right icons. + +## Usage + +The `NavItem` component displays a navigation link with options for left and right icons and different states (default, hover, selected). + +<Canvas of={NavItemStories.Default} /> + +## Props + +<Controls /> + +## States + +### Default + +<Canvas of={NavItemStories.Default} /> + +### Selected + +<Canvas of={NavItemStories.Selected} /> + +## Variations + +### With Left Icon + +<Canvas of={NavItemStories.WithLeftIcon} /> + +### With Right Icon + +<Canvas of={NavItemStories.WithRightIcon} /> + +### With Both Icons + +<Canvas of={NavItemStories.WithBothIcons} /> + +## Styling + +The `NavItem` component uses the following foundational styles: + +- **Color:** Text and icon colors change based on the state (default, hover, selected). +- **Spacing:** Uses `s` and `m` spacing values for padding and gaps. +- **Corner Radius:** Uses `m` corner radius for the rounded corners. +- **Background Color:** Changes based on the state: + - Default: transparent + - Hover: `surface2` (unless selected) + - Selected: `s400` +- **Border:** A border appears on hover with `border.default` color. +- **Typography:** Uses `title` variant with `l` size and `weak` strength. +- **Transitions:** Smooth background-color transition with `0.18s ease-in-out`. + +## Themes + +<Unstyled> + <Flex gap='m' wrap='wrap'> + <ThemeProvider theme='day'> + <NavItem leftIcon='UserFeed'>Label</NavItem> + </ThemeProvider> + <ThemeProvider theme='dark'> + <NavItem leftIcon='UserFeed'>Label</NavItem> + </ThemeProvider> + <ThemeProvider theme='matrix'> + <NavItem leftIcon='UserFeed'>Label</NavItem> + </ThemeProvider> + </Flex> +</Unstyled> + +## Related Foundations + +<RelatedFoundations + foundationNames={['Color', 'Spacing', 'CornerRadius', 'Typography']} +/> diff --git a/packages/harmony/src/components/NavItem/NavItem.stories.tsx b/packages/harmony/src/components/NavItem/NavItem.stories.tsx new file mode 100644 index 00000000000..382551f8999 --- /dev/null +++ b/packages/harmony/src/components/NavItem/NavItem.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { IconFeed, IconVolumeLevel3 } from '../../icons' + +import { NavItem } from './NavItem' + +const meta: Meta<typeof NavItem> = { + title: 'Components/NavItem', + component: NavItem, + parameters: { + design: { + type: 'figma', + url: '' // Add your Figma URL here + } + }, + tags: ['autodocs'] +} + +export default meta + +type Story = StoryObj<typeof NavItem> + +export const Default: Story = { + args: { + children: 'Label' + } +} + +export const Selected: Story = { + args: { + children: 'Label', + isSelected: true + } +} + +export const WithLeftIcon: Story = { + args: { + children: 'Label', + leftIcon: IconFeed + } +} + +export const WithRightIcon: Story = { + args: { + children: 'Label', + rightIcon: IconVolumeLevel3 + } +} + +export const WithBothIcons: Story = { + args: { + children: 'Label', + leftIcon: IconFeed, + rightIcon: IconVolumeLevel3 + } +} diff --git a/packages/harmony/src/components/NavItem/NavItem.tsx b/packages/harmony/src/components/NavItem/NavItem.tsx new file mode 100644 index 00000000000..8fda0213040 --- /dev/null +++ b/packages/harmony/src/components/NavItem/NavItem.tsx @@ -0,0 +1,86 @@ +import { useTheme } from '@emotion/react' + +import { motion } from '../../foundations/motion' +import { Flex } from '../layout/Flex' +import { Text } from '../text/Text' + +import type { NavItemProps } from './types' + +/** + * A navigation item component, typically used in sidebars or navigation menus. + * Supports default, hover, and selected states, as well as optional left and right icons. + */ +export const NavItem = ({ + children, + leftIcon: LeftIcon, + rightIcon: RightIcon, + isSelected = false, + onClick, + ...props +}: NavItemProps) => { + const { color } = useTheme() + + const hasLeftIcon = !!LeftIcon + const hasRightIcon = !!RightIcon + + const backgroundColor = isSelected ? color.secondary.s400 : undefined + + const textColor = isSelected ? 'staticWhite' : 'default' + + const iconColor = isSelected ? 'staticStaticWhite' : 'default' + + return ( + <Flex + alignItems='center' + gap='s' + pl='s' + pr='s' + css={{ + width: '240px', + cursor: 'pointer', + transition: `background-color ${motion.hover}` + }} + {...props} + onClick={onClick} + > + <Flex + alignItems='center' + flex={1} + gap='m' + p='s' + borderRadius='m' + css={{ + backgroundColor, + borderWidth: '1px', + + '&:hover': { + backgroundColor: isSelected ? undefined : color.background.surface2, + borderColor: color.border.default + } + }} + > + <Flex + alignItems='center' + gap='m' + flex={1} + css={{ + maxWidth: '240px' + }} + > + {hasLeftIcon ? <LeftIcon size='l' color={iconColor} /> : null} + <Text + variant='title' + size='l' + strength='weak' + lineHeight='single' + color={textColor} + ellipses + > + {children} + </Text> + </Flex> + {hasRightIcon ? <RightIcon size='m' color={iconColor} /> : null} + </Flex> + </Flex> + ) +} diff --git a/packages/harmony/src/components/NavItem/index.ts b/packages/harmony/src/components/NavItem/index.ts new file mode 100644 index 00000000000..e1400e60f12 --- /dev/null +++ b/packages/harmony/src/components/NavItem/index.ts @@ -0,0 +1,2 @@ +export * from './NavItem' +export * from './types' diff --git a/packages/harmony/src/components/NavItem/types.ts b/packages/harmony/src/components/NavItem/types.ts new file mode 100644 index 00000000000..bea821c12b5 --- /dev/null +++ b/packages/harmony/src/components/NavItem/types.ts @@ -0,0 +1,16 @@ +import { IconComponent } from 'components/icon' + +import type { WithCSS } from '../../foundations/theme' + +export type NavItemProps = WithCSS<{ + /** The label text of the navigation item. */ + children: React.ReactNode + /** The name of the icon to display on the left side of the label. */ + leftIcon?: IconComponent + /** The name of the icon to display on the right side of the label. */ + rightIcon?: IconComponent + /** Whether the navigation item is currently selected. */ + isSelected?: boolean + /** The callback function to be called when the navigation item is clicked. */ + onClick?: () => void +}> diff --git a/packages/harmony/src/components/balance-pill/BalancePill.mdx b/packages/harmony/src/components/balance-pill/BalancePill.mdx new file mode 100644 index 00000000000..8d6e86c8a43 --- /dev/null +++ b/packages/harmony/src/components/balance-pill/BalancePill.mdx @@ -0,0 +1,59 @@ +import { + Meta, + Title, + Description, + Controls, + Canvas, + Unstyled +} from '@storybook/blocks' + +import { Heading, RelatedFoundations } from 'storybook/components' + +import { ThemeProvider, themes } from '../../foundations/theme' +import { Flex } from '../layout/Flex' +import { Text } from '../text/Text' + +import { BalancePill } from './BalancePill' +import * as BalancePillStories from './BalancePill.stories' + +<Meta of={BalancePillStories} /> + +<Title /> + +<Description of={BalancePill} /> + +## Usage + +The `BalancePill` component is designed to display a balance amount along with an optional icon or child component. It utilizes several foundational styles from the Harmony design system, including colors, spacing, and border-radius. + +<Canvas of={BalancePillStories.Default} /> + +## Themes + +<Unstyled> + <Flex gap='m' wrap='wrap'> + <ThemeProvider theme='day'> + <BalancePill balance='1,000'> + <Text>💰</Text> + </BalancePill> + </ThemeProvider> + <ThemeProvider theme='dark'> + <BalancePill balance='1,000'> + <Text>💰</Text> + </BalancePill> + </ThemeProvider> + <ThemeProvider theme='matrix'> + <BalancePill balance='1,000'> + <Text>💰</Text> + </BalancePill> + </ThemeProvider> + </Flex> +</Unstyled> + +## Props + +<Controls /> + +## Related Foundations + +<RelatedFoundations foundationNames={['Color', 'Spacing', 'CornerRadius']} /> diff --git a/packages/harmony/src/components/balance-pill/BalancePill.stories.tsx b/packages/harmony/src/components/balance-pill/BalancePill.stories.tsx new file mode 100644 index 00000000000..8d4a69a5eeb --- /dev/null +++ b/packages/harmony/src/components/balance-pill/BalancePill.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { BalancePill } from './BalancePill' + +const meta: Meta<typeof BalancePill> = { + title: 'Components/BalancePill', + component: BalancePill, + parameters: { + design: { + type: 'figma', + url: '' // Add your Figma URL here + } + }, + tags: ['autodocs'] +} + +export default meta + +type Story = StoryObj<typeof BalancePill> + +export const Default: Story = { + args: { + balance: 125 + } +} diff --git a/packages/harmony/src/components/balance-pill/BalancePill.tsx b/packages/harmony/src/components/balance-pill/BalancePill.tsx new file mode 100644 index 00000000000..f3d2e63f362 --- /dev/null +++ b/packages/harmony/src/components/balance-pill/BalancePill.tsx @@ -0,0 +1,52 @@ +import { useTheme, CSSObject } from '@emotion/react' + +import { Flex } from '../layout/Flex' +import { Text } from '../text/Text' + +import type { BalancePillProps } from './types' + +/** + * A pill-shaped component that displays a balance, typically used to show a user's token balance. + */ +export const BalancePill = ({ + balance, + children, + ...props +}: BalancePillProps) => { + const { color } = useTheme() + + const textStyles: CSSObject = { + color: color.neutral.n950 + } + + return ( + <Flex + pl='s' + alignItems='center' + gap='xs' + borderRadius='circle' + border='default' + backgroundColor='surface1' + {...props} + > + <Text + variant='label' + size='s' + strength='strong' + textAlign='center' + css={textStyles} + > + {balance} + </Text> + <Flex + w='unit6' + h='unit6' + p='unitHalf' + justifyContent='center' + alignItems='center' + > + {children} + </Flex> + </Flex> + ) +} diff --git a/packages/harmony/src/components/balance-pill/index.ts b/packages/harmony/src/components/balance-pill/index.ts new file mode 100644 index 00000000000..b26b956adb0 --- /dev/null +++ b/packages/harmony/src/components/balance-pill/index.ts @@ -0,0 +1,2 @@ +export * from './BalancePill' +export * from './types' diff --git a/packages/harmony/src/components/balance-pill/types.ts b/packages/harmony/src/components/balance-pill/types.ts new file mode 100644 index 00000000000..bc006ceaf14 --- /dev/null +++ b/packages/harmony/src/components/balance-pill/types.ts @@ -0,0 +1,6 @@ +import { ReactNode } from 'react' + +export type BalancePillProps = { + balance: string | number + children: ReactNode +} diff --git a/packages/harmony/src/components/notification-count/NotificationCount.mdx b/packages/harmony/src/components/notification-count/NotificationCount.mdx new file mode 100644 index 00000000000..4846c0031ea --- /dev/null +++ b/packages/harmony/src/components/notification-count/NotificationCount.mdx @@ -0,0 +1,90 @@ +import { + Meta, + Title, + Description, + Primary, + Controls, + Canvas +} from '@storybook/blocks' + +import { ComponentRules } from 'storybook/components' + +import * as NotificationCountStories from './NotificationCount.stories' + +<Meta of={NotificationCountStories} /> + +<Title /> + +- [Overview](#overview) +- [Props](#props) +- [Usage Guidelines](#usage-guidelines) +- [States](#states) +- [Do's and Don'ts](#dos-and-donts) + +## Overview + +<Description /> + +The NotificationCount component displays a small badge with a count, typically used to show unread notifications or pending items. It can be used standalone or wrapped around another component. + +<Primary /> + +## Props + +<Controls /> + +## Usage Guidelines + +- Use to indicate unread or pending notifications +- Wrap the NotificationCount around the target component (usually an icon or button) +- For counts over 99, it will display as "99+" +- Use size 's' for less emphasis or in compact layouts +- The badge appears in the top-right corner by default +- Supports hover states when parent has the 'parent' className + +## States + +### Default + +<Canvas of={NotificationCountStories.Default} /> + +### With Icon + +<Canvas of={NotificationCountStories.WithIcon} /> + +### Small Size + +<Canvas of={NotificationCountStories.SmallSize} /> + +### Large Number + +<Canvas of={NotificationCountStories.LargeNumber} /> + +## Do's and Don'ts + +<ComponentRules + rules={[ + { + positive: { + description: 'Wrap NotificationCount around the target component.', + component: NotificationCountStories.WithIcon.render?.() + }, + negative: { + description: + "Don't place NotificationCount as a sibling - it should wrap the target component.", + component: NotificationCountStories.Default.render?.() + } + }, + { + positive: { + description: 'Use size="s" for compact layouts.', + component: NotificationCountStories.SmallSize.render?.() + }, + negative: { + description: + "Don't use the small size when the count needs to be prominently displayed.", + component: NotificationCountStories.LargeNumber.render?.() + } + } + ]} +/> diff --git a/packages/harmony/src/components/notification-count/NotificationCount.stories.tsx b/packages/harmony/src/components/notification-count/NotificationCount.stories.tsx new file mode 100644 index 00000000000..99a69bb8f7f --- /dev/null +++ b/packages/harmony/src/components/notification-count/NotificationCount.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { IconButton } from 'components/button' +import { IconNotificationOn } from 'icons' + +import { NotificationCount } from './NotificationCount' + +const meta: Meta<typeof NotificationCount> = { + title: 'Components/NotificationCount', + component: NotificationCount, + parameters: { + layout: 'centered' + } +} + +export default meta + +type Story = StoryObj<typeof NotificationCount> + +export const Default: Story = { + args: { + count: 5 + } +} + +export const WithIcon: Story = { + render: () => ( + <NotificationCount count={3}> + <IconButton icon={IconNotificationOn} aria-label='Notifications' /> + </NotificationCount> + ) +} + +export const SmallSize: Story = { + render: () => ( + <NotificationCount count={3} size='s'> + <IconButton icon={IconNotificationOn} aria-label='Notifications' /> + </NotificationCount> + ) +} + +export const LargeNumber: Story = { + render: () => ( + <NotificationCount count={15000}> + <IconButton icon={IconNotificationOn} aria-label='Notifications' /> + </NotificationCount> + ) +} diff --git a/packages/harmony/src/components/notification-count/NotificationCount.tsx b/packages/harmony/src/components/notification-count/NotificationCount.tsx new file mode 100644 index 00000000000..6502bbb7772 --- /dev/null +++ b/packages/harmony/src/components/notification-count/NotificationCount.tsx @@ -0,0 +1,83 @@ +import { useTheme, CSSObject } from '@emotion/react' + +import { formatCount } from 'utils/formatCount' + +import { Flex } from '../layout/Flex' +import { Text } from '../text/Text' + +import type { NotificationCountProps } from './types' + +/** + * A small badge that displays a notification count and can wrap an icon, typically used with icons or buttons + * to indicate unread or pending notifications. + */ +export const NotificationCount = ({ + count, + size, + children, + ...props +}: NotificationCountProps) => { + const { spacing, color } = useTheme() + + const containerStyles: CSSObject = { + display: 'inline-flex', + position: 'relative' + } + + const badgeStyles: CSSObject = { + position: 'absolute', + top: 0, + right: 0, + transform: 'translate(50%, -50%)', + minWidth: spacing.unit5, + backgroundColor: color.primary.p300, + border: 'none', + '.parent:hover &': { + backgroundColor: color.background.surface1 + }, + ...(size === 's' && { + height: spacing.unit3 + spacing.unitHalf, + minWidth: spacing.unit3 + spacing.unitHalf, + padding: `0px ${spacing.xs}px`, + backgroundColor: color.primary.p300 + }) + } + + const textStyles: CSSObject = { + '.parent:hover &': { + color: color.neutral.n950 + } + } + + return ( + <Flex + className='parent' + alignItems='center' + justifyContent='center' + css={containerStyles} + {...props} + > + {children} + <Flex + as='span' + h='unit5' + ph='xs' + direction='column' + justifyContent='center' + alignItems='center' + gap='s' + borderRadius='circle' + css={badgeStyles} + > + <Text + variant='label' + size='xs' + css={textStyles} + color='staticStaticWhite' + > + {count !== undefined ? formatCount(count) : '0'} + </Text> + </Flex> + </Flex> + ) +} diff --git a/packages/harmony/src/components/notification-count/index.ts b/packages/harmony/src/components/notification-count/index.ts new file mode 100644 index 00000000000..8ae68bdac19 --- /dev/null +++ b/packages/harmony/src/components/notification-count/index.ts @@ -0,0 +1,2 @@ +export { NotificationCount } from './NotificationCount' +export type { NotificationCountProps } from './types' diff --git a/packages/harmony/src/components/notification-count/types.ts b/packages/harmony/src/components/notification-count/types.ts new file mode 100644 index 00000000000..3069833cd9c --- /dev/null +++ b/packages/harmony/src/components/notification-count/types.ts @@ -0,0 +1,14 @@ +import { ComponentPropsWithoutRef, ReactNode } from 'react' + +export type NotificationCountProps = { + /** + * The notification count to display + */ + count?: number + /** + * The size of the notification count. + * @default undefined + */ + size?: 's' + children?: ReactNode +} & ComponentPropsWithoutRef<'div'> diff --git a/packages/harmony/src/utils/formatCount.ts b/packages/harmony/src/utils/formatCount.ts new file mode 100644 index 00000000000..7dbf220365e --- /dev/null +++ b/packages/harmony/src/utils/formatCount.ts @@ -0,0 +1,33 @@ +import numeral from 'numeral' + +/** + * Formats a count into a more readable string representation. + * For counts over 1000, it converts the number into a format with a suffix (K for thousands, M for millions, etc.) + * For example: + * - 375 => "375" + * - 4,210 => "4.21K" + * - 443,123 => "443K" + * - 4,001,000 => "4M" + * If the count is 0, it returns "0". + * This function is pulled over from the common package because we don't use the common package in Harmony. + */ +export const formatCount = (count: number) => { + if (count >= 1000) { + const countStr = count.toString() + if (countStr.length % 3 === 0) { + return numeral(count).format('0a').toUpperCase() + } else if (countStr.length % 3 === 1 && countStr[2] !== '0') { + return numeral(count).format('0.00a').toUpperCase() + } else if (countStr.length % 3 === 1 && countStr[1] !== '0') { + return numeral(count).format('0.0a').toUpperCase() + } else if (countStr.length % 3 === 2 && countStr[2] !== '0') { + return numeral(count).format('0.0a').toUpperCase() + } else { + return numeral(count).format('0a').toUpperCase() + } + } else if (!count) { + return '0' + } else { + return `${count}` + } +}