diff --git a/libs/react/ui/src/components/button/button.stories.tsx b/libs/react/ui/src/components/button/button.stories.tsx index 7404a927..a3739723 100644 --- a/libs/react/ui/src/components/button/button.stories.tsx +++ b/libs/react/ui/src/components/button/button.stories.tsx @@ -26,6 +26,7 @@ const meta = { options: sizeOptions, }, asChild: {control: 'boolean'}, + isLoading: {control: 'boolean'}, }, args: { children: 'Click me', @@ -118,3 +119,20 @@ export const Icons: Story = { ), }; + +export const Loading: Story = { + render: (args) => ( +
+
+ +
+
+ +
+
+ ), +}; diff --git a/libs/react/ui/src/components/button/button.tsx b/libs/react/ui/src/components/button/button.tsx index ee411912..7c661988 100644 --- a/libs/react/ui/src/components/button/button.tsx +++ b/libs/react/ui/src/components/button/button.tsx @@ -38,6 +38,15 @@ export const buttonVariants = cva( }, ); +const spinnerSizeMap: Record['size']>, string> = { + '2xs': 'size-10', + xs: 'size-10', + sm: 'size-12', + md: 'size-14', + lg: 'size-16', + xl: 'size-18', +}; + export function Button({ className, variant, @@ -46,18 +55,34 @@ export function Button({ children, iconLeft, iconRight, + isLoading = false, + disabled, ...props }: ComponentProps<'button'> & VariantProps & { asChild?: boolean; iconLeft?: IconName; iconRight?: IconName; + isLoading?: boolean; }) { const Comp = asChild ? Slot : 'button'; + const spinnerSize = + spinnerSizeMap[(size ?? 'md') as NonNullable['size']>]; return ( - - {iconLeft && } + + {isLoading ? ( + + ) : ( + iconLeft && + )} {children} {iconRight && } diff --git a/libs/react/ui/src/components/button/icon-button.stories.tsx b/libs/react/ui/src/components/button/icon-button.stories.tsx index 7cf12613..8f7ee495 100644 --- a/libs/react/ui/src/components/button/icon-button.stories.tsx +++ b/libs/react/ui/src/components/button/icon-button.stories.tsx @@ -180,3 +180,49 @@ export const Sizes: Story = { ), }; + +export const Loading: Story = { + render: ({children: _children, ...args}) => ( +
+
+ Loading by Size: +
+ {sizeOptions.map((size) => ( +
+ + {size} + + +
+ ))} +
+
+
+ Loading by Variant: +
+ {variantOptions.map((variant) => ( +
+ + {variant} + + +
+ ))} +
+
+
+ Normal vs Loading: +
+ + +
+
+
+ ), +}; diff --git a/libs/react/ui/src/components/button/icon-button.tsx b/libs/react/ui/src/components/button/icon-button.tsx index 3e2f00cb..deb75eb7 100644 --- a/libs/react/ui/src/components/button/icon-button.tsx +++ b/libs/react/ui/src/components/button/icon-button.tsx @@ -40,6 +40,18 @@ export const iconButtonVariants = cva( }, ); +const spinnerSizeMap: Record< + NonNullable['size']>, + string +> = { + '2xs': 'size-8', + xs: 'size-10', + sm: 'size-12', + md: 'size-14', + lg: 'size-16', + xl: 'size-18', +}; + export function IconButton({ className, variant, @@ -49,21 +61,34 @@ export function IconButton({ asChild = false, children, icon, + isLoading = false, + disabled, ...props }: ComponentProps<'button'> & VariantProps & { asChild?: boolean; icon?: IconName; + isLoading?: boolean; }) { const Comp = asChild ? Slot : 'button'; + const spinnerSize = spinnerSizeMap[size ?? 'md']; return ( - {icon ? : children} + {isLoading ? ( + + ) : icon ? ( + + ) : ( + children + )} ); } diff --git a/libs/react/ui/src/components/icon/custom/spinner.tsx b/libs/react/ui/src/components/icon/custom/spinner.tsx index b9c9f870..e500602e 100644 --- a/libs/react/ui/src/components/icon/custom/spinner.tsx +++ b/libs/react/ui/src/components/icon/custom/spinner.tsx @@ -1,12 +1,56 @@ import type {RemixiconComponentType} from '@remixicon/react'; +import {motion, type SVGMotionProps, type Variants} from 'framer-motion'; import type {ComponentProps} from 'react'; +import {cn} from 'utils/cn'; -export function SpinnerIcon(_props: ComponentProps) { +const SEGMENT_COUNT = 8; +const DURATION = 1.2; +const BASE_OPACITY = 0; + +const CLOCKWISE_ORDER = [1, 8, 4, 6, 2, 7, 3, 5]; + +const segmentVariants: Record = {}; + +for (let i = 0; i < SEGMENT_COUNT; i++) { + const segmentIndex = CLOCKWISE_ORDER[i]; + const delay = (i * DURATION) / SEGMENT_COUNT; + + segmentVariants[`segment${segmentIndex}`] = { + initial: {opacity: BASE_OPACITY}, + animate: { + opacity: [BASE_OPACITY, 1, BASE_OPACITY], + transition: { + duration: DURATION, + ease: 'easeInOut', + repeat: Infinity, + repeatType: 'loop', + delay, + times: [0, 0.5, 1], + }, + }, + }; +} + +export function SpinnerIcon(props: ComponentProps) { + const {className, size, ...restProps} = props; + + const iconSize = size ?? 24; + + const svgProps: SVGMotionProps = { + width: typeof iconSize === 'number' ? String(iconSize) : iconSize, + height: typeof iconSize === 'number' ? String(iconSize) : iconSize, + viewBox: '0 0 24 24', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + className: cn(className), + initial: 'initial', + animate: 'animate', + ...(restProps as SVGMotionProps), + }; return ( - + Spinner - ) { strokeWidth="0.916667" strokeLinecap="round" strokeLinejoin="round" + variants={segmentVariants.segment1} /> - ) { strokeWidth="0.916667" strokeLinecap="round" strokeLinejoin="round" + variants={segmentVariants.segment2} /> - ) { strokeWidth="0.916667" strokeLinecap="round" strokeLinejoin="round" + variants={segmentVariants.segment3} /> - ) { strokeWidth="0.916667" strokeLinecap="round" strokeLinejoin="round" + variants={segmentVariants.segment4} /> - ) { strokeWidth="0.916667" strokeLinecap="round" strokeLinejoin="round" + variants={segmentVariants.segment5} /> - ) { strokeWidth="0.916667" strokeLinecap="round" strokeLinejoin="round" + variants={segmentVariants.segment6} /> - ) { strokeWidth="0.916667" strokeLinecap="round" strokeLinejoin="round" + variants={segmentVariants.segment7} /> - ) { strokeWidth="0.916667" strokeLinecap="round" strokeLinejoin="round" + variants={segmentVariants.segment8} /> - + ); } diff --git a/libs/react/ui/src/components/icon/icon.tsx b/libs/react/ui/src/components/icon/icon.tsx index ee66db60..9fe15a2a 100644 --- a/libs/react/ui/src/components/icon/icon.tsx +++ b/libs/react/ui/src/components/icon/icon.tsx @@ -1,5 +1,5 @@ +import type {RemixiconComponentType} from '@remixicon/react'; import { - type RemixiconComponentType, RiAddLine, RiArrowRightSLine, RiBookOpenFill, @@ -36,32 +36,32 @@ import { const iconsMap = { google: RiGoogleFill, microsoft: RiMicrosoftFill, + github: RiGithubFill, + shipfox: ShipfoxLogo, + slack: SlackLogo, + stripe: StripeLogo, badge: BadgeIcon, checkCircleSolid: CheckCircleSolidIcon, circleDottedLine: CircleDottedLineIcon, componentFill: ComponentFillIcon, - xCircleSolid: XCircleSolidIcon, - thunder: ThunderIcon, - resize: ResizeIcon, + componentLine: ComponentLineIcon, + ellipseMiniSolid: EllipseMiniSolidIcon, infoTooltipFill: InfoTooltipFillIcon, + resize: ResizeIcon, spinner: SpinnerIcon, - ellipseMiniSolid: EllipseMiniSolidIcon, - componentLine: ComponentLineIcon, - imageAdd: RiImageAddFill, - close: RiCloseLine, - shipfox: ShipfoxLogo, - slack: SlackLogo, - stripe: StripeLogo, - github: RiGithubFill, + thunder: ThunderIcon, + xCircleSolid: XCircleSolidIcon, + addLine: RiAddLine, + bookOpen: RiBookOpenFill, check: RiCheckLine, - subtractLine: RiSubtractLine, + chevronRight: RiArrowRightSLine, + close: RiCloseLine, + copy: RiFileCopyLine, + homeSmile: RiHomeSmileFill, + imageAdd: RiImageAddFill, info: RiInformationFill, money: RiMoneyDollarCircleLine, - homeSmile: RiHomeSmileFill, - copy: RiFileCopyLine, - addLine: RiAddLine, - chevronRight: RiArrowRightSLine, - bookOpen: RiBookOpenFill, + subtractLine: RiSubtractLine, } as const satisfies Record; export type IconName = keyof typeof iconsMap;