Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions libs/react/ui/src/components/button/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const meta = {
options: sizeOptions,
},
asChild: {control: 'boolean'},
isLoading: {control: 'boolean'},
},
args: {
children: 'Click me',
Expand Down Expand Up @@ -118,3 +119,20 @@ export const Icons: Story = {
</div>
),
};

export const Loading: Story = {
render: (args) => (
<div className="flex flex-col gap-16">
<div>
<Button {...args} isLoading>
Loading...
</Button>
</div>
<div>
<Button {...args} isLoading iconLeft="google">
Loading with left icon
</Button>
</div>
</div>
),
};
29 changes: 27 additions & 2 deletions libs/react/ui/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export const buttonVariants = cva(
},
);

const spinnerSizeMap: Record<NonNullable<VariantProps<typeof buttonVariants>['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,
Expand All @@ -46,18 +55,34 @@ export function Button({
children,
iconLeft,
iconRight,
isLoading = false,
disabled,
...props
}: ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
iconLeft?: IconName;
iconRight?: IconName;
isLoading?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
const spinnerSize =
spinnerSizeMap[(size ?? 'md') as NonNullable<VariantProps<typeof buttonVariants>['size']>];

return (
<Comp data-slot="button" className={cn(buttonVariants({variant, size, className}))} {...props}>
{iconLeft && <Icon name={iconLeft} />}
<Comp
data-slot="button"
className={cn(buttonVariants({variant, size, className}))}
disabled={disabled || isLoading}
aria-busy={isLoading}
aria-live={isLoading ? 'polite' : undefined}
{...props}
>
{isLoading ? (
<Icon name="spinner" className={spinnerSize} />
) : (
iconLeft && <Icon name={iconLeft} />
)}
{children}
{iconRight && <Icon name={iconRight} />}
</Comp>
Expand Down
46 changes: 46 additions & 0 deletions libs/react/ui/src/components/button/icon-button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,49 @@ export const Sizes: Story = {
</div>
),
};

export const Loading: Story = {
render: ({children: _children, ...args}) => (
<div className="flex flex-col gap-32">
<div className="flex flex-col gap-16">
<Code variant="label">Loading by Size:</Code>
<div className="flex gap-16 items-center">
{sizeOptions.map((size) => (
<div key={size} className="flex flex-col gap-8 items-center">
<Code variant="label" className="text-foreground-neutral-subtle text-xs">
{size}
</Code>
<IconButton {...args} icon="addLine" aria-label="Loading" size={size} isLoading />
</div>
))}
</div>
</div>
<div className="flex flex-col gap-16">
<Code variant="label">Loading by Variant:</Code>
<div className="flex gap-16 items-center">
{variantOptions.map((variant) => (
<div key={variant} className="flex flex-col gap-8 items-center">
<Code variant="label" className="text-foreground-neutral-subtle text-xs">
{variant}
</Code>
<IconButton
{...args}
icon="addLine"
aria-label="Loading"
variant={variant}
isLoading
/>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-16">
<Code variant="label">Normal vs Loading:</Code>
<div className="flex gap-16 items-center">
<IconButton {...args} icon="addLine" aria-label="Add" />
<IconButton {...args} icon="addLine" aria-label="Loading" isLoading />
</div>
</div>
</div>
),
};
27 changes: 26 additions & 1 deletion libs/react/ui/src/components/button/icon-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ export const iconButtonVariants = cva(
},
);

const spinnerSizeMap: Record<
NonNullable<VariantProps<typeof iconButtonVariants>['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,
Expand All @@ -49,21 +61,34 @@ export function IconButton({
asChild = false,
children,
icon,
isLoading = false,
disabled,
...props
}: ComponentProps<'button'> &
VariantProps<typeof iconButtonVariants> & {
asChild?: boolean;
icon?: IconName;
isLoading?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
const spinnerSize = spinnerSizeMap[size ?? 'md'];

return (
<Comp
data-slot="icon-button"
className={cn(iconButtonVariants({variant, size, radius, muted}), className)}
disabled={disabled || isLoading}
aria-busy={isLoading}
aria-live={isLoading ? 'polite' : undefined}
{...props}
>
{icon ? <Icon name={icon} /> : children}
{isLoading ? (
<Icon name="spinner" className={spinnerSize} />
) : icon ? (
<Icon name={icon} />
) : (
children
)}
</Comp>
);
}
82 changes: 64 additions & 18 deletions libs/react/ui/src/components/icon/custom/spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,66 @@
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<RemixiconComponentType>) {
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<string, Variants> = {};

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<RemixiconComponentType>) {
const {className, size, ...restProps} = props;

const iconSize = size ?? 24;

const svgProps: SVGMotionProps<SVGSVGElement> = {
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<SVGSVGElement>),
};
return (
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.svg {...svgProps}>
<title>Spinner</title>
<path
opacity="0.55"
<motion.path
d="M10.583 1.91667C10.583 1.41041 10.9934 1 11.4997 1C12.0059 1 12.4163 1.41041 12.4163 1.91667V5.58333C12.4163 6.08959 12.0059 6.5 11.4997 6.5C10.9934 6.5 10.583 6.08959 10.583 5.58333V1.91667Z"
fill="currentColor"
stroke="currentColor"
style={{fill: 'currentColor', fillOpacity: 1, stroke: 'currentColor', strokeOpacity: 1}}
strokeWidth="0.916667"
strokeLinecap="round"
strokeLinejoin="round"
variants={segmentVariants.segment1}
/>
<rect
<motion.rect
x="10.583"
y="17.5"
width="1.83333"
Expand All @@ -27,39 +72,39 @@ export function SpinnerIcon(_props: ComponentProps<RemixiconComponentType>) {
strokeWidth="0.916667"
strokeLinecap="round"
strokeLinejoin="round"
variants={segmentVariants.segment2}
/>
<path
opacity="0.25"
<motion.path
d="M1.41667 12.918C0.910406 12.918 0.5 12.5076 0.5 12.0013C0.5 11.495 0.910405 11.0846 1.41667 11.0846L5.08333 11.0846C5.58959 11.0846 6 11.495 6 12.0013C6 12.5076 5.58959 12.918 5.08333 12.918L1.41667 12.918Z"
fill="currentColor"
stroke="currentColor"
style={{fill: 'currentColor', fillOpacity: 1, stroke: 'currentColor', strokeOpacity: 1}}
strokeWidth="0.916667"
strokeLinecap="round"
strokeLinejoin="round"
variants={segmentVariants.segment3}
/>
<path
opacity="0.75"
<motion.path
d="M17.9167 12.918C17.4104 12.918 17 12.5076 17 12.0013C17 11.495 17.4104 11.0846 17.9167 11.0846L21.5833 11.0846C22.0896 11.0846 22.5 11.495 22.5 12.0013C22.5 12.5076 22.0896 12.918 21.5833 12.918L17.9167 12.918Z"
fill="currentColor"
stroke="currentColor"
style={{fill: 'currentColor', fillOpacity: 1, stroke: 'currentColor', strokeOpacity: 1}}
strokeWidth="0.916667"
strokeLinecap="round"
strokeLinejoin="round"
variants={segmentVariants.segment4}
/>
<path
opacity="0.38"
<motion.path
d="M3.7224 5.52123C3.36442 5.16325 3.36442 4.58285 3.7224 4.22487C4.08038 3.86689 4.66078 3.86688 5.01876 4.22487L7.61149 6.81759C7.96947 7.17557 7.96947 7.75597 7.61149 8.11395C7.25351 8.47193 6.67311 8.47193 6.31512 8.11395L3.7224 5.52123Z"
fill="currentColor"
stroke="currentColor"
style={{fill: 'currentColor', fillOpacity: 1, stroke: 'currentColor', strokeOpacity: 1}}
strokeWidth="0.916667"
strokeLinecap="round"
strokeLinejoin="round"
variants={segmentVariants.segment5}
/>
<rect
opacity="0.88"
<motion.rect
x="14.7412"
y="16.5391"
width="1.83333"
Expand All @@ -72,27 +117,28 @@ export function SpinnerIcon(_props: ComponentProps<RemixiconComponentType>) {
strokeWidth="0.916667"
strokeLinecap="round"
strokeLinejoin="round"
variants={segmentVariants.segment6}
/>
<path
opacity="0.13"
<motion.path
d="M5.0183 19.7796C4.66032 20.1375 4.07992 20.1375 3.72194 19.7796C3.36396 19.4216 3.36396 18.8412 3.72194 18.4832L6.31466 15.8905C6.67264 15.5325 7.25304 15.5325 7.61102 15.8905C7.969 16.2484 7.969 16.8288 7.61102 17.1868L5.0183 19.7796Z"
fill="currentColor"
stroke="currentColor"
style={{fill: 'currentColor', fillOpacity: 1, stroke: 'currentColor', strokeOpacity: 1}}
strokeWidth="0.916667"
strokeLinecap="round"
strokeLinejoin="round"
variants={segmentVariants.segment7}
/>
<path
opacity="0.63"
<motion.path
d="M16.6853 8.11354C16.3273 8.47152 15.7469 8.47152 15.3889 8.11354C15.0309 7.75556 15.0309 7.17516 15.3889 6.81718L17.9817 4.22445C18.3396 3.86647 18.92 3.86647 19.278 4.22445C19.636 4.58243 19.636 5.16283 19.278 5.52081L16.6853 8.11354Z"
fill="currentColor"
stroke="currentColor"
style={{fill: 'currentColor', fillOpacity: 1, stroke: 'currentColor', strokeOpacity: 1}}
strokeWidth="0.916667"
strokeLinecap="round"
strokeLinejoin="round"
variants={segmentVariants.segment8}
/>
</svg>
</motion.svg>
);
}
36 changes: 18 additions & 18 deletions libs/react/ui/src/components/icon/icon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {RemixiconComponentType} from '@remixicon/react';
import {
type RemixiconComponentType,
RiAddLine,
RiArrowRightSLine,
RiBookOpenFill,
Expand Down Expand Up @@ -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<string, RemixiconComponentType>;

export type IconName = keyof typeof iconsMap;
Expand Down