Skip to content

Commit

Permalink
feat: Update Spinner and Button components to handle responsive sizes (
Browse files Browse the repository at this point in the history
  • Loading branch information
cgero-eth authored Dec 19, 2023
1 parent 773e659 commit a8ca96a
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 98 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Changed

- Update `Spinner` and `Button` components to handle responsive sizes
- Update `Icon` and `AvatarIcon` components to handle xl and 2xl responsive sizes

## [1.0.6] - 2023-12-13

### Added
Expand Down
6 changes: 6 additions & 0 deletions src/components/avatars/avatarIcon/avatarIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,22 @@ const responsiveSizeClasses: ResponsiveAttributeClassMap<AvatarIconSize> = {
sm: 'w-6 h-6',
md: 'md:w-6 md:h-6',
lg: 'lg:w-6 lg:h-6',
xl: 'xl:w-6 xl:h-6',
'2xl': '2xl:w-6 2xl:h-6',
},
md: {
sm: 'w-8 h-8',
md: 'md:w-8 md:h-8',
lg: 'lg:w-8 lg:h-8',
xl: 'xl:w-8 xl:h-8',
'2xl': '2xl:w-8 2xl:h-8',
},
lg: {
sm: 'w-10 h-10',
md: 'md:w-10 md:h-10',
lg: 'lg:w-10 lg:h-10',
xl: 'xl:w-10 xl:h-10',
'2xl': '2xl:w-10 2xl:h-10',
},
};

Expand Down
6 changes: 6 additions & 0 deletions src/components/button/button.api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import type { ResponsiveAttribute } from '../../types';
import type { IconType } from '../icon';

export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'success' | 'warning' | 'critical';
export type ButtonContext = 'default' | 'onlyIcon';
export type ButtonSize = 'lg' | 'md' | 'sm';
export type ButtonState = 'disabled' | 'loading';

Expand All @@ -14,6 +16,10 @@ export interface IButtonBaseProps {
* Size of the button.
*/
size: ButtonSize;
/**
* Applies responsiveness to the size of the button.
*/
responsiveSize?: ResponsiveAttribute<ButtonSize>;
/**
* State of the button.
*/
Expand Down
14 changes: 13 additions & 1 deletion src/components/button/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const Default: Story = {
};

/**
* Usage example of the Button component as Link.
* The Button component renders a <a /> tag when the href property is set.
*/
export const Link: Story = {
args: {
Expand All @@ -44,4 +44,16 @@ export const Link: Story = {
},
};

/**
* Button component with a size that changes depending on the current breakpoint.
*/
export const ResponsiveButton: Story = {
args: {
variant: 'primary',
size: 'md',
responsiveSize: { xl: 'lg' },
children: 'Responsive button',
},
};

export default meta;
139 changes: 113 additions & 26 deletions src/components/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import classNames from 'classnames';
import type { ButtonHTMLAttributes, MouseEvent } from 'react';
import type { Breakpoint, ResponsiveAttribute, ResponsiveAttributeClassMap } from '../../types';
import { responsiveUtils } from '../../utils';
import { Icon, type IconSize } from '../icon';
import { Spinner } from '../spinner';
import type { SpinnerSize, SpinnerVariant } from '../spinner/spinner';
import type { ButtonSize, ButtonVariant, IButtonProps } from './button.api';
import type { ButtonContext, ButtonSize, ButtonVariant, IButtonProps } from './button.api';

// Using aria-disabled: instead of disabled: modifier in order to make the modifier work for buttons and links
const variantToClassNames: Record<ButtonVariant, string[]> = {
Expand Down Expand Up @@ -60,25 +62,79 @@ const variantToSpinnerVariant: Record<ButtonVariant, SpinnerVariant> = {
critical: 'critical',
};

const sizeToClassNames: Record<ButtonSize, Record<'onlyIcon' | 'default' | 'common', string>> = {
const responsiveSizeClassNames: ResponsiveAttributeClassMap<ButtonSize> = {
sm: {
sm: 'h-[32px] text-sm rounded-lg gap-0.5',
md: 'md:h-[32px] md:text-sm md:rounded-lg md:gap-0.5',
lg: 'lg:h-[32px] lg:text-sm lg:rounded-lg lg:gap-0.5',
xl: 'xl:h-[32px] xl:text-sm xl:rounded-lg xl:gap-0.5',
'2xl': '2xl:h-[32px] 2xl:text-sm 2xl:rounded-lg 2xl:gap-0.5',
},
md: {
sm: 'h-[40px] text-base rounded-xl gap-1',
md: 'md:h-[40px] md:text-base md:rounded-xl md:gap-1',
lg: 'lg:h-[40px] lg:text-base lg:rounded-xl lg:gap-1',
xl: 'xl:h-[40px] xl:text-base xl:rounded-xl xl:gap-1',
'2xl': '2xl:h-[40px] 2xl:text-base 2xl:rounded-xl 2xl:gap-1',
},
lg: {
common: 'h-[48px] text-base rounded-xl gap-1',
default: 'min-w-[112px] px-4',
onlyIcon: 'w-[48px]',
sm: 'h-[48px] text-base rounded-xl gap-1',
md: 'md:h-[48px] md:text-base md:rounded-xl md:gap-1',
lg: 'lg:h-[48px] lg:text-base lg:rounded-xl lg:gap-1',
xl: 'xl:h-[48px] xl:text-base xl:rounded-xl xl:gap-1',
'2xl': '2xl:h-[48px] 2xl:text-base 2xl:rounded-xl 2xl:gap-1',
},
};

const responsiveDefaultContextClassNames: ResponsiveAttributeClassMap<ButtonSize> = {
sm: {
sm: 'min-w-[80px] px-2',
md: 'md:min-w-[80px] md:px-2',
lg: 'lg:min-w-[80px] lg:px-2',
xl: 'xl:min-w-[80px] xl:px-2',
'2xl': '2xl:min-w-[80px] 2xl:px-2',
},
md: {
common: 'h-[40px] text-base rounded-xl gap-1',
default: 'min-w-[96px] px-3',
onlyIcon: 'w-[40px]',
sm: 'min-w-[96px] px-3',
md: 'md:min-w-[96px] md:px-3',
lg: 'lg:min-w-[96px] lg:px-3',
xl: 'xl:min-w-[96px] xl:px-3',
'2xl': '2xl:min-w-[96px] 2xl:px-3',
},
lg: {
sm: 'min-w-[112px] px-4',
md: 'md:min-w-[112px] md:px-4',
lg: 'lg:min-w-[112px] lg:px-4',
xl: 'xl:min-w-[112px] xl:px-4',
'2xl': '2xl:min-w-[112px] 2xl:px-4',
},
};

const responsiveOnlyIconContextClassNames: ResponsiveAttributeClassMap<ButtonSize> = {
sm: {
common: 'h-[32px] text-sm rounded-lg gap-0.5',
default: 'min-w-[80px] px-2',
onlyIcon: 'w-[32px]',
sm: 'w-[32px]',
md: 'md:w-[32px]',
lg: 'lg:w-[32px]',
xl: 'xl:w-[32px]',
'2xl': '2xl:w-[32px]',
},
md: {
sm: 'w-[40px]',
md: 'md:w-[40px]',
lg: 'lg:w-[40px]',
xl: 'xl:w-[40px]',
'2xl': '2xl:w-[40px]',
},
lg: {
sm: 'w-[48px]',
md: 'md:w-[48px]',
lg: 'lg:w-[48px]',
xl: 'xl:w-[48px]',
'2xl': '2xl:w-[48px]',
},
};

const sizeToIconSize: Record<ButtonSize, Record<'onlyIcon' | 'default', IconSize>> = {
const sizeToIconSize: Record<ButtonSize, Record<ButtonContext, IconSize>> = {
lg: {
default: 'md',
onlyIcon: 'lg',
Expand All @@ -100,10 +156,21 @@ const sizeToSpinnerSize: Record<ButtonSize, SpinnerSize> = {
};

export const Button: React.FC<IButtonProps> = (props) => {
const { variant, size, iconRight, iconLeft, className, children, state, ...otherProps } = props;
const {
variant,
size,
responsiveSize = {},
iconRight,
iconLeft,
className,
children,
state,
...otherProps
} = props;

const isOnlyIcon = children == null || children === '';
const isDisabled = state === 'disabled' || state === 'loading';
const buttonContext = isOnlyIcon ? 'onlyIcon' : 'default';

const commonClasses = [
'flex flex-row items-center justify-center', // Layout
Expand All @@ -126,32 +193,52 @@ export const Button: React.FC<IButtonProps> = (props) => {
})
.join(' ');

const sizeClasses = sizeToClassNames[size];
const sizeClassNames = responsiveUtils.generateClassNames(size, responsiveSize, responsiveSizeClassNames);
const contextClassNames = responsiveUtils.generateClassNames(
size,
responsiveSize,
isOnlyIcon ? responsiveOnlyIconContextClassNames : responsiveDefaultContextClassNames,
);

const classes = classNames(
commonClasses,
variantClasses,
sizeClasses.common,
className,
{ [sizeClasses.default]: !isOnlyIcon },
{ [sizeClasses.onlyIcon]: isOnlyIcon },
{ 'cursor-progress': state === 'loading' },
const classes = classNames(commonClasses, variantClasses, sizeClassNames, contextClassNames, className, {
'cursor-progress': state === 'loading',
});

const iconSize = sizeToIconSize[size][buttonContext];
const iconResponsiveSize = Object.keys(responsiveSize ?? {}).reduce<ResponsiveAttribute<IconSize>>(
(current, breakpoint) => ({
...current,
[breakpoint]: sizeToIconSize[responsiveSize![breakpoint as Breakpoint]!][buttonContext],
}),
{},
);

const spinnerSize = sizeToSpinnerSize[size];
const spinnerResponsiveSize = Object.keys(responsiveSize ?? {}).reduce<ResponsiveAttribute<SpinnerSize>>(
(current, breakpoint) => ({
...current,
[breakpoint]: sizeToSpinnerSize[responsiveSize![breakpoint as Breakpoint]!],
}),
{},
);

const iconSize = sizeToIconSize[size][isOnlyIcon ? 'onlyIcon' : 'default'];
const displayIconLeft = state !== 'loading' && iconLeft != null;
const displayIconRight = state !== 'loading' && iconRight != null && !isOnlyIcon;

const commonProps = { className: classes, 'aria-disabled': isDisabled };

const buttonContent = (
<>
{displayIconLeft && <Icon icon={iconLeft} size={iconSize} />}
{displayIconLeft && <Icon icon={iconLeft} size={iconSize} responsiveSize={iconResponsiveSize} />}
{state === 'loading' && (
<Spinner size={sizeToSpinnerSize[size]} variant={variantToSpinnerVariant[variant]} />
<Spinner
size={spinnerSize}
responsiveSize={spinnerResponsiveSize}
variant={variantToSpinnerVariant[variant]}
/>
)}
{!isOnlyIcon && <div className="px-1">{children}</div>}
{displayIconRight && <Icon icon={iconRight} size={iconSize} />}
{displayIconRight && <Icon icon={iconRight} size={iconSize} responsiveSize={iconResponsiveSize} />}
</>
);

Expand Down
22 changes: 19 additions & 3 deletions src/components/icon/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,42 @@ import type { IconType } from './iconType';
export type IconSize = 'sm' | 'md' | 'lg';

export interface IIconProps extends SVGProps<SVGSVGElement> {
/**
* Icon to be displayed.
*/
icon: IconType;
/**
* Size of the icon.
* @default md
*/
size?: IconSize;
/**
* Size of the icon depending on the current breakpoint.
*/
responsiveSize?: ResponsiveAttribute<IconSize>;
}

const iconClasses: ResponsiveAttributeClassMap<IconSize> = {
const responsiveSizeClassNames: ResponsiveAttributeClassMap<IconSize> = {
sm: {
sm: 'w-3 h-3',
md: 'md:w-3 md:h-3',
lg: 'lg:w-3 lg:h-3',
xl: 'xl:w-3 xl:h-3',
'2xl': '2xl:w-3 2xl:h-3',
},
md: {
sm: 'w-4 h-4',
md: 'md:w-4 md:h-4',
lg: 'lg:w-4 lg:h-4',
xl: 'xl:w-4 xl:h-4',
'2xl': '2xl:w-4 2xl:h-4',
},
lg: {
sm: 'w-5 h-5',
md: 'md:w-5 md:h-5',
lg: 'lg:w-5 lg:h-5',
xl: 'xl:w-5 xl:h-5',
'2xl': '2xl:w-5 2xl:h-5',
},
};

Expand All @@ -37,7 +53,7 @@ export const Icon: React.FC<IIconProps> = (props) => {

const IconComponent = iconList[icon];

const classes = responsiveUtils.generateClassNames(size, responsiveSize, iconClasses);
const sizeClassNames = responsiveUtils.generateClassNames(size, responsiveSize, responsiveSizeClassNames);

return <IconComponent className={classNames(classes, className)} {...otherProps} />;
return <IconComponent className={classNames(sizeClassNames, className)} {...otherProps} />;
};
11 changes: 11 additions & 0 deletions src/components/spinner/spinner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,15 @@ export const Default: Story = {
},
};

/**
* Usage example of the Spinner component with responsive size.
*/
export const ResponsiveSize: Story = {
args: {
variant: 'neutral',
size: 'sm',
responsiveSize: { lg: 'xl' },
},
};

export default meta;
Loading

0 comments on commit a8ca96a

Please sign in to comment.