diff --git a/.gitignore b/.gitignore index d6688c4e..b927e768 100644 --- a/.gitignore +++ b/.gitignore @@ -139,4 +139,6 @@ dist .direnv .idea -.DS_Store \ No newline at end of file +.DS_Store + +.cursorrules \ No newline at end of file diff --git a/libs/react/ui/index.css b/libs/react/ui/index.css index 3a6d0a61..0db931f4 100644 --- a/libs/react/ui/index.css +++ b/libs/react/ui/index.css @@ -290,6 +290,37 @@ /* Alpha */ --alpha-250: var(--color-alpha-black-10); --alpha-400: var(--color-alpha-black-24); + + /* Shadow */ + --shadow-border-base: + 0 -1px 0 0 var(--color-alpha-white-6), 0 0 0 1px var(--color-alpha-white-6), 0 0 0 1px + var(--color-neutral-800), 0 0 1px 1.5px var(--color-alpha-black-24), 0 2px 2px 0 + var(--color-alpha-black-24); + --shadow-border-interactive-with-active: 0 0 0 1px #ff8b16, 0 0 0 4px rgba(255, 49, 0, 0.25); + --shadow-border-error: 0 0 0 1px #ff1f5a, 0 0 0 3px rgba(246, 0, 67, 0.25); + --shadow-button-inverted: + 0 -1px 0 0 var(--color-alpha-white-12), 0 0 0 1px var(--color-alpha-white-10), 0 0 0 1px + var(--color-neutral-600), 0 0 1px 1.5px var(--color-alpha-black-24), 0 2px 2px 0 + var(--color-alpha-black-24); + --shadow-button-inverted-focus: + 0 -1px 0 0 var(--color-alpha-white-12), 0 0 0 1px var(--color-alpha-white-12), 0 0 0 1px + var(--color-neutral-600), 0 0 0 2px var(--color-alpha-white-88), 0 0 0 4px + var(--color-primary-500); + --shadow-button-neutral: + 0 -1px 0 0 var(--color-alpha-white-6), 0 0 0 1px var(--color-alpha-white-6), 0 0 0 1px + var(--color-neutral-800), 0 0 1px 1.5px var(--color-alpha-black-24), 0 2px 2px 0 + var(--color-alpha-black-24); + --shadow-button-neutral-focus: + 0 -1px 0 0 var(--color-alpha-white-6), 0 0 0 1px var(--color-alpha-white-6), 0 0 0 1px + var(--color-neutral-800), 0 0 0 2px var(--color-alpha-white-88), 0 0 0 4px + var(--color-primary-500); + --shadow-button-danger: + 0 -1px 0 0 var(--color-alpha-white-16), 0 0 0 1px var(--color-alpha-white-12), 0 0 0 1px + var(--color-red-700), 0 0 1px 1.5px var(--color-alpha-black-24), 0 2px 2px 0 + var(--color-alpha-black-24); + --shadow-button-danger-focus: + 0 -1px 0 0 var(--color-alpha-white-16), 0 0 0 1px var(--color-alpha-white-12), 0 0 0 1px + var(--color-red-700), 0 0 0 2px var(--color-alpha-white-88), 0 0 0 4px var(--color-primary-500); } .dark { diff --git a/libs/react/ui/src/components/avatar/avatar-group.tsx b/libs/react/ui/src/components/avatar/avatar-group.tsx new file mode 100644 index 00000000..daa44e21 --- /dev/null +++ b/libs/react/ui/src/components/avatar/avatar-group.tsx @@ -0,0 +1,186 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import {cva, type VariantProps} from 'class-variance-authority'; +import { + Children, + type ComponentProps, + cloneElement, + type ReactElement, + type ReactNode, + useMemo, +} from 'react'; +import {cn} from 'utils/cn'; +import {TooltipContent, TooltipProvider, TooltipTrigger} from '../tooltip/tooltip'; + +const avatarGroupVariants = cva('flex items-start', { + variants: { + size: { + '3xs': '-space-x-4', + '2xs': '-space-x-4', + xs: '-space-x-4', + sm: '-space-x-4', + md: '-space-x-4', + lg: '-space-x-6', + xl: '-space-x-6', + '2xl': '-space-x-12', + '3xl': '-space-x-12', + }, + }, + defaultVariants: { + size: 'md', + }, +}); + +const avatarGroupOverflowVariants = cva( + 'flex shrink-0 items-center justify-center rounded-full bg-background-components-base text-foreground-neutral-subtle font-medium ring-1 ring-border-neutral-base-component ring-offset-1 ring-offset-background-neutral-base shadow-button-neutral', + { + variants: { + size: { + '3xs': 'size-[18px] text-[10px] leading-[10px]', + '2xs': 'size-[20px] text-[11px] leading-[11px]', + xs: 'size-[24px] text-xs leading-4', + sm: 'size-[28px] text-xs leading-5', + md: 'size-[32px] text-sm leading-5', + lg: 'size-[36px] text-sm leading-5', + xl: 'size-[40px] text-base leading-6', + '2xl': 'size-[80px] text-2xl leading-8', + '3xl': 'size-[120px] text-4xl leading-[56px]', + }, + }, + defaultVariants: { + size: 'md', + }, + }, +); + +type TooltipContentProps = ComponentProps; + +type AvatarContainerProps = { + children: ReactNode; + zIndex: number; + tooltipContent?: ReactNode; + tooltipProps?: Partial; + animateOnHover?: boolean; +}; + +function AvatarContainer({ + children, + zIndex, + tooltipContent, + tooltipProps, + animateOnHover = false, +}: AvatarContainerProps) { + return ( + + +
+ {children} +
+
+ {tooltipContent && ( + {tooltipContent} + )} +
+ ); +} + +function getTooltipContent(children: ReactNode): ReactNode | null { + const tooltip = Children.toArray(children).find( + (child) => + typeof child === 'object' && + child !== null && + 'type' in child && + child.type === AvatarGroupTooltip, + ) as ReactElement> | undefined; + + return tooltip?.props.children || null; +} + +type AvatarGroupTooltipProps = TooltipContentProps; + +function AvatarGroupTooltip(props: AvatarGroupTooltipProps) { + return ; +} + +type AvatarGroupProps = ComponentProps<'div'> & + VariantProps & { + children: ReactElement[]; + maxVisible?: number; + animateOnHover?: boolean; + tooltipProps?: Partial; + }; + +export function AvatarGroup({ + className, + size = 'md', + children, + maxVisible, + animateOnHover = false, + tooltipProps = {side: 'top', sideOffset: 8}, + ...props +}: AvatarGroupProps) { + const normalizedSize = size ?? 'md'; + + const childrenArray = Children.toArray(children) as ReactElement[]; + + const {visibleCount, visibleAvatars, overflowCount} = useMemo(() => { + const count = + maxVisible !== undefined ? Math.min(maxVisible, childrenArray.length) : childrenArray.length; + return { + visibleCount: count, + visibleAvatars: childrenArray.slice(0, count), + overflowCount: childrenArray.length - count, + }; + }, [childrenArray, maxVisible]); + + return ( + +
+ {visibleAvatars.map((child, index) => { + const zIndex = index + 1; + const childProps = 'props' in child ? (child.props as {children?: ReactNode}) : {}; + const tooltipContent = getTooltipContent(childProps.children); + + return ( + + {cloneElement(child, { + ...childProps, + children: tooltipContent ? undefined : childProps.children, + } as Partial)} + + ); + })} + {overflowCount > 0 && ( +
+ +{overflowCount} +
+ )} +
+
+ ); +} + +export {AvatarGroupTooltip, type AvatarGroupTooltipProps}; diff --git a/libs/react/ui/src/components/avatar/avatar.stories.tsx b/libs/react/ui/src/components/avatar/avatar.stories.tsx new file mode 100644 index 00000000..af26f811 --- /dev/null +++ b/libs/react/ui/src/components/avatar/avatar.stories.tsx @@ -0,0 +1,172 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Code} from 'components/typography'; +import {Avatar} from './avatar'; +import {AvatarGroup, AvatarGroupTooltip} from './avatar-group'; + +const contentOptions = ['letters', 'logo', 'logoPlaceholder', 'image', 'upload'] as const; +const radiusOptions = ['full', 'rounded'] as const; +const sizeOptions = ['3xs', '2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const; + +const meta = { + title: 'Components/Avatar', + component: Avatar, + tags: ['autodocs'], + argTypes: { + content: { + control: 'select', + options: contentOptions, + }, + radius: { + control: 'select', + options: radiusOptions, + }, + size: { + control: 'select', + options: sizeOptions, + }, + fallback: { + control: 'text', + }, + src: { + control: 'text', + }, + alt: { + control: 'text', + }, + }, + args: { + content: 'letters', + radius: 'full', + size: 'md', + fallback: 'John Doe', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: 'upload', + fallback: 'Kyle Nguyen', + }, + + render: (args) => ( +
+ {sizeOptions.map((size) => ( +
+ + + {size} + +
+ ))} +
+ ), +}; + +// AvatarGroup Stories +const avatarGroupMeta = { + title: 'Components/AvatarGroup', + component: AvatarGroup, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: sizeOptions, + }, + maxVisible: { + control: 'number', + }, + }, + args: { + size: 'md', + children: [], + }, +} satisfies Meta; + +export const AvatarGroupDefault: StoryObj = { + args: { + children: [], + }, + render: () => { + const avatars = [ + {name: 'John Doe', content: 'image'}, + {name: 'Jane Smith', content: 'image'}, + {name: 'Bob Johnson', content: 'image'}, + {name: 'Alice Brown', content: 'image'}, + ] as const; + + return ( +
+
+ + Default (without tooltips) + + + {avatars.map((avatar) => ( + + ))} + +
+
+ ); + }, +}; + +export const AvatarGroupWithTooltips: StoryObj = { + args: { + children: [], + }, + render: () => { + const avatars = [ + {name: 'John Doe', content: 'image'}, + {name: 'Jane Smith', content: 'image'}, + {name: 'Bob Johnson', content: 'image'}, + {name: 'Alice Brown', content: 'image'}, + {name: 'Carlos Vega', content: 'image'}, + {name: 'Linda Tran', content: 'image'}, + ] as const; + + return ( +
+
+ + With Tooltips + + + {avatars.map((avatar) => ( + + {avatar.name} + + ))} + +
+
+ + With Tooltips (maxVisible: 4) + + + {avatars.map((avatar) => ( + + {avatar.name} + + ))} + +
+
+ + With Tooltips and Hover Animation + + + {avatars.map((avatar) => ( + + {avatar.name} + + ))} + +
+
+ ); + }, +}; diff --git a/libs/react/ui/src/components/avatar/avatar.tsx b/libs/react/ui/src/components/avatar/avatar.tsx new file mode 100644 index 00000000..f81211fc --- /dev/null +++ b/libs/react/ui/src/components/avatar/avatar.tsx @@ -0,0 +1,215 @@ +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import {cva, type VariantProps} from 'class-variance-authority'; +import type {ComponentProps, ReactNode} from 'react'; +import {getInitial, getPlaceholderImageUrl} from 'utils'; +import {cn} from 'utils/cn'; +import {Icon} from '../icon/icon'; + +export const avatarVariants = cva( + 'relative flex shrink-0 overflow-hidden bg-background-button-neutral-default text-foreground-neutral-base ring-1 ring-border-neutral-base-component ring-offset-1 ring-offset-background-neutral-base shadow-button-neutral', + { + variants: { + radius: { + full: 'rounded-full', + rounded: 'rounded-6', + }, + size: { + '3xs': 'size-[18px]', + '2xs': 'size-[20px]', + xs: 'size-[24px]', + sm: 'size-[28px]', + md: 'size-[32px]', + lg: 'size-[36px]', + xl: 'size-[40px]', + '2xl': 'size-[80px]', + '3xl': 'size-[120px]', + }, + }, + defaultVariants: { + radius: 'full', + size: 'md', + }, + }, +); + +const avatarInnerVariants = cva('flex h-full w-full items-center justify-center', { + variants: { + size: { + '3xs': 'text-[10px] leading-[10px]', + '2xs': 'text-[11px] leading-[11px]', + xs: 'text-xs leading-4', + sm: 'text-xs leading-5', + md: 'text-sm leading-5', + lg: 'text-sm leading-5', + xl: 'text-base leading-6', + '2xl': 'text-2xl leading-8', + '3xl': 'text-4xl leading-[56px]', + }, + }, + defaultVariants: { + size: 'md', + }, +}); + +export type AvatarContent = 'letters' | 'logo' | 'logoPlaceholder' | 'image' | 'upload'; + +const UPLOAD_ICON_SIZE_MAP: Record< + NonNullable['size']>, + string +> = { + '3xs': 'size-[10px]', + '2xs': 'size-[12px]', + xs: 'size-[14px]', + sm: 'size-[16px]', + md: 'size-[18px]', + lg: 'size-[20px]', + xl: 'size-[24px]', + '2xl': 'size-[40px]', + '3xl': 'size-[60px]', +} as const; + +function AvatarRoot({ + className, + radius, + size, + ...props +}: ComponentProps & VariantProps) { + return ( + + ); +} + +function AvatarImage({className, ...props}: ComponentProps) { + return ( + + ); +} + +function AvatarFallback({className, ...props}: ComponentProps) { + return ( + + ); +} + +export type AvatarProps = ComponentProps & + VariantProps & { + content?: AvatarContent; + src?: string; + alt?: string; + fallback?: string; + animateOnHover?: boolean; + }; + +export function Avatar({ + className, + radius, + size = 'md', + content = 'letters', + src, + alt, + fallback, + animateOnHover = false, + ...props +}: AvatarProps) { + const innerClassName = + 'flex h-full w-full items-center justify-center rounded-inherit relative bg-background-neutral-base dark:bg-background-components-base text-foreground-neutral-subtle'; + + const renderContent = (): ReactNode => { + if (content === 'image') { + const imageSrc = src || getPlaceholderImageUrl(fallback); + return ( + <> + + +
+ {getInitial(fallback)} +
+
+ + ); + } + + if (content === 'logo') { + return ( + + + + ); + } + + if (content === 'logoPlaceholder') { + return ( + + + + ); + } + + if (content === 'letters') { + return ( + +
+ {getInitial(fallback)} +
+
+ ); + } + + if (content === 'upload') { + const iconSizeClass = UPLOAD_ICON_SIZE_MAP[size as keyof typeof UPLOAD_ICON_SIZE_MAP]; + return ( + + + + ); + } + + return null; + }; + + return ( + + {renderContent()} + + ); +} + +export {AvatarRoot, AvatarImage, AvatarFallback}; diff --git a/libs/react/ui/src/components/avatar/index.ts b/libs/react/ui/src/components/avatar/index.ts new file mode 100644 index 00000000..238edc2d --- /dev/null +++ b/libs/react/ui/src/components/avatar/index.ts @@ -0,0 +1,2 @@ +export * from './avatar'; +export * from './avatar-group'; diff --git a/libs/react/ui/src/components/icon/custom/index.ts b/libs/react/ui/src/components/icon/custom/index.ts index 5cca60a9..478ca1fb 100644 --- a/libs/react/ui/src/components/icon/custom/index.ts +++ b/libs/react/ui/src/components/icon/custom/index.ts @@ -6,6 +6,7 @@ export * from './component-line'; export * from './ellipse-mini-solid'; export * from './info-tooltip-fill'; export * from './resize'; +export * from './shipfox-logo'; export * from './spinner'; export * from './thunder'; export * from './x-circle-solid'; diff --git a/libs/react/ui/src/components/icon/custom/shipfox-logo.tsx b/libs/react/ui/src/components/icon/custom/shipfox-logo.tsx new file mode 100644 index 00000000..bbe165d0 --- /dev/null +++ b/libs/react/ui/src/components/icon/custom/shipfox-logo.tsx @@ -0,0 +1,20 @@ +import type {RemixiconComponentType} from '@remixicon/react'; +import type {ComponentProps} from 'react'; + +type ShipfoxLogoProps = ComponentProps & { + color?: string; +}; + +export function ShipfoxLogo({color = '#FF4B00', ...props}: ShipfoxLogoProps) { + return ( + + Shipfox Logo + + + ); +} diff --git a/libs/react/ui/src/components/icon/icon.tsx b/libs/react/ui/src/components/icon/icon.tsx index 25ea2016..5c5f62a4 100644 --- a/libs/react/ui/src/components/icon/icon.tsx +++ b/libs/react/ui/src/components/icon/icon.tsx @@ -2,6 +2,7 @@ import { type RemixiconComponentType, RiCloseLine, RiGoogleFill, + RiImageAddFill, RiMicrosoftFill, } from '@remixicon/react'; import type {ComponentProps} from 'react'; @@ -14,6 +15,7 @@ import { EllipseMiniSolidIcon, InfoTooltipFillIcon, ResizeIcon, + ShipfoxLogo, SpinnerIcon, ThunderIcon, XCircleSolidIcon, @@ -33,7 +35,9 @@ const iconsMap = { spinner: SpinnerIcon, ellipseMiniSolid: EllipseMiniSolidIcon, componentLine: ComponentLineIcon, + imageAdd: RiImageAddFill, close: RiCloseLine, + shipfoxLogo: ShipfoxLogo, } as const satisfies Record; export type IconName = keyof typeof iconsMap; diff --git a/libs/react/ui/src/components/index.ts b/libs/react/ui/src/components/index.ts index f8c47221..92c9914e 100644 --- a/libs/react/ui/src/components/index.ts +++ b/libs/react/ui/src/components/index.ts @@ -1,4 +1,5 @@ export * from './alert'; +export * from './avatar'; export * from './button'; export * from './icon'; export * from './inline-tips'; diff --git a/libs/react/ui/src/components/tooltip/tooltip.tsx b/libs/react/ui/src/components/tooltip/tooltip.tsx new file mode 100644 index 00000000..c5d4b109 --- /dev/null +++ b/libs/react/ui/src/components/tooltip/tooltip.tsx @@ -0,0 +1,52 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import {cn} from 'utils/cn'; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({...props}: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({...props}: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + ); +} + +export {Tooltip, TooltipTrigger, TooltipContent, TooltipProvider}; diff --git a/libs/react/ui/src/utils/avatar.ts b/libs/react/ui/src/utils/avatar.ts new file mode 100644 index 00000000..450b414c --- /dev/null +++ b/libs/react/ui/src/utils/avatar.ts @@ -0,0 +1,27 @@ +const hashString = (str: string): number => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +}; + +export const getPlaceholderImageUrl = (name?: string): string => { + const backgroundColors = ['BFDFFF', 'BFEAFF', 'CFBFFF', 'FFBFC3', 'FFEABF', 'E3E6EA', 'EAEAEA']; + + const seed = name?.trim() || 'avatar'; + + const colorIndex = hashString(seed) % backgroundColors.length; + const backgroundColor = backgroundColors[colorIndex]; + + return `https://api.dicebear.com/9.x/micah/svg?backgroundColor=${backgroundColor}&seed=${encodeURIComponent(seed)}`; +}; + +export const getInitial = (name?: string): string => { + if (name) { + return name.trim()[0]?.toUpperCase() ?? 'L'; + } + return 'L'; +}; diff --git a/libs/react/ui/src/utils/index.ts b/libs/react/ui/src/utils/index.ts index 6935a8ef..6e5d8494 100644 --- a/libs/react/ui/src/utils/index.ts +++ b/libs/react/ui/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './avatar'; export * from './clipboard'; export * from './cn'; export * from './date';