diff --git a/src/components/button/Button.tsx b/src/components/button/Button.tsx index 66b1c2d..be302e8 100644 --- a/src/components/button/Button.tsx +++ b/src/components/button/Button.tsx @@ -1,5 +1,5 @@ import { ReactNode } from 'react'; -import { Spinner } from '../spinner/Spinner'; +import Loader from '../loader/Loader'; interface ButtonProps { id?: string; @@ -73,7 +73,7 @@ export const Button = ({ ring-offset-2 ring-offset-transparent transition-all duration-100 ease-in-out focus-visible:ring-primary/50 ${styles} ${className}`} > - {loading && } + {loading && }
{children}
diff --git a/src/components/button/__test__/__snapshots__/Button.test.tsx.snap b/src/components/button/__test__/__snapshots__/Button.test.tsx.snap index 1407d77..71438bb 100644 --- a/src/components/button/__test__/__snapshots__/Button.test.tsx.snap +++ b/src/components/button/__test__/__snapshots__/Button.test.tsx.snap @@ -541,10 +541,53 @@ exports[`Button component > Primary loading button should render correctly 1`] = disabled="" type="button" > +
+ + + + +
+
+ +
+ , + "container":
+ -
- , - "container":
-
diff --git a/src/components/contextMenu/__test__/ContextMenu.test.tsx b/src/components/contextMenu/__test__/ContextMenu.test.tsx index 460e747..7357ea4 100644 --- a/src/components/contextMenu/__test__/ContextMenu.test.tsx +++ b/src/components/contextMenu/__test__/ContextMenu.test.tsx @@ -1,7 +1,8 @@ -import { render, fireEvent, screen } from '@testing-library/react'; -import ContextMenu, { ContextMenuProps, MenuItemType } from '../ContextMenu'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import ContextMenu, { ContextMenuProps } from '../ContextMenu'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MenuItemType } from '../../menu/Menu'; interface TestItem { name: string; @@ -29,7 +30,9 @@ describe('ContextMenu Component', () => { return render(); }; - beforeEach(() => {}); + afterEach(() => { + vi.clearAllMocks(); + }); it('should match snapshot', () => { const contextMenu = renderContextMenu(); diff --git a/src/components/index.ts b/src/components/index.ts index 34e9654..328a539 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,10 +2,10 @@ export * from './checkbox/Checkbox'; export * from './input/Input'; export * from './textArea/TextArea'; export * from './button/Button'; +export * from './loader/Loader'; export * from './switch/Switch'; export * from './radio-button/RadioButton'; export * from './avatar/Avatar'; -export * from './spinner/Spinner'; export * from './slider/RangeSlider'; export * from './dialog/Dialog'; export * from './modal/Modal'; diff --git a/src/components/loader/Loader.tsx b/src/components/loader/Loader.tsx new file mode 100644 index 0000000..90e4a70 --- /dev/null +++ b/src/components/loader/Loader.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import '../../styles/Loader.css'; + +interface LoaderProps { + classNameContainer?: string; + classNameLoader?: string; + classNameText?: string; + type?: 'spinner' | 'pulse'; + text?: string; + size?: number; +} + +/** + * Loader component. + * + * @property {string} [classNameContainer] + * - Optional class name for the container wrapping the loader. + * Useful for applying custom styles to the outermost container. + * + * @property {string} [classNameLoader] + * - Optional class name for the loader element itself (spinner or pulse). + * Allows custom styling of the loading animation. + * + * @property {string} [classNameText] + * - Optional class name for the text displayed below the loader. + * Allows style or adjust the appearance of the text. + * + * @property {'spinner' | 'pulse'} [type='spinner'] + * - Determines the type of loader to render. + * Can be `'spinner'` for a rotating animation or `'pulse'` for a pulsing effect. + * Defaults to `'spinner'`. + * + * @property {string} [text] + * - Optional text to display below the loader. + * + * @property {number} [size=32] + * - Size of the spinner loader in pixels. + * Applies to the width and height of the SVG element for the `'spinner'` type. + * Defaults to `32`. + */ + +const Loader: React.FC = ({ + classNameContainer, + classNameLoader, + classNameText, + type = 'spinner', + text, + size = 32, +}) => { + const isSpinner = type === 'spinner'; + + return ( +
+ {isSpinner ? ( + <> + + + + + {text &&

{text}

} + + ) : ( +
+
+ {text &&

{text}

} +
+ )} +
+ ); +}; + +export default Loader; diff --git a/src/components/loader/__test__/Loader.test.tsx b/src/components/loader/__test__/Loader.test.tsx new file mode 100644 index 0000000..da9ddfb --- /dev/null +++ b/src/components/loader/__test__/Loader.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { expect } from 'chai'; +import { afterEach, describe, it, vi } from 'vitest'; +import Loader from '../Loader'; + +describe('Loader Component', () => { + const renderLoader = (props = {}) => { + return render(); + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should match snapshot', () => { + const loader = renderLoader(); + expect(loader).toMatchSnapshot(); + }); + + it('renders a spinner loader by default', () => { + const { getByRole } = renderLoader(); + const spinner = getByRole('img', { hidden: true }); + expect(spinner).toBeInTheDocument(); + expect(spinner).toHaveClass('animate-spin'); + }); + + it('renders a pulse loader when type is "pulse"', () => { + const { getByText } = renderLoader({ type: 'pulse' }); + const pulseLoader = getByText('', { selector: '.loader06' }); + expect(pulseLoader).toBeInTheDocument(); + }); + + it('renders the provided text below the spinner loader', () => { + const text = 'Loading data...'; + const { getByText } = renderLoader({ text }); + const textElement = getByText(text); + expect(textElement).toBeInTheDocument(); + }); + + it('renders the provided text below the pulse loader', () => { + const text = 'Loading data...'; + const { getByText } = renderLoader({ type: 'pulse', text }); + const textElement = getByText(text); + expect(textElement).toBeInTheDocument(); + expect(textElement).toHaveClass('loader-text'); + }); + + it('applies custom class to the container', () => { + const customClass = 'custom-container'; + const { container } = renderLoader({ classNameContainer: customClass }); + expect(container.firstChild).toHaveClass(customClass); + }); + + it('applies custom class to the loader', () => { + const customClass = 'custom-loader'; + const { container } = renderLoader({ classNameLoader: customClass }); + const loader = container.firstChild?.firstChild; + expect(loader).toHaveClass(customClass); + }); + + it('renders with the correct size for the spinner', () => { + const size = 64; + const { getByRole } = renderLoader({ size }); + const spinner = getByRole('img', { hidden: true }); + expect(spinner).toHaveAttribute('width', `${size}`); + expect(spinner).toHaveAttribute('height', `${size}`); + }); + + it('does not render text if none is provided', () => { + const { queryByText } = renderLoader(); + const textElement = queryByText(/./); + expect(textElement).toBeNull(); + }); +}); diff --git a/src/components/spinner/__test__/__snapshots__/Spinner.test.tsx.snap b/src/components/loader/__test__/__snapshots__/Loader.test.tsx.snap similarity index 72% rename from src/components/spinner/__test__/__snapshots__/Spinner.test.tsx.snap rename to src/components/loader/__test__/__snapshots__/Loader.test.tsx.snap index af91d36..857512a 100644 --- a/src/components/spinner/__test__/__snapshots__/Spinner.test.tsx.snap +++ b/src/components/loader/__test__/__snapshots__/Loader.test.tsx.snap @@ -1,16 +1,47 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Spinner component > Primary button should render correctly 1`] = ` +exports[`__default_name_ 1`] = ` { "asFragment": [Function], "baseElement": +
+
+ + + + +
+
+ , + "container":
Primary button should render correctly 1`] = `
- , - "container":
- - - -
, "debug": [Function], "findAllByAltText": [Function], diff --git a/src/components/spinner/Spinner.tsx b/src/components/spinner/Spinner.tsx deleted file mode 100644 index da21e32..0000000 --- a/src/components/spinner/Spinner.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// TODO: Add Spinner to StoryBook -export const Spinner = ({ className = '', size }: { className?: string; size?: number }): JSX.Element => { - return ( - - - - - ); -}; diff --git a/src/components/spinner/__test__/Spinner.test.tsx b/src/components/spinner/__test__/Spinner.test.tsx deleted file mode 100644 index 6cb1c2a..0000000 --- a/src/components/spinner/__test__/Spinner.test.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { describe, expect, it } from 'vitest'; -import { Spinner } from '../Spinner'; -import { render } from '@testing-library/react'; - -describe('Spinner component', () => { - it('Primary button should render correctly', () => { - const spinner = render(); - expect(spinner).toMatchSnapshot(); - }); -}); diff --git a/src/stories/components/button/Button.stories.ts b/src/stories/components/button/Button.stories.ts index f9543cc..6bb0838 100644 --- a/src/stories/components/button/Button.stories.ts +++ b/src/stories/components/button/Button.stories.ts @@ -43,3 +43,11 @@ export const Destructive: Story = { children: 'Button', }, }; + +export const Loading: Story = { + args: { + variant: 'primary', + children: 'Button', + loading: true, + }, +}; diff --git a/src/stories/components/loader/Loader.stories.tsx b/src/stories/components/loader/Loader.stories.tsx new file mode 100644 index 0000000..e407572 --- /dev/null +++ b/src/stories/components/loader/Loader.stories.tsx @@ -0,0 +1,66 @@ +import type { Decorator, Meta, StoryObj } from '@storybook/react'; +import Loader from '../../../components/loader/Loader'; + +const overlay: Decorator = (Story) => ( +
+ +
+); + +const meta: Meta = { + title: 'Components/Loader', + component: Loader, + parameters: { + layout: 'fullscreen', + }, + decorators: [overlay], + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const defaultArgs = { + classNameContainer: 'absolute z-50 flex h-full w-full flex-col items-center justify-center bg-highlight/10', + classNameLoader: 'text-blue-500', + size: 40, +}; + +export const SpinnerLoader: Story = { + args: { + ...defaultArgs, + type: 'spinner', + }, +}; + +export const PulseLoader: Story = { + args: { + ...defaultArgs, + type: 'pulse', + }, +}; + +export const CustomSpinnerTextLoader: Story = { + args: { + ...defaultArgs, + type: 'spinner', + classNameText: 'mt-5 text-2xl font-medium text-gray-100', + text: 'Cargando, por favor espera...', + }, +}; + +export const CustomPulseTextLoader: Story = { + args: { + ...defaultArgs, + type: 'pulse', + classNameText: 'mt-5 text-2xl font-medium text-gray-100', + text: 'Cargando, por favor espera...', + }, +}; + +export const LargeLoader: Story = { + args: { + ...defaultArgs, + size: 80, + }, +}; diff --git a/src/styles/Loader.css b/src/styles/Loader.css new file mode 100644 index 0000000..b79fa4c --- /dev/null +++ b/src/styles/Loader.css @@ -0,0 +1,60 @@ +.loader-container { + width: 100%; + height: 90%; +} + +.loader06 { + position: absolute; + left: 49%; + top: 50%; +} + +.loader06::before { + content: ''; + border: 4px solid rgba(0, 82, 236, 0.5); + border-radius: 50%; + width: 67.2px; + height: 67.2px; + position: absolute; + top: -9.6px; + left: -9.6px; + animation: loader-scale 1s ease-out infinite; + animation-delay: 1s; + opacity: 0; +} + +.loader06::after { + content: ''; + border: 4px solid #0052ec; + border-radius: 50%; + width: 56px; + height: 56px; + position: absolute; + top: -4px; + left: -4px; + animation: loader-scale 1s ease-out infinite; + animation-delay: 0.5s; +} + +.loader-text { + position: absolute; + top: calc(50% + 3rem); + left: 50%; + transform: translateX(-50%); +} + +@keyframes loader-scale { + 0% { + transform: scale(0); + opacity: 0; + } + + 50% { + opacity: 1; + } + + 100% { + transform: scale(1); + opacity: 0; + } +}