diff --git a/src/design-system/components/icon-button/IconButton.constants.ts b/src/design-system/components/icon-button/IconButton.constants.ts new file mode 100644 index 000000000..b49074e22 --- /dev/null +++ b/src/design-system/components/icon-button/IconButton.constants.ts @@ -0,0 +1,6 @@ +export enum IconButtonVariant { + Primary = 'primary', + Secondary = 'secondary', + Success = 'success', + Error = 'error' +} diff --git a/src/design-system/components/icon-button/IconButton.scss b/src/design-system/components/icon-button/IconButton.scss new file mode 100644 index 000000000..7b706fe27 --- /dev/null +++ b/src/design-system/components/icon-button/IconButton.scss @@ -0,0 +1,157 @@ +@use '~scss/utilities' as *; +@use 'sass:map'; +$icon-sizes-bg: ( + xs: 20, + sm: 24, + md: 32, + lg: 40 +); +$icon-sizes: ( + xs: 12, + sm: 16, + md: 20, + lg: 24 +); +$icon-variants: ( + primary: ( + bg-toggle: $blue-gray-100, + icon: $blue-gray-800, + active-icon: $blue-gray-500, + active-icon-toggle: $blue-gray-600, + hover: rgba($blue-gray-400, 0.2), + active: rgba($blue-gray-400, 0.2), + focus: rgba($blue-gray-400, 0.4), + disabled: $blue-gray-300, + bg-hover-toggle: rgba($blue-gray-400, 0.6) + ), + secondary: ( + bg-toggle: $blue-gray-100, + icon: $blue-gray-600, + active-icon: $blue-gray-500, + active-icon-toggle: $blue-gray-600, + hover: rgba($blue-gray-400, 0.2), + active: rgba($blue-gray-400, 0.2), + focus: rgba($blue-gray-400, 0.4), + disabled: $blue-gray-300, + bg-hover-toggle: rgba($blue-gray-400, 0.6) + ), + success: ( + bg-toggle: $green-100, + icon: $green-700, + active-icon: $green-600, + active-icon-toggle: $green-600, + hover: $green-100, + active: $green-100, + focus: $green-200, + disabled: $green-300, + bg-hover-toggle: $green-200 + ), + error: ( + bg-toggle: $red-100, + icon: $red-600, + active-icon: $red-500, + active-icon-toggle: $red-500, + hover: $red-100, + active: $red-100, + focus: $red-200, + disabled: $red-300, + bg-hover-toggle: $red-200 + ) +); + +@mixin icon-bg-mixin($size) { + width: $size; + height: $size; + box-shadow: none; + background-color: transparent; + transition: + transform 0.3s ease, + background-color 0.3s ease; + padding: 0; + + &:disabled { + background-color: none; + } +} +@mixin icon-mixin($size) { + width: $size; + height: $size; + box-shadow: none; +} +@mixin icon-bg-mixin-variant($variant, $toggle-able: false) { + $variant-data: map.get($icon-variants, $variant); + + @if $toggle-able == true { + background-color: map.get($variant-data, bg-toggle); + + .#{$prefix}icon { + color: map.get($variant-data, icon); + } + + &:hover { + background-color: map.get($variant-data, bg-hover-toggle); + } + + &:active { + background-color: map.get($variant-data, bg-hover-toggle); + .#{$prefix}icon.MuiSvgIcon-root { + color: map.get($variant-data, active-icon-toggle); + } + } + + &:focus { + background-color: map.get($variant-data, bg-hover-toggle); + } + + &:disabled { + background-color: map.get($variant-data, bgToggle); + .#{$prefix}icon { + color: map.get($variant-data, disabled); + } + } + } @else { + .#{$prefix}icon { + color: map.get($variant-data, icon); + } + + &:hover { + background-color: map.get($variant-data, hover); + } + + &:active { + background-color: map.get($variant-data, active); + .#{$prefix}icon.MuiSvgIcon-root { + color: map.get($variant-data, active-icon); + } + } + + &:focus { + background-color: map.get($variant-data, focus); + } + + &:disabled { + .#{$prefix}icon { + color: map.get($variant-data, disabled); + } + } + } +} + +@each $name, $size in $icon-sizes-bg { + .#{$prefix}icon-button--#{$name} { + @include icon-bg-mixin($size + px); + } +} +@each $name, $size in $icon-sizes { + .#{$prefix}icon--#{$name} { + @include icon-mixin($size + px); + } +} +@each $variant in map.keys($icon-variants) { + .#{$prefix}icon-button--#{$variant} { + @include icon-bg-mixin-variant($variant, false); + } + .#{$prefix}icon-button--#{$variant}-toggle-able { + @include icon-bg-mixin-variant($variant, true); + } +} diff --git a/src/design-system/components/icon-button/IconButton.tsx b/src/design-system/components/icon-button/IconButton.tsx new file mode 100644 index 000000000..c0f4343eb --- /dev/null +++ b/src/design-system/components/icon-button/IconButton.tsx @@ -0,0 +1,66 @@ +import { + CircularProgress, + IconButtonProps, + IconButton as MuiIconButton +} from '@mui/material' +import { FC } from 'react' +import { IconButtonVariant } from './IconButton.constants' +import AddRoundedIcon from '@mui/icons-material/AddRounded' +import { cn } from '~/utils/cn' +import './IconButton.scss' + +interface S2SIconButtonProps extends Omit { + variant?: IconButtonVariant + size?: 'xs' | 'sm' | 'md' | 'lg' + loading?: boolean + disabled?: boolean + toggleAble?: boolean + isToggled?: boolean + onClick?: () => void +} + +export const IconButton: FC = ({ + variant = IconButtonVariant.Primary, + size = 'md', + loading = false, + disabled = false, + toggleAble = false, + isToggled = false, + onClick, + ...props +}) => { + const classNamesContainerIconBG = cn( + 's2s-icon-button', + `s2s-icon-button--${size}`, + `s2s-icon-button--${variant}${toggleAble && isToggled ? '-toggle-able' : ''}` + ) + const classNamesContainerIcon = cn( + 's2s-icon', + `s2s-icon--${size}`, + `s2s-icon--${variant}${toggleAble && isToggled ? '-toggle-able' : ''}` + ) + const loaderSizes = { + xs: 12, + sm: 16, + md: 20, + lg: 24 + } + + const loader = ( + + ) + return ( + + {loading ? ( + loader + ) : ( + + )} + + ) +} diff --git a/src/design-system/stories/IconButton.stories.tsx b/src/design-system/stories/IconButton.stories.tsx new file mode 100644 index 000000000..1ecb555f8 --- /dev/null +++ b/src/design-system/stories/IconButton.stories.tsx @@ -0,0 +1,201 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { IconButton } from '~/design-system/components/icon-button/IconButton' +import { IconButtonVariant } from '~/design-system/components/icon-button/IconButton.constants' +import AddRoundedIcon from '@mui/icons-material/AddRounded' + +const meta: Meta = { + title: 'Components/IconButton', + component: IconButton, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +The \`IconButton\` component provides a compact and customizable icon button that can handle various actions. It supports multiple variants, sizes, loading states, and toggling functionality for enhanced interactivity. + +#### Key Features: +- **Variants:** Control the button's visual style, such as success, primary, or error. +- **Sizes:** Adjust the button size to fit different contexts, from small icons to larger touch targets. +- **Disabled state:** Disables icon and background to indicate that user doesn't have permission to interact with button. +- **Loading Indicator:** Replace the icon with a spinner to indicate ongoing processes. +- **Toggleable State:** Enable a toggleable mode to switch styles on click. + ` + } + } + }, + tags: ['autodocs'], + argTypes: { + variant: { + description: 'The visual style of the button.', + options: [ + IconButtonVariant.Primary, + IconButtonVariant.Secondary, + IconButtonVariant.Success, + IconButtonVariant.Error + ], + control: { type: 'radio' } + }, + size: { + description: 'Size of the button.', + options: ['xs', 'sm', 'md', 'lg'], + control: { type: 'radio' } + }, + loading: { + description: 'Displays a loading indicator when true.', + control: { type: 'boolean' } + }, + disabled: { + description: 'Disables the button, preventing interactions.', + control: { type: 'boolean' } + }, + toggleAble: { + description: 'Enables toggleable functionality for the button.', + control: { type: 'boolean' } + }, + isToggled: { + description: 'Set toggle styles for the button.', + control: { type: 'boolean' } + } + }, + args: { + variant: IconButtonVariant.Primary, + size: 'lg', + loading: false, + disabled: false, + toggleAble: false, + isToggled: false + } +} + +export default meta +type Story = StoryObj + +export const AllVariants: Story = { + render: (args) => ( +
+ + + + + + + + + + + + +
+ ), + parameters: { + docs: { + description: { + story: + 'This story demonstrates all button variants in a row, including Primary, Secondary, Success and Error.' + } + } + } +} + +export const Primary: Story = { + args: { + variant: IconButtonVariant.Primary, + children: + }, + parameters: { + docs: { + description: { + story: + 'The "Primary" variant is a default button, suitable for main actions.' + } + } + } +} +export const Secondary: Story = { + args: { + variant: IconButtonVariant.Secondary, + children: + }, + parameters: { + docs: { + description: { + story: + 'The "Secondary" variant is a secondary button, suitable for main actions.' + } + } + } +} + +export const Success: Story = { + args: { + variant: IconButtonVariant.Success, + children: + }, + parameters: { + docs: { + description: { + story: + 'The "Success" variant indicates a positive action using a green theme.' + } + } + } +} +export const Error: Story = { + args: { + variant: IconButtonVariant.Error, + children: + }, + parameters: { + docs: { + description: { + story: + 'The "Error" variant is red and is often used for destructive actions or alerts.' + } + } + } +} +export const Disabled: Story = { + args: { + disabled: true, + variant: IconButtonVariant.Primary + }, + parameters: { + docs: { + description: { + story: + 'The disabled state disables icon and background to indicate that user doesn`t have permission to interact with button.' + } + } + } +} + +export const Loading: Story = { + args: { + loading: true, + variant: IconButtonVariant.Primary + }, + parameters: { + docs: { + description: { + story: + 'The loading state replaces the icon with a spinner to indicate that a process is in progress.' + } + } + } +} + +export const ToggleAble: Story = { + args: { + toggleAble: true, + isToggled: true, + variant: IconButtonVariant.Primary + }, + parameters: { + docs: { + description: { + story: + 'This toggleable button switches states when clicked, providing visual feedback for toggling actions.' + } + } + } +} diff --git a/tests/unit/design-system/components/IconButton/IconButton.spec.jsx b/tests/unit/design-system/components/IconButton/IconButton.spec.jsx new file mode 100644 index 000000000..671382eee --- /dev/null +++ b/tests/unit/design-system/components/IconButton/IconButton.spec.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { IconButton } from '~/design-system/components/icon-button/IconButton'; +import { IconButtonVariant } from '~/design-system/components/icon-button/IconButton.constants'; +import { vi } from 'vitest'; + +describe('IconButton Component', () => { + test('renders correctly with default props', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveClass('s2s-icon-button--md'); + expect(button).toHaveClass('s2s-icon-button--primary'); + expect(button).not.toBeDisabled(); + expect(screen.getByTestId('AddRoundedIcon')).toBeInTheDocument(); + }); + + test('applies the correct size class', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveClass('s2s-icon-button--lg'); + }); + + test('applies the correct variant class', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveClass('s2s-icon-button--success'); + }); + + test('disables the button when loading is true', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + test('disables the button when disabled is true', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); + + test('applies the toggled class when isToggled is true and toggleAble is true', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveClass('s2s-icon-button--primary-toggle-able'); + }); + + test('does not apply the toggled class when toggleAble is false', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveClass('s2s-icon-button--primary'); + expect(button).not.toHaveClass('s2s-icon-button--primary-toggle-able'); + }); + + test('calls onClick prop when clicked', () => { + const handleClick = vi.fn(); + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + test('does not call onClick when disabled', () => { + const handleClick = vi.fn(); + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); +});