diff --git a/src/common/components/Loader/LoaderSpinner.tsx b/src/common/components/Loader/LoaderSpinner.tsx deleted file mode 100644 index c344c3f..0000000 --- a/src/common/components/Loader/LoaderSpinner.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from 'common/utils/css'; -import { BaseComponentProps } from 'common/utils/types'; -import FAIcon, { FAIconProps } from 'common/components/Icon/FAIcon'; - -/** - * Properties for the `LoaderSpinner` component. - * @param {string} [iconClassName] - Optional. CSS class names for the icon. - * @param {string} [icon] - Optional. The icon name. Default: "circleNotch". - * @param {string} [text] - Optional. The loader text. - * @param {string} [textClassName] - Optional. CSS class names for the text. - * @see {@link BaseComponentProps} - */ -export interface LoaderSpinnerProps extends BaseComponentProps, Partial> { - iconClassName?: string; - text?: string; - textClassName?: string; -} - -/** - * The `LoaderSpinner` component renders an animated spinning loader icon with - * optional accompanying text. Typically used when some foreground or background - * process is occurring, usually interaction with an API. - * @param {LoaderSpinnerProps} [props] - Component properties, `LoaderSpinnerProps`. - * @returns {JSX.Element} JSX - */ -const LoaderSpinner = ({ - className, - iconClassName, - icon = 'circleNotch', - testId = 'loader-spinner', - text, - textClassName, -}: LoaderSpinnerProps): JSX.Element => { - return ( -
- - {!!text &&
{text}
} -
- ); -}; - -export default LoaderSpinner; diff --git a/src/common/components/Loader/LoaderSuspense.tsx b/src/common/components/Loader/LoaderSuspense.tsx index 90700f2..8790979 100644 --- a/src/common/components/Loader/LoaderSuspense.tsx +++ b/src/common/components/Loader/LoaderSuspense.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, Suspense } from 'react'; -import LoaderSpinner from './LoaderSpinner'; +import Spinner from './Spinner'; /** * The `LoaderSuspense` component renders an animated spinning loader. Typically used @@ -14,7 +14,9 @@ const LoaderSuspense = ({ children }: PropsWithChildren): JSX.Element => { className="flex h-[50vh] items-center justify-center" data-testid="loader-suspense-fallback" > - + + Loading... + } > diff --git a/src/common/components/Loader/Spinner.tsx b/src/common/components/Loader/Spinner.tsx new file mode 100644 index 0000000..d0b4885 --- /dev/null +++ b/src/common/components/Loader/Spinner.tsx @@ -0,0 +1,47 @@ +import { PropsWithChildren } from 'react'; + +import { BaseComponentProps } from 'common/utils/types'; +import { cn } from 'common/utils/css'; +import FAIcon, { FAIconProps } from 'common/components/Icon/FAIcon'; + +/** + * Properties for the `Spinner` component. + * @param {string} [icon] - Optional. A `FAIconProps` object containing properties + * for the icon. + * @see {@link FAIconProps} + */ +export interface SpinnerProps extends BaseComponentProps, PropsWithChildren { + icon?: Omit & Partial>; +} + +/** + * The `Spinner` component displays an animated spinning loader icon with + * optional accompanying text. Typically used when some foreground or background + * process is occurring, such as an interaction with an API. + */ +const Spinner = ({ children, className, icon, testId = 'spinner' }: SpinnerProps): JSX.Element => { + return ( +
+ + {children} +
+ ); +}; + +/** + * The Text component displays optional accompanying text for the `Spinner`. + */ +const Text = ({ + children, + className, + testId = 'spinner-text', +}: BaseComponentProps & PropsWithChildren): JSX.Element => { + return ( +
+ {children} +
+ ); +}; +Spinner.Text = Text; + +export default Spinner; diff --git a/src/common/components/Loader/__stories__/LoaderSpinner.stories.tsx b/src/common/components/Loader/__stories__/Spinner.stories.tsx similarity index 51% rename from src/common/components/Loader/__stories__/LoaderSpinner.stories.tsx rename to src/common/components/Loader/__stories__/Spinner.stories.tsx index bad8877..16785e3 100644 --- a/src/common/components/Loader/__stories__/LoaderSpinner.stories.tsx +++ b/src/common/components/Loader/__stories__/Spinner.stories.tsx @@ -1,23 +1,21 @@ import type { Meta, StoryObj } from '@storybook/react'; -import LoaderSpinner from '../LoaderSpinner'; +import Spinner from '../Spinner'; const meta = { - title: 'Common/Loader/LoaderSpinner', - component: LoaderSpinner, + title: 'Common/Loader/Spinner', + component: Spinner, parameters: { layout: 'centered', }, tags: ['autodocs'], argTypes: { + children: { description: 'Optional. The content, e.g. "Spinner.Text".' }, className: { description: 'Additional CSS classes.' }, - icon: { description: 'Optional. The icon name.' }, - iconClassName: { description: 'Optional. CSS class names for the icon.' }, + icon: { description: 'Optional. A "FAIconProps" object containing properties for the icon.' }, testId: { description: 'The test identifier.' }, - text: { description: 'Optional. The loader text.' }, - textClassName: { description: 'Optional. CSS class names for the text.' }, }, -} satisfies Meta; +} satisfies Meta; export default meta; @@ -29,24 +27,24 @@ export const Simple: Story = { export const Larger: Story = { args: { - iconClassName: 'text-2xl', + icon: { size: '2x' }, }, }; export const Colored: Story = { args: { - iconClassName: 'text-blue-600', + icon: { className: 'text-blue-600' }, }, }; export const WithText: Story = { args: { - text: 'Engaging warp engines Captain...', + children: Engaging warp engines Captain..., }, }; export const WithAlternativeIcon: Story = { args: { - icon: 'circleXmark', + icon: { icon: 'circleXmark' }, }, }; diff --git a/src/common/components/Loader/__tests__/LoaderSpinner.test.tsx b/src/common/components/Loader/__tests__/LoaderSpinner.test.tsx deleted file mode 100644 index e3e604c..0000000 --- a/src/common/components/Loader/__tests__/LoaderSpinner.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen, waitFor } from 'test/test-utils'; - -import LoaderSpinner from '../LoaderSpinner'; - -describe('LoaderSpinner', () => { - it('should render successfully', async () => { - // ARRANGE - render(); - await screen.findByTestId('loader-spinner'); - - // ASSERT - expect(screen.getByTestId('loader-spinner')).toBeDefined(); - }); - - it('should render custom testId', async () => { - // ARRANGE - render(); - await screen.findByTestId('test'); - - // ASSERT - expect(screen.getByTestId('test')).toBeDefined(); - }); - - it('should render default icon', async () => { - // ARRANGE - render(); - await screen.findByTestId('loader-spinner'); - - // ASSERT - expect(screen.getByTestId('loader-spinner-icon')).toHaveAttribute('data-icon', 'circle-notch'); - }); - - it('should render custom icon', async () => { - // ARRANGE - render(); - await waitFor(() => - expect(screen.getByTestId('loader-spinner-icon')).toHaveAttribute('data-icon', 'bars'), - ); - - // ASSERT - expect(screen.getByTestId('loader-spinner-icon')).toHaveAttribute('data-icon', 'bars'); - }); - - it('should render custom icon class name', async () => { - // ARRANGE - render(); - await screen.findByTestId('loader-spinner-test'); - - // ASSERT - expect(screen.getByTestId('loader-spinner-test-icon').classList).toContain('my-class'); - }); - - it('should render custom class name', async () => { - // ARRANGE - render(); - await screen.findByTestId('loader-spinner-test'); - - // ASSERT - expect(screen.getByTestId('loader-spinner-test').classList).toContain('my-class'); - }); - - it('should render text', async () => { - // ARRANGE - render(); - await screen.findByText('loader text'); - - // ASSERT - expect(screen.getByText('loader text')).toBeDefined(); - }); - - it('should render custom text class name', async () => { - // ARRANGE - render( - , - ); - await screen.findByText('loader text'); - - // ASSERT - expect(screen.getByText('loader text').classList).toContain('my-class'); - }); -}); diff --git a/src/common/components/Loader/__tests__/Spinner.test.tsx b/src/common/components/Loader/__tests__/Spinner.test.tsx new file mode 100644 index 0000000..35b7640 --- /dev/null +++ b/src/common/components/Loader/__tests__/Spinner.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen, waitFor } from 'test/test-utils'; + +import Spinner from '../Spinner'; + +describe('Spinner', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('my-spinner'); + + // ASSERT + expect(screen.getByTestId('my-spinner')).toBeDefined(); + }); + + it('should render default icon', async () => { + // ARRANGE + render(); + await screen.findByTestId('my-spinner'); + + // ASSERT + expect(screen.getByTestId('my-spinner-icon')).toHaveAttribute('data-icon', 'circle-notch'); + }); + + it('should render custom icon', async () => { + // ARRANGE + render(); + await waitFor(() => + expect(screen.getByTestId('my-spinner-icon')).toHaveAttribute('data-icon', 'bars'), + ); + + // ASSERT + expect(screen.getByTestId('my-spinner-icon')).toHaveAttribute('data-icon', 'bars'); + }); + + it('should render text', async () => { + // ARRANGE + render( + + loader text + , + ); + await screen.findByTestId('my-spinner-text'); + + // ASSERT + expect(screen.getByTestId('my-spinner-text')).toHaveTextContent(/loader text/); + }); +}); diff --git a/src/common/components/Router/Router.tsx b/src/common/components/Router/Router.tsx index d66e82f..3748ee2 100644 --- a/src/common/components/Router/Router.tsx +++ b/src/common/components/Router/Router.tsx @@ -43,6 +43,7 @@ const SearchInputComponents = lazy( ); const SelectComponents = lazy(() => import('pages/Components/components/SelectComponents')); const SkeletonComponents = lazy(() => import('pages/Components/components/SkeletonComponents')); +const SpinnerComponents = lazy(() => import('pages/Components/components/SpinnerComponents')); const TabsComponents = lazy(() => import('pages/Components/components/TabsComponents')); const TextComponents = lazy(() => import('pages/Components/components/TextComponents')); const TextareaComponents = lazy(() => import('pages/Components/components/TextareaComponents')); @@ -175,6 +176,10 @@ export const routes: RouteObject[] = [ path: 'skeleton', element: withSuspense(), }, + { + path: 'spinner', + element: withSuspense(), + }, { path: 'tabs', element: withSuspense(), diff --git a/src/common/providers/AuthProvider.tsx b/src/common/providers/AuthProvider.tsx index e496b2a..45662e3 100644 --- a/src/common/providers/AuthProvider.tsx +++ b/src/common/providers/AuthProvider.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react'; import { AuthContext, AuthContextValue } from './AuthContext'; import { useGetUserTokens } from 'common/api/useGetUserTokens'; -import LoaderSpinner from 'common/components/Loader/LoaderSpinner'; +import Spinner from 'common/components/Loader/Spinner'; /** * The `AuthContextProvider` React component creates, maintains, and provides @@ -33,7 +33,9 @@ const AuthContextProvider = ({ children }: PropsWithChildren): JSX.Element => { {!isReady && (
- + + Signing in... +
)} diff --git a/src/pages/Auth/Signout/SignoutPage.tsx b/src/pages/Auth/Signout/SignoutPage.tsx index 0b35a0e..f86da62 100644 --- a/src/pages/Auth/Signout/SignoutPage.tsx +++ b/src/pages/Auth/Signout/SignoutPage.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSignout } from './api/useSignout'; -import LoaderSpinner from 'common/components/Loader/LoaderSpinner'; +import Spinner from 'common/components/Loader/Spinner'; import Page from 'common/components/Content/Page'; import Container from 'common/components/Content/Container'; @@ -30,7 +30,9 @@ const SignoutPage = (): JSX.Element => {
- + + Signing out... +
diff --git a/src/pages/Components/ComponentsPage.tsx b/src/pages/Components/ComponentsPage.tsx index b14184b..47f0a46 100644 --- a/src/pages/Components/ComponentsPage.tsx +++ b/src/pages/Components/ComponentsPage.tsx @@ -85,6 +85,9 @@ const ComponentsPage = (): JSX.Element => { Skeleton + + Spinner + Tabs diff --git a/src/pages/Components/components/SpinnerComponents.tsx b/src/pages/Components/components/SpinnerComponents.tsx new file mode 100644 index 0000000..b3fc843 --- /dev/null +++ b/src/pages/Components/components/SpinnerComponents.tsx @@ -0,0 +1,206 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { BaseComponentProps } from 'common/utils/types'; +import { ComponentProperty } from '../model/components'; +import Table from 'common/components/Table/Table'; +import CodeSnippet from 'common/components/Text/CodeSnippet'; +import Heading from 'common/components/Text/Heading'; +import Spinner from 'common/components/Loader/Spinner'; +import Link from 'common/components/Link/Link'; + +/** + * The `SpinnerComponents` component renders a set of examples illustrating + * the use of the `Spinner` component. + */ +const SpinnerComponents = ({ + className, + testId = 'components-spinner', +}: BaseComponentProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'children', + description: 'Optional. The content, e.g. "Spinner.Text".', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'icon', + description: 'Optional. A "FAIconProps" object containing properties for the icon.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => ( + {info.getValue()} + ), + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+ + Spinner Component + + +
+
+ The Spinner component displays an animated, + spinning loader icon with optional accompanying text. The Spinner is typically used when a + process is running, such as an API call. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + + Examples + + + + Basic + +
This is the most basic use of the Spinner component.
+
+
+ {/* Example */} + +
+ `} /> +
+ + + Sizes + +
+ Adjust the size of the spinner using the "icon" property. +
+
+
+ {/* Example */} +
+ + + + + + +
+
+ + + + + + + +
`} + /> +
+ + + Colors + +
+ Use Tailwind classes to adjust the color of the spinner. +
+
+
+ {/* Example */} +
+ + + + + + +
+
+ + + + + + + +
`} + /> + + + + Alternative icons + +
+ By default, the spinner uses the "circle notch" icon. You can use any icon from the + FontAwesome library. For a list of available icons, see the{' '} + + FontAwesome icon gallery + + . Adjust the icon using the "icon" property. +
+
+
+ {/* Example */} + +
+ `} + /> +
+ + + Composition + +
+ Add text to the spinner using the "Spinner.Text" component. +
+
+
+ {/* Example */} +
+ + Loading... + + + Loading... + +
+
+ + + Loading... + + + Loading... + +
`} + /> + + +
+ ); +}; + +export default SpinnerComponents; diff --git a/src/pages/Components/components/__tests__/SpinnerComponents.test.tsx b/src/pages/Components/components/__tests__/SpinnerComponents.test.tsx new file mode 100644 index 0000000..5f0d5bb --- /dev/null +++ b/src/pages/Components/components/__tests__/SpinnerComponents.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import SpinnerComponents from '../SpinnerComponents'; + +describe('SpinnerComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-spinner'); + + // ASSERT + expect(screen.getByTestId('components-spinner')).toBeDefined(); + }); +});