diff --git a/packages/blade/src/components/Avatar/Avatar.native.tsx b/packages/blade/src/components/Avatar/Avatar.native.tsx new file mode 100644 index 00000000000..73f9e303282 --- /dev/null +++ b/packages/blade/src/components/Avatar/Avatar.native.tsx @@ -0,0 +1,14 @@ +import type { AvatarProps } from './types'; +import { throwBladeError } from '~utils/logger'; + +const Avatar = (_props: AvatarProps): React.ReactElement => { + throwBladeError({ + message: 'Avatar is not yet implemented for React Native', + moduleName: 'Avatar', + }); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +}; + +export { Avatar }; diff --git a/packages/blade/src/components/Avatar/Avatar.web.tsx b/packages/blade/src/components/Avatar/Avatar.web.tsx new file mode 100644 index 00000000000..439e8235a52 --- /dev/null +++ b/packages/blade/src/components/Avatar/Avatar.web.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import type { AvatarProps } from './types'; +import { StyledAvatar } from './StyledAvatar'; +import { DefaultAvatarIcon } from './DefaultAvatarIcon'; + +import { useAvatarGroupContext } from './AvatarGroupContext'; +import { getStyledProps } from '~components/Box/styledProps'; +import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; +import { useDropdown } from '~components/Dropdown/useDropdown'; +import { getActionListContainerRole } from '~components/ActionList/getA11yRoles'; +import BaseButton from '~components/Button/BaseButton/BaseButton'; +import { throwBladeError } from '~utils/logger'; + +const getInitials = (name: string): string => { + // Combine first and last name initials + const names = name.split(' '); + if (names.length === 1) { + return name.substring(0, 2); + } + return names[0].substring(0, 1) + names[names.length - 1].substring(0, 1); +}; + +const _Avatar = ({ + name = 'Nitin Kumar', + color = 'neutral', + size = 'xsmall', + variant = 'circle', + icon, + href, + target, + rel, + // Image Props + src, + alt, + srcSet, + crossOrigin, + referrerPolicy, + testID, + ...styledProps +}: AvatarProps): React.ReactElement => { + if (src && !alt && !name) { + throwBladeError({ + moduleName: 'Avatar', + message: '"alt" or "name" prop is required when the "src" prop is provided.', + }); + } + const accessibilityLabel = alt ?? name; + const groupProps = useAvatarGroupContext(); + console.log('🚀 ~ groupProps:', groupProps); + const avatarSize = groupProps?.size ?? size; + + const { + onTriggerClick, + onTriggerKeydown, + dropdownBaseId, + isOpen, + activeIndex, + hasFooterAction, + triggererRef, + dropdownTriggerer, + } = useDropdown(); + const isInsideDropdown = dropdownTriggerer === 'Avatar'; + + const dropDownTriggerProps = isInsideDropdown + ? { + ref: triggererRef, + onClick: onTriggerClick, + accessibilityProps: { + label: accessibilityLabel, + hasPopup: getActionListContainerRole(hasFooterAction, 'DropdownButton'), + expanded: isOpen, + controls: `${dropdownBaseId}-actionlist`, + activeDescendant: activeIndex >= 0 ? `${dropdownBaseId}-${activeIndex}` : undefined, + }, + } + : {}; + + const getChildrenToRender = (): React.ReactElement => { + if (src) { + return ( + { + if (isInsideDropdown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-explicit-any + onTriggerKeydown?.({ event: e as any }); + } + }} + /> + ); + } + + if (name && !src) { + return ( + { + if (isInsideDropdown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-explicit-any + onTriggerKeydown?.({ event: e as any }); + } + }} + {...dropDownTriggerProps} + > + {getInitials(name)} + + ); + } + + return ( + { + if (isInsideDropdown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-explicit-any + onTriggerKeydown?.({ event: e as any }); + } + }} + {...dropDownTriggerProps} + /> + ); + }; + + return ( + + {getChildrenToRender()} + + ); +}; + +/** + * ### Avatar Component + * + * The Avatar component is used to group related buttons together. + * + * --- + * + * #### Usage + * + * ```jsx + + * ``` + * + * --- + * + * Checkout {@link https://blade.razorpay.com/?path=/docs/components-avatar Avatar Documentation} + * + */ +const Avatar = assignWithoutSideEffects(_Avatar, { + displayName: 'Avatar', + componentId: 'Avatar', +}); + +export { Avatar }; +export type { AvatarProps }; diff --git a/packages/blade/src/components/Avatar/AvatarGroup.web.tsx b/packages/blade/src/components/Avatar/AvatarGroup.web.tsx new file mode 100644 index 00000000000..84401a7805f --- /dev/null +++ b/packages/blade/src/components/Avatar/AvatarGroup.web.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import type { AvatarGroupProps, AvatarGroupContextType } from './types'; +import { StyledAvatarGroup } from './StyledAvatarGroup'; +import { StyledAvatar } from './StyledAvatar'; +import { AvatarGroupProvider } from './AvatarGroupContext'; +import { getStyledProps } from '~components/Box/styledProps'; +import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; +import { throwBladeError } from '~utils/logger'; +import { isValidAllowedChildren } from '~utils/isValidAllowedChildren'; +import BaseButton from '~components/Button/BaseButton/BaseButton'; + +const _AvatarGroup = ({ + children, + size = 'xsmall', + maxCount = 2, + testID, + ...styledProps +}: AvatarGroupProps): React.ReactElement => { + const contextValue: AvatarGroupContextType = { + size, + }; + + return ( + + + {React.Children.map(children, (child, index) => { + if (__DEV__) { + // throw error if child is not an Avatar + if (!isValidAllowedChildren(child, 'Avatar')) { + throwBladeError({ + moduleName: 'AvatarGroup', + message: `Only "Avatar" component is allowed as a children.`, + }); + } + } + + if (maxCount && maxCount <= React.Children.count(children)) { + if (index === maxCount) { + return ( + + + {`+${String(React.Children.count(children) - maxCount)}`} + + + ); + } + + if (index > maxCount) { + return null; + } + } + + return child; + })} + + + ); +}; + +/** + * ### AvatarGroup Component + * + * The Avatar component is used to group related buttons together. + * + * --- + * + * #### Usage + * + * ```jsx + const App = () => { + return ( + + + + + + ); + } + * ``` + * + * --- + * + * Checkout {@link https://blade.razorpay.com/?path=/docs/components-buttongroup FileUpload Documentation} + * + */ +const AvatarGroup = assignWithoutSideEffects(_AvatarGroup, { + displayName: 'AvatarGroup', + componentId: 'AvatarGroup', +}); + +export { AvatarGroup }; +export type { AvatarGroupProps }; diff --git a/packages/blade/src/components/Avatar/AvatarGroupContext.tsx b/packages/blade/src/components/Avatar/AvatarGroupContext.tsx new file mode 100644 index 00000000000..4b71fc933da --- /dev/null +++ b/packages/blade/src/components/Avatar/AvatarGroupContext.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { AvatarGroupContextType } from './types'; + +const AvatarGroupContext = React.createContext({}); +const AvatarGroupProvider = AvatarGroupContext.Provider; + +const useAvatarGroupContext = (): AvatarGroupContextType => { + const context = React.useContext(AvatarGroupContext); + return context; +}; + +export { useAvatarGroupContext, AvatarGroupProvider }; diff --git a/packages/blade/src/components/Avatar/DefaultAvatarIcon.tsx b/packages/blade/src/components/Avatar/DefaultAvatarIcon.tsx new file mode 100644 index 00000000000..667b35fd2e0 --- /dev/null +++ b/packages/blade/src/components/Avatar/DefaultAvatarIcon.tsx @@ -0,0 +1,82 @@ +import { Svg, Path } from '~components/Icons/_Svg'; +import type { IconComponent } from '~components/Icons'; +import useIconProps from '~components/Icons/useIconProps'; + +const DefaultAvatarIcon: IconComponent = ({ color, size, ...styledProps }) => { + const { iconColor } = useIconProps({ color }); + + if (size === 'xsmall') { + return ( + + + + + ); + } + + if (size === 'small') { + return ( + + + + + ); + } + + if (size === 'medium') { + return ( + + + + + ); + } + + if (size === 'large') { + return ( + + + + + ); + } + + return ( + + + + + ); +}; + +export { DefaultAvatarIcon }; diff --git a/packages/blade/src/components/Avatar/StyledAvatar.tsx b/packages/blade/src/components/Avatar/StyledAvatar.tsx new file mode 100644 index 00000000000..efdba49bdc6 --- /dev/null +++ b/packages/blade/src/components/Avatar/StyledAvatar.tsx @@ -0,0 +1,92 @@ +import styled from 'styled-components'; +import type { StyledAvatarProps } from './types'; +import { + avatarSizeTokens, + avatarTextSizeMapping, + avatarColorTokens, + avatarBorderRadiusTokens, +} from './avatarTokens'; +import BaseBox from '~components/Box/BaseBox'; +import { makeBorderSize, makeSize } from '~utils'; +import { getBackgroundColorToken } from '~components/Button/BaseButton/BaseButton'; +import getIn from '~utils/lodashButBetter/get'; +import getBaseTextStyles from '~components/Typography/BaseText/getBaseTextStyles'; +import { getTextProps, getHeadingProps } from '~components/Typography'; + +const StyledAvatar = styled(BaseBox)(({ theme, variant, color, size }) => { + return { + width: 'fit-content', + // borderWidth: makeBorderSize(theme.border.width.thinner), + borderRadius: makeBorderSize(theme.border.radius[avatarBorderRadiusTokens[variant]]), + // borderColor: getIn(theme.colors, 'surface.border.gray.subtle'), + // borderStyle: 'solid', + + // '&:hover': { + // borderColor: getIn(theme.colors, 'surface.border.gray.muted'), + + // 'button[role="button"]': { + // borderColor: getIn(theme.colors, 'surface.border.gray.muted'), + // backgroundColor: getIn(theme.colors, avatarColorTokens.background[color]), + // }, + // }, + + 'div[data-blade-component="base-text"]': { + ...getBaseTextStyles({ + theme, + ...(size === 'xlarge' + ? { + ...getHeadingProps({ + size: avatarTextSizeMapping[size].size, + weight: 'semibold', + color: getIn(theme.colors, avatarColorTokens.text[color]), + }), + } + : { + ...getTextProps({ + variant: avatarTextSizeMapping[size].variant, + size: avatarTextSizeMapping[size].size, + weight: 'semibold', + color: getIn(theme.colors, avatarColorTokens.text[color]), + }), + }), + }), + }, + + 'button[role="button"]': { + minHeight: makeSize(avatarSizeTokens[size]), + height: makeSize(avatarSizeTokens[size]), + width: makeSize(avatarSizeTokens[size]), + borderWidth: makeBorderSize(theme.border.width.thin), + borderRadius: makeBorderSize(theme.border.radius[avatarBorderRadiusTokens[variant]]), + borderColor: getIn(theme.colors, 'transparent'), + backgroundColor: getIn(theme.colors, avatarColorTokens.background[color]), + + img: { + display: 'block', + height: avatarSizeTokens[size], + width: avatarSizeTokens[size], + borderRadius: makeBorderSize(theme.border.radius[avatarBorderRadiusTokens[variant]]), + objectFit: 'cover', + }, + + '&:hover': { + borderColor: getIn(theme.colors, 'surface.border.gray.muted'), + backgroundColor: getIn(theme.colors, avatarColorTokens.background[color]), + }, + + '&:focus': { + backgroundColor: getIn( + theme.colors, + getBackgroundColorToken({ + property: 'background', + variant: 'secondary', + color, + state: 'default', + }), + ), + }, + }, + }; +}); + +export { StyledAvatar }; diff --git a/packages/blade/src/components/Avatar/StyledAvatarGroup.tsx b/packages/blade/src/components/Avatar/StyledAvatarGroup.tsx new file mode 100644 index 00000000000..7f553e1c693 --- /dev/null +++ b/packages/blade/src/components/Avatar/StyledAvatarGroup.tsx @@ -0,0 +1,21 @@ +import styled from 'styled-components'; +import type { AvatarGroupProps } from './types'; +import { avatarSizeTokens } from './avatarTokens'; +import BaseBox from '~components/Box/BaseBox'; +import { makeSize } from '~utils'; + +const StyledAvatarGroup = styled(BaseBox)<{ size: NonNullable }>( + ({ size }) => { + return { + display: 'inline-flex', + flexDirection: 'row', + + [`> *:not(:first-child)`]: { + marginLeft: `-${makeSize(avatarSizeTokens[size] / 2)}`, + zIndex: 2, + }, + }; + }, +); + +export { StyledAvatarGroup }; diff --git a/packages/blade/src/components/Avatar/avatarTokens.ts b/packages/blade/src/components/Avatar/avatarTokens.ts new file mode 100644 index 00000000000..240451c505c --- /dev/null +++ b/packages/blade/src/components/Avatar/avatarTokens.ts @@ -0,0 +1,105 @@ +import { Theme } from '~components/BladeProvider'; +import { size } from '~tokens/global'; +import type { DotNotationToken } from '~utils/lodashButBetter/get'; + +const avatarSizeTokens = { + xsmall: size[20], + small: size[28], + medium: size[36], + large: size[48], + xlarge: size[56], +}; + +const avatarIconSizeTokens = { + xsmall: size[16], + small: size[16], + medium: size[20], + large: size[24], + xlarge: size[30], +}; + +const avatarTextSizeMapping = { + xsmall: { + variant: 'body', + size: 'xsmall', + }, + small: { + variant: 'body', + size: 'xsmall', + }, + medium: { + variant: 'body', + size: 'small', + }, + large: { + variant: 'body', + size: 'medium', + }, + xlarge: { + variant: 'heading', + size: 'medium', + }, +} as const; + +type InteractiveTextColors< + T extends 'positive' | 'negative' | 'primary' | 'notice' | 'information' | 'neutral' +> = `interactive.text.${T}.${DotNotationToken}`; + +type InteractiveBackgroundColors< + T extends 'positive' | 'negative' | 'primary' | 'notice' | 'information' | 'neutral' +> = `interactive.background.${T}.${DotNotationToken< + Theme['colors']['interactive']['background'][T] +>}`; + +type AvatarBackgroundColors = + | InteractiveBackgroundColors<'positive'> + | InteractiveBackgroundColors<'negative'> + | InteractiveBackgroundColors<'primary'> + | InteractiveBackgroundColors<'notice'> + | InteractiveBackgroundColors<'neutral'> + | InteractiveBackgroundColors<'information'>; + +type AvatarTextColors = + | InteractiveTextColors<'positive'> + | InteractiveTextColors<'negative'> + | InteractiveTextColors<'primary'> + | InteractiveTextColors<'notice'> + | InteractiveTextColors<'neutral'> + | InteractiveTextColors<'information'>; + +type AvatarColorTokensType = { + text: Record; + background: Record; +}; + +const avatarColorTokens: AvatarColorTokensType = { + text: { + primary: 'interactive.text.primary.normal', + positive: 'interactive.text.positive.normal', + negative: 'interactive.text.negative.normal', + notice: 'interactive.text.notice.normal', + information: 'interactive.text.information.normal', + neutral: 'interactive.text.neutral.normal', + }, + background: { + primary: 'interactive.background.primary.faded', + positive: 'interactive.background.positive.faded', + negative: 'interactive.background.negative.faded', + notice: 'interactive.background.notice.faded', + information: 'interactive.background.information.faded', + neutral: 'interactive.background.neutral.faded', + }, +}; + +const avatarBorderRadiusTokens = { + circle: 'max', + square: 'medium', +} as const; + +export { + avatarSizeTokens, + avatarIconSizeTokens, + avatarTextSizeMapping, + avatarColorTokens, + avatarBorderRadiusTokens, +}; diff --git a/packages/blade/src/components/Avatar/docs/Avatar.stories.tsx b/packages/blade/src/components/Avatar/docs/Avatar.stories.tsx new file mode 100644 index 00000000000..165ee8ec492 --- /dev/null +++ b/packages/blade/src/components/Avatar/docs/Avatar.stories.tsx @@ -0,0 +1,285 @@ +import type { StoryFn, Meta } from '@storybook/react'; +import type { AvatarProps } from '../Avatar.web'; +import { Avatar as AvatarComponent } from '../Avatar.web'; +import { AvatarGroup as AvatarGroupComponent } from '../AvatarGroup.web'; +import { Heading } from '~components/Typography/Heading'; +import { Box } from '~components/Box'; +import { Sandbox } from '~utils/storybook/Sandbox'; +import StoryPageWrapper from '~utils/storybook/StoryPageWrapper'; +import { Button } from '~components/Button'; +import { getStyledPropsArgTypes } from '~components/Box/BaseBox/storybookArgTypes'; +import { RefreshIcon, ShareIcon, DownloadIcon, ChevronDownIcon, PlusIcon } from '~components/Icons'; +import { Dropdown, DropdownButton, DropdownOverlay } from '~components/Dropdown'; +import { ActionList, ActionListItem } from '~components/ActionList'; +import iconMap from '~components/Icons/iconMap'; + +const Page = (): React.ReactElement => { + return ( + + Usage + + {` + import { + Button, + Avatar, + RefreshIcon, + ShareIcon, + DownloadIcon, + } from '@razorpay/blade/components'; + + function App(): React.ReactElement { + return ( + + + + + + ) + } + + export default App; + `} + + + ); +}; + +export default { + title: 'Components/Avatar', + component: AvatarComponent, + tags: ['autodocs'], + argTypes: { + ...getStyledPropsArgTypes(), + icon: { + name: 'icon', + type: 'select', + options: Object.keys(iconMap), + mapping: iconMap, + }, + }, + parameters: { + docs: { + page: Page, + }, + }, +} as Meta; + +const AvatarTemplate: StoryFn = (args) => { + return ; +}; + +export const Default = AvatarTemplate.bind({}); +Default.storyName = 'Default'; + +const AvatarSizesTemplate: StoryFn = (args) => { + const sizes = ['xsmall', 'small', 'medium', 'large', 'xlarge'] as const; + return ( + + {sizes.map((size) => ( + + + {size} + + + + + + ))} + + ); +}; + +export const AllSizes = AvatarSizesTemplate.bind({}); +AllSizes.storyName = 'All Sizes'; + +const AvatarColorsTemplate: StoryFn = (args) => { + const colors = ['primary', 'positive', 'negative', 'neutral', 'notice', 'information'] as const; + return ( + + {colors.map((color) => ( + + + {color} + + + + + + ))} + + ); +}; + +export const AllColors = AvatarColorsTemplate.bind({}); +AllColors.storyName = 'All Colors'; + +const AvatarVariantsTemplate: StoryFn = (args) => { + const variants = ['circle', 'square'] as const; + return ( + + {variants.map((variant) => ( + + + {variant} + + + + + + ))} + + ); +}; + +export const AllVariants = AvatarVariantsTemplate.bind({}); +AllVariants.storyName = 'All Variants'; + +const AvatarGroupTemplate: StoryFn = (args) => { + // const variants = ['circle', 'square'] as const; + return ( + + {/* {variants.map((variant) => ( + + + {variant} + + + + + + ))} */} + + + + + + + + ); +}; + +export const AvatarGroup = AvatarGroupTemplate.bind({}); +AvatarGroup.storyName = 'AvatarGroup'; +// const AvatarDropdownTemplate: StoryFn = (args) => { +// return ( +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// ); +// }; + +// export const WithDropdown = AvatarDropdownTemplate.bind({}); +// WithDropdown.storyName = 'With Dropdown'; + +// const AvatarVariantsTemplate: StoryFn = (args) => { +// const variants: AvatarProps['variant'][] = ['primary', 'secondary', 'tertiary']; +// return ( +// <> +// {variants.map((variant) => ( +// +// {variant} +// +// +// +// +// +// +// ))} +// +// ); +// }; + +// export const AllVariants = AvatarVariantsTemplate.bind({}); +// AllVariants.storyName = 'All Variants'; + +// const AvatarSizesTemplate: StoryFn = (args) => { +// const sizes: AvatarProps['size'][] = ['xsmall', 'small', 'medium', 'large']; +// return ( +// <> +// {sizes.map((size) => ( +// +// {size} +// +// +// +// +// +// +// ))} +// +// ); +// }; + +// export const AllSizes = AvatarSizesTemplate.bind({}); +// AllSizes.storyName = 'All Sizes'; + +// const AvatarIconOnlyTemplate: StoryFn = (args) => { +// return ( +// +//