diff --git a/packages/ui-toolkit/package.json b/packages/ui-toolkit/package.json index d5c912f7..4a0cdd0c 100644 --- a/packages/ui-toolkit/package.json +++ b/packages/ui-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@groww-tech/ui-toolkit", - "version": "0.5.4", + "version": "0.5.5", "description": "A lightning nature UI", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/ui-toolkit/src/components/atoms/Avatar/Avatar.tsx b/packages/ui-toolkit/src/components/atoms/Avatar/Avatar.tsx new file mode 100644 index 00000000..6dacce10 --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/Avatar/Avatar.tsx @@ -0,0 +1,183 @@ +import React, { useState, useEffect } from 'react'; +import cn from 'classnames'; +import { AccountBalance } from '@groww-tech/icon-store/mi'; + +import { AVATAR_SHAPES, AVATAR_SIZES } from './avatar.constants'; + +import './avatar.css'; + + +function getInitials(name: string) { + const names = name.trim().split(' '); + const firstName = names[0] ?? ''; + const lastName = names.length > 1 ? names[names.length - 1] : ''; + + const initial = firstName && lastName ? `${firstName.charAt(0)}${lastName.charAt(0)}` : firstName.charAt(0); + + return initial.toUpperCase(); +} + + +type Status = 'loading' | 'failed' | 'pending' | 'loaded'; + + +const useImage = (src: string): Status => { + const [ status, setStatus ] = useState('pending'); + + useEffect(() => { + const image = new Image(); + + image.src = src; + + image.onload = () => { + setStatus('loaded'); + }; + + image.onerror = () => { + setStatus('failed'); + }; + + setStatus('loading'); + + return () => { + // Cleanup on unmount or if src changes + image.onload = null; + image.onerror = null; + }; + }, [ src ]); + + return status; +}; + + +const Avatar = (props: Props) => { + const { src, name, size, isDisabled, shape } = props; + + const status = useImage(src); + + const initials = getInitials(name); + + + const getAvatarSize = () => { + switch (size) { + case AVATAR_SIZES.XSMALL: + return 24; + + case AVATAR_SIZES.SMALL: + return 32; + + case AVATAR_SIZES.BASE: + return 40; + + case AVATAR_SIZES.LARGE: + return 48; + + case AVATAR_SIZES.XLARGE: + return 56; + + default: + return 40; + } + }; + + const avatarSize = getAvatarSize(); + + const avatarDimensionClasses = cn(`av91Avatar${size}`); + + const avatarInitialClasses = cn('av91AvatarInitialContainer absolute-center circle'); + + const avatarImgClasses = cn({ + circle: shape === AVATAR_SHAPES.CIRCULAR, + av91AvatarRectangular: shape === AVATAR_SHAPES.RECTANGULAR, + av91AvatarImageDisabled: isDisabled + }); + + const avatarIconContainerClasses = cn('av91AvatarInitialContainer absolute-center', { + av91AvatarRectangular: shape === AVATAR_SHAPES.RECTANGULAR, + backgroundTertiary: !isDisabled, + backgroundSecondary: isDisabled + }); + + const avatarIconClasses = cn({ + contentDisabled: isDisabled, + contentSecondary: !isDisabled + }); + + const backgroundClasses = cn({ + backgroundAccentSubtle: !isDisabled, + backgroundTertiary: isDisabled + }); + + const fontClasses = cn({ + bodySmallHeavy: size === AVATAR_SIZES.XSMALL || size === AVATAR_SIZES.SMALL, + bodyBaseHeavy: size === AVATAR_SIZES.BASE, + bodyLargeHeavy: size === AVATAR_SIZES.LARGE, + bodyXLargeHeavy: size === AVATAR_SIZES.XLARGE, + contentPositive: !isDisabled, + contentDisabled: isDisabled + }); + + const avatarInitial = ( +
+ {initials} +
+ ); + + const avatarIcon = ( +
+ +
+ ); + + const avatarImage = {name}; + const avatarDisabled = shape === AVATAR_SHAPES.RECTANGULAR ? avatarIcon : avatarInitial; + + switch (status) { + case 'loading': + return avatarDisabled; + + case 'failed': + return avatarDisabled; + + case 'loaded': + return avatarImage; + + case 'pending': + return avatarDisabled; + + default: + return avatarDisabled; + } +}; + + +type RequiredProps = { + name: string; + src: string; +}; + + +type DefaultProps = { + size: ValueOf; + shape: ValueOf; + isDisabled: boolean; +}; + +export type Props = RequiredProps & DefaultProps & Partial; + +Avatar.defaultProps = { + isDisabled: false, + size: AVATAR_SIZES.BASE, + shape: AVATAR_SHAPES.CIRCULAR +} as DefaultProps; + +export default Avatar; diff --git a/packages/ui-toolkit/src/components/atoms/Avatar/avatar.constants.ts b/packages/ui-toolkit/src/components/atoms/Avatar/avatar.constants.ts new file mode 100644 index 00000000..fc7d7ed2 --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/Avatar/avatar.constants.ts @@ -0,0 +1,12 @@ +export const AVATAR_SHAPES = { + CIRCULAR: 'Circular', + RECTANGULAR: 'Rectangular' +} as const; + +export const AVATAR_SIZES = { + XSMALL: 'XSmall', + SMALL: 'Small', + BASE: 'Base', + LARGE: 'Large', + XLARGE: 'XLarge' +} as const; diff --git a/packages/ui-toolkit/src/components/atoms/Avatar/avatar.css b/packages/ui-toolkit/src/components/atoms/Avatar/avatar.css new file mode 100644 index 00000000..f0334a38 --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/Avatar/avatar.css @@ -0,0 +1,37 @@ +.av91AvatarInitialContainer { + width: fit-content; + height: fit-content; +} + +.av91AvatarRectangular { + border-radius: 8px; +} + +.av91AvatarImageDisabled { + filter: grayscale(100%) opacity(50%); +} + +.av91AvatarXSmall { + height: 24px; + width: 24px; +} + +.av91AvatarSmall { + height: 32px; + width: 32px; +} + +.av91AvatarBase { + height: 40px; + width: 40px; +} + +.av91AvatarLarge { + height: 48px; + width: 48px; +} + +.av91AvatarXLarge { + height: 56px; + width: 56px; +} diff --git a/packages/ui-toolkit/src/components/atoms/Avatar/index.ts b/packages/ui-toolkit/src/components/atoms/Avatar/index.ts new file mode 100644 index 00000000..fdf35d3a --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/Avatar/index.ts @@ -0,0 +1 @@ +export { default as Avatar } from './Avatar'; diff --git a/packages/ui-toolkit/src/components/atoms/Divider/Divider.tsx b/packages/ui-toolkit/src/components/atoms/Divider/Divider.tsx new file mode 100644 index 00000000..ec4aa0c0 --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/Divider/Divider.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import './divider.css'; + + +const Divider = () => { + return
; +}; + +export default Divider; diff --git a/packages/ui-toolkit/src/components/atoms/Divider/divider.css b/packages/ui-toolkit/src/components/atoms/Divider/divider.css new file mode 100644 index 00000000..ca83422c --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/Divider/divider.css @@ -0,0 +1,5 @@ +.divider23divider { + border-top: 1px; + width: 100%; + margin: 0; +} \ No newline at end of file diff --git a/packages/ui-toolkit/src/components/atoms/Divider/index.ts b/packages/ui-toolkit/src/components/atoms/Divider/index.ts new file mode 100644 index 00000000..d84c86b0 --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/Divider/index.ts @@ -0,0 +1 @@ +export { default as Divider } from './Divider'; diff --git a/packages/ui-toolkit/src/components/atoms/IconView/IconView.tsx b/packages/ui-toolkit/src/components/atoms/IconView/IconView.tsx new file mode 100644 index 00000000..277fb01a --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/IconView/IconView.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import cn from 'classnames'; + +import { ReactIconProps } from '@groww-tech/icon-store'; + +import { ICON_VIEW_SIZES } from './iconView.constants'; + +import './iconView.css'; + +export default function IconView(props: Props) { + const { iconComponent, size, isContained } = props; + + + const getIconSize = () => { + switch (size) { + case ICON_VIEW_SIZES.SMALL: + return 16; + + case ICON_VIEW_SIZES.BASE: + return 20; + + case ICON_VIEW_SIZES.LARGE: + return 24; + + case ICON_VIEW_SIZES.XLARGE: + return 28; + + default: + return 20; + } + }; + + const iconProps = { + className: 'absolute-center', + size: getIconSize() + }; + + const iconViewClasses = cn(`iv98IconContainer iv98${size} circle`, { + backgroundTertiary: isContained + }); + + return
{iconComponent(iconProps as ReactIconProps)}
; +} + + +type RequiredProps = { + iconComponent: (props: ReactIconProps) => JSX.Element; +}; + + +type DefaultProps = { + size?: ValueOf; + isContained?: boolean; +}; + +IconView.defaultProps = { + size: ICON_VIEW_SIZES.BASE, + isContained: false +}; + +export type Props = RequiredProps & DefaultProps; diff --git a/packages/ui-toolkit/src/components/atoms/IconView/iconView.constants.ts b/packages/ui-toolkit/src/components/atoms/IconView/iconView.constants.ts new file mode 100644 index 00000000..5a1ef02b --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/IconView/iconView.constants.ts @@ -0,0 +1,6 @@ +export const ICON_VIEW_SIZES = { + SMALL: 'Small', + BASE: 'Base', + LARGE: 'Large', + XLARGE: 'XLarge' +} as const; diff --git a/packages/ui-toolkit/src/components/atoms/IconView/iconView.css b/packages/ui-toolkit/src/components/atoms/IconView/iconView.css new file mode 100644 index 00000000..67ca3b0c --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/IconView/iconView.css @@ -0,0 +1,20 @@ +.iv98IconContainer { + width: fit-content; + height: fit-content; +} + +.iv98Small { + padding: 8px; +} + +.iv98Base { + padding: 10px; +} + +.iv98Large { + padding: 12px; +} + +.iv98XLarge { + padding: 14px; +} diff --git a/packages/ui-toolkit/src/components/atoms/IconView/index.tsx b/packages/ui-toolkit/src/components/atoms/IconView/index.tsx new file mode 100644 index 00000000..017104e8 --- /dev/null +++ b/packages/ui-toolkit/src/components/atoms/IconView/index.tsx @@ -0,0 +1 @@ +export { default as IconView } from './IconView'; diff --git a/packages/ui-toolkit/src/components/atoms/Tabs/Tabs.tsx b/packages/ui-toolkit/src/components/atoms/Tabs/Tabs.tsx index 05749405..f9cbca85 100644 --- a/packages/ui-toolkit/src/components/atoms/Tabs/Tabs.tsx +++ b/packages/ui-toolkit/src/components/atoms/Tabs/Tabs.tsx @@ -1,27 +1,52 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import cn from 'classnames'; import './tabs.css'; +type TabMeta = { + left: number; + width: number; +}; + + const Tabs = (props: Props) => { - const { - onTabSelect, - activeTabIndexOnMount, - data, - showBottomBorder, - customStyleTab, - isHorizScrollable - } = props; + const { onTabSelect, defaultIndex, data, showBottomBorder, isFitted } = props; - const [ activeIndex, setActiveIndex ] = useState(activeTabIndexOnMount); + const [ activeIndex, setActiveIndex ] = useState(defaultIndex); + const tabsRef = useRef(null); + const [ tabsMeta, setTabsMeta ] = useState([]); - const { width, left } = getActiveTabDimensions(data, activeIndex); + useEffect(() => { + setActiveIndex(props.defaultIndex); + }, [ props.defaultIndex ]); useEffect(() => { - setActiveIndex(props.activeTabIndexOnMount); - }, [ props.activeTabIndexOnMount ]); + const computeDimensions = () => { + if (!tabsRef.current) return; + + const tabsMetadata: TabMeta[] = []; + + for (let i = 0; i < tabsRef.current.childElementCount; i++) { + const element = tabsRef.current.children[i] as HTMLElement; + + tabsMetadata.push({ + width: element.offsetWidth, + left: element.offsetLeft + }); + } + + setTabsMeta(tabsMetadata); + }; + + computeDimensions(); + + window.addEventListener('load', computeDimensions); + return () => { + window.removeEventListener('load', computeDimensions); + }; + }, [ isFitted ]); const onTabClick = (index: number) => { @@ -32,113 +57,78 @@ const Tabs = (props: Props) => { onTabSelect(index); }; + const tabClasses = cn('tabs8Tab', { + borderPrimary: showBottomBorder + }); - return ( -
- { - ((typeof width === 'number' ? width : parseInt(width)) > 0) && -
- } + const tabItemClasses = cn('tabs8TabItem cur-po headingXSmall'); + + const tabParentClasses = cn('valign-wrapper tabs8Parent', { + flex: isFitted + }); -
+ return ( +
+
{ data.map((item, key) => { return ( -
{item.name} -
+ ); }) }
+ { + tabsMeta[activeIndex] && + }
); }; - -const getActiveTabDimensions = (data: Tab[], activeIndex: number) => { - let left = 0; - let width:number|string = 0; - - const activeTab = data[activeIndex]; - - if (activeTab && activeTab.hasOwnProperty('width') && activeTab.hasOwnProperty('left')) { - width = activeTab.width || 0; - left = activeTab.left || 0; - - return { - 'width': width, - 'left': left - }; - - } else { - if (typeof document !== 'undefined') { - const prevActiveElement = document?.getElementsByClassName('tabs8TextActive') as HTMLCollectionOf; - - if (prevActiveElement && prevActiveElement.length) { - const currentActiveElement = prevActiveElement[0]?.parentElement?.children[activeIndex] as HTMLElement; - - return { - 'width': currentActiveElement?.offsetWidth, - 'left': currentActiveElement?.offsetLeft - }; - - } else { - return { - 'width': width, - 'left': left - }; - } - - } else { - return { - 'width': 0, - 'left': 0 - }; - } - } - -}; - - const defaultProps: DefaultProps = { showBottomBorder: true, - activeTabIndexOnMount: 0, - customStyleTab: 'tabs8Text', - isHorizScrollable: false + defaultIndex: 0, + isFitted: false }; type DefaultProps = { showBottomBorder: boolean; - activeTabIndexOnMount: number; - customStyleTab: string; - isHorizScrollable: boolean; -} + defaultIndex: number; + isFitted: boolean; +}; type RequiredProps = { data: Tab[]; onTabSelect: (index: number) => void; -} +}; type Tab = { description?: string; - name: React.ReactNode; - style?: React.CSSProperties; - width?: number | string; - left?: number; -} - + name: string; +}; Tabs.defaultProps = defaultProps; diff --git a/packages/ui-toolkit/src/components/atoms/Tabs/tabs.css b/packages/ui-toolkit/src/components/atoms/Tabs/tabs.css index ed14a154..946c30e0 100644 --- a/packages/ui-toolkit/src/components/atoms/Tabs/tabs.css +++ b/packages/ui-toolkit/src/components/atoms/Tabs/tabs.css @@ -1,75 +1,60 @@ -.tabs8Container { +.tabs8Tab { position: relative; - max-height: 55px; -} + border-width: 0 0 1px 0; + + overflow-x: auto; -.bottomBorderOnly { - border-width: 0 0 1px 0 !important; + /* IE and Edge */ + -ms-overflow-style: none; + + /* Firefox */ + scrollbar-width: none; } -.tabs8Text { - line-height: 15px; - padding-right: 45px; - height: 30px; - cursor: pointer; - color: var(--gray900); +.tabs8Tab::-webkit-scrollbar { + display: none; } -.tabs8TextActive { - color: var(--green500); +.tabs8Parent { + min-width: max-content; } .tabs8Line { position: absolute; bottom: 0; height: 3px; + width: 0; background: var(--green500); will-change: left, width; - transition: left 0.2s linear, width 0.2s linear; + transition: left 0.2s, width 0.2s; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); } +.tabs8TabItem { + border: 0; + margin: 0; + padding: 0; + width: auto; + overflow: visible; + background: transparent; + line-height: normal; + padding: 14px 35px; +} +.tabs8TabItem::-moz-focus-inner { + border: 0; + padding: 0; +} -@media (max-width: 1025px) { - .tabs8Container { - max-height: 25px; - overflow-x: auto; - - /* IE and Edge */ - -ms-overflow-style: none; - - /* Firefox */ - scrollbar-width: none; - } - - .tabs8Container::-webkit-scrollbar { - display: none; - } - - .tabs8PageWidth20Mgn { - width: calc(100vw - 40px); - overflow-y: auto; - } - - .tabs8Text { - font-size: 15px; - font-weight: 500; - border-radius: 25px; - padding-right: 24px; - height: 25px; - -webkit-font-smoothing: auto; - } - - .tabs8TextActive { - color: var(--green500); - } +.tabs8TabItem:hover { + background-color: var(--gray50); +} - .tabs8Line { - will-change: left, width; - transition: left 0.2s linear, width 0.2s linear; - } +.tabs8TabItem:active { + background-color: var(--gray100); +} - .tabs8Parent { - min-width: max-content; - } +.tabs8TabItemFlex1 { + flex: 1; + flex-wrap: nowrap; } diff --git a/packages/ui-toolkit/src/components/atoms/index.ts b/packages/ui-toolkit/src/components/atoms/index.ts index ca84f359..f85d8aee 100644 --- a/packages/ui-toolkit/src/components/atoms/index.ts +++ b/packages/ui-toolkit/src/components/atoms/index.ts @@ -31,3 +31,6 @@ export * from './NumberInput'; export * from './InputField'; export * from './NumberPicker'; export * from './IconButton'; +export * from './Avatar'; +export * from './IconView'; +export * from './Divider'; diff --git a/packages/ui-toolkit/stories/Avatar.stories.tsx b/packages/ui-toolkit/stories/Avatar.stories.tsx new file mode 100644 index 00000000..3819a7bd --- /dev/null +++ b/packages/ui-toolkit/stories/Avatar.stories.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Story } from '@storybook/react'; + +import { Avatar } from '../src/components/atoms'; +import { Props as AvatarProps } from '../src/components/atoms/Avatar/Avatar'; + +export default { + title: 'Avatar', + component: Avatar, + argTypes: { + size: { + control: { + type: 'select', + options: [ 'XSmall', 'Small', 'Base', 'Large', 'XLarge' ] + } + }, + shape: { + control: { + type: 'select', + options: [ 'Circular', 'Rectangular' ] + } + } + } +}; + + +const Template: Story = (args) => ; + +export const Circular = Template.bind({}); +Circular.args = { + name: 'Dan Abramov', + src: 'https://bit.ly/dan-abramov', + size: 'Base', + isDisabled: false, + shape: 'Circular' +}; + +export const CircularPlaceholder = Template.bind({}); +CircularPlaceholder.args = { + name: 'Dan Abramov', + src: 'https://bit.ly/broken-image', + size: 'Base', + isDisabled: false, + shape: 'Circular' +}; + +export const Rectangular = Template.bind({}); +Rectangular.args = { + name: 'Navi Nifty 50 Index Fund Direct Growth', + src: 'https://assets-netstorage.groww.in/mf-assets/logos/peerless_groww.png', + size: 'Base', + isDisabled: false, + shape: 'Rectangular' +}; + +export const RectangularPlaceholder = Template.bind({}); +RectangularPlaceholder.args = { + name: 'Navi Nifty 50 Index Fund Direct Growth', + src: 'https://bit.ly/broken-image', + size: 'Base', + isDisabled: false, + shape: 'Rectangular' +}; diff --git a/packages/ui-toolkit/stories/Divider.stories.tsx b/packages/ui-toolkit/stories/Divider.stories.tsx new file mode 100644 index 00000000..de9c994f --- /dev/null +++ b/packages/ui-toolkit/stories/Divider.stories.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { Story } from '@storybook/react'; + +import { Divider } from '../src/components/atoms'; + +export default { + title: 'Divider', + component: Divider +}; + + +const Template: Story = () => ; + +export const Default = Template.bind({}); diff --git a/packages/ui-toolkit/stories/IconView.stories.tsx b/packages/ui-toolkit/stories/IconView.stories.tsx new file mode 100644 index 00000000..5ec89797 --- /dev/null +++ b/packages/ui-toolkit/stories/IconView.stories.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import { Story } from '@storybook/react'; + +import { IconView } from '../src/components/atoms/IconView'; +import { Props as IconViewProps } from '../src/components/atoms/IconView/IconView'; +import { Edit } from '@groww-tech/icon-store/mi'; + +export default { + title: 'IconView', + component: IconView, + argTypes: { + size: { + control: { + type: 'select', + options: [ 'Small', 'Base', 'Large', 'XLarge' ] + } + } + } +}; + + +const Template: Story = (args) => ( +
+ +
+); + +export const Default = Template.bind({}); +Default.args = { + iconComponent: (iconProps: any) => , + size: 'Base' +}; + +export const Contained = Template.bind({}); +Contained.args = { + iconComponent: (iconProps: any) => , + size: 'Base', + isContained: true +}; diff --git a/packages/ui-toolkit/stories/Tabs.stories.tsx b/packages/ui-toolkit/stories/Tabs.stories.tsx index f6475648..8e23eaa0 100644 --- a/packages/ui-toolkit/stories/Tabs.stories.tsx +++ b/packages/ui-toolkit/stories/Tabs.stories.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { Story } from "@storybook/react"; +import React from 'react'; +import { Story } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { Props as TabsProps } from '../src/components/atoms/Tabs/Tabs'; @@ -8,44 +8,38 @@ import { Tabs } from '../src/components/atoms'; export default { title: 'Tabs', component: Tabs, - argTypes: { - } + argTypes: {} }; -const Template: Story = (args) => + +const Template: Story = (args) => ; export const Default = Template.bind({}); -const WALLETS_TABS = [ +const data = [ + { + name: 'Option 1' + }, { - width: 168, - left: 10, - name: ( -
- DEPOSIT -
- ) + name: 'Option 2' }, { - width: 208, - left: 180, - name: ( -
- WITHDRAW -
- ) + name: 'Option 3' + }, + { + name: 'Option 4' } -] +]; Default.args = { - data: WALLETS_TABS, + data, showBottomBorder: true, - customStyleTab: "", - onTabSelect: action('onSelect') -} + onTabSelect: (index) => console.log(data.at(index)), + isFitted: false +}; +export const Fitted = Template.bind({}); +Fitted.args = { + ...Default.args, + isFitted: true +};