Skip to content

Commit 14147dc

Browse files
authored
Spinner component page (#108)
* #86 rename LoaderSpinner to Spinner * #86 Spinner examples * #86 tests
1 parent feac43e commit 14147dc

12 files changed

Lines changed: 348 additions & 142 deletions

File tree

src/common/components/Loader/LoaderSpinner.tsx

Lines changed: 0 additions & 42 deletions
This file was deleted.

src/common/components/Loader/LoaderSuspense.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { PropsWithChildren, Suspense } from 'react';
22

3-
import LoaderSpinner from './LoaderSpinner';
3+
import Spinner from './Spinner';
44

55
/**
66
* The `LoaderSuspense` component renders an animated spinning loader. Typically used
@@ -14,7 +14,9 @@ const LoaderSuspense = ({ children }: PropsWithChildren): JSX.Element => {
1414
className="flex h-[50vh] items-center justify-center"
1515
data-testid="loader-suspense-fallback"
1616
>
17-
<LoaderSpinner text="Loading..." testId="loader-suspense-spinner" />
17+
<Spinner testId="loader-suspense-spinner">
18+
<Spinner.Text>Loading...</Spinner.Text>
19+
</Spinner>
1820
</div>
1921
}
2022
>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { PropsWithChildren } from 'react';
2+
3+
import { BaseComponentProps } from 'common/utils/types';
4+
import { cn } from 'common/utils/css';
5+
import FAIcon, { FAIconProps } from 'common/components/Icon/FAIcon';
6+
7+
/**
8+
* Properties for the `Spinner` component.
9+
* @param {string} [icon] - Optional. A `FAIconProps` object containing properties
10+
* for the icon.
11+
* @see {@link FAIconProps}
12+
*/
13+
export interface SpinnerProps extends BaseComponentProps, PropsWithChildren {
14+
icon?: Omit<FAIconProps, 'icon'> & Partial<Pick<FAIconProps, 'icon'>>;
15+
}
16+
17+
/**
18+
* The `Spinner` component displays an animated spinning loader icon with
19+
* optional accompanying text. Typically used when some foreground or background
20+
* process is occurring, such as an interaction with an API.
21+
*/
22+
const Spinner = ({ children, className, icon, testId = 'spinner' }: SpinnerProps): JSX.Element => {
23+
return (
24+
<div className={cn('flex items-center gap-2', className)} data-testid={testId}>
25+
<FAIcon icon={icon?.icon || 'circleNotch'} spin {...icon} testId={`${testId}-icon`} />
26+
{children}
27+
</div>
28+
);
29+
};
30+
31+
/**
32+
* The Text component displays optional accompanying text for the `Spinner`.
33+
*/
34+
const Text = ({
35+
children,
36+
className,
37+
testId = 'spinner-text',
38+
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
39+
return (
40+
<div className={cn(className)} data-testid={testId}>
41+
{children}
42+
</div>
43+
);
44+
};
45+
Spinner.Text = Text;
46+
47+
export default Spinner;

src/common/components/Loader/__stories__/LoaderSpinner.stories.tsx renamed to src/common/components/Loader/__stories__/Spinner.stories.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
import type { Meta, StoryObj } from '@storybook/react';
22

3-
import LoaderSpinner from '../LoaderSpinner';
3+
import Spinner from '../Spinner';
44

55
const meta = {
6-
title: 'Common/Loader/LoaderSpinner',
7-
component: LoaderSpinner,
6+
title: 'Common/Loader/Spinner',
7+
component: Spinner,
88
parameters: {
99
layout: 'centered',
1010
},
1111
tags: ['autodocs'],
1212
argTypes: {
13+
children: { description: 'Optional. The content, e.g. "Spinner.Text".' },
1314
className: { description: 'Additional CSS classes.' },
14-
icon: { description: 'Optional. The icon name.' },
15-
iconClassName: { description: 'Optional. CSS class names for the icon.' },
15+
icon: { description: 'Optional. A "FAIconProps" object containing properties for the icon.' },
1616
testId: { description: 'The test identifier.' },
17-
text: { description: 'Optional. The loader text.' },
18-
textClassName: { description: 'Optional. CSS class names for the text.' },
1917
},
20-
} satisfies Meta<typeof LoaderSpinner>;
18+
} satisfies Meta<typeof Spinner>;
2119

2220
export default meta;
2321

@@ -29,24 +27,24 @@ export const Simple: Story = {
2927

3028
export const Larger: Story = {
3129
args: {
32-
iconClassName: 'text-2xl',
30+
icon: { size: '2x' },
3331
},
3432
};
3533

3634
export const Colored: Story = {
3735
args: {
38-
iconClassName: 'text-blue-600',
36+
icon: { className: 'text-blue-600' },
3937
},
4038
};
4139

4240
export const WithText: Story = {
4341
args: {
44-
text: 'Engaging warp engines Captain...',
42+
children: <Spinner.Text>Engaging warp engines Captain...</Spinner.Text>,
4543
},
4644
};
4745

4846
export const WithAlternativeIcon: Story = {
4947
args: {
50-
icon: 'circleXmark',
48+
icon: { icon: 'circleXmark' },
5149
},
5250
};

src/common/components/Loader/__tests__/LoaderSpinner.test.tsx

Lines changed: 0 additions & 82 deletions
This file was deleted.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { render, screen, waitFor } from 'test/test-utils';
4+
5+
import Spinner from '../Spinner';
6+
7+
describe('Spinner', () => {
8+
it('should render successfully', async () => {
9+
// ARRANGE
10+
render(<Spinner testId="my-spinner" />);
11+
await screen.findByTestId('my-spinner');
12+
13+
// ASSERT
14+
expect(screen.getByTestId('my-spinner')).toBeDefined();
15+
});
16+
17+
it('should render default icon', async () => {
18+
// ARRANGE
19+
render(<Spinner testId="my-spinner" />);
20+
await screen.findByTestId('my-spinner');
21+
22+
// ASSERT
23+
expect(screen.getByTestId('my-spinner-icon')).toHaveAttribute('data-icon', 'circle-notch');
24+
});
25+
26+
it('should render custom icon', async () => {
27+
// ARRANGE
28+
render(<Spinner testId="my-spinner" icon={{ icon: 'bars' }} />);
29+
await waitFor(() =>
30+
expect(screen.getByTestId('my-spinner-icon')).toHaveAttribute('data-icon', 'bars'),
31+
);
32+
33+
// ASSERT
34+
expect(screen.getByTestId('my-spinner-icon')).toHaveAttribute('data-icon', 'bars');
35+
});
36+
37+
it('should render text', async () => {
38+
// ARRANGE
39+
render(
40+
<Spinner testId="my-spinner">
41+
<Spinner.Text testId="my-spinner-text">loader text</Spinner.Text>
42+
</Spinner>,
43+
);
44+
await screen.findByTestId('my-spinner-text');
45+
46+
// ASSERT
47+
expect(screen.getByTestId('my-spinner-text')).toHaveTextContent(/loader text/);
48+
});
49+
});

src/common/components/Router/Router.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const SearchInputComponents = lazy(
4343
);
4444
const SelectComponents = lazy(() => import('pages/Components/components/SelectComponents'));
4545
const SkeletonComponents = lazy(() => import('pages/Components/components/SkeletonComponents'));
46+
const SpinnerComponents = lazy(() => import('pages/Components/components/SpinnerComponents'));
4647
const TabsComponents = lazy(() => import('pages/Components/components/TabsComponents'));
4748
const TextComponents = lazy(() => import('pages/Components/components/TextComponents'));
4849
const TextareaComponents = lazy(() => import('pages/Components/components/TextareaComponents'));
@@ -175,6 +176,10 @@ export const routes: RouteObject[] = [
175176
path: 'skeleton',
176177
element: withSuspense(<SkeletonComponents />),
177178
},
179+
{
180+
path: 'spinner',
181+
element: withSuspense(<SpinnerComponents />),
182+
},
178183
{
179184
path: 'tabs',
180185
element: withSuspense(<TabsComponents />),

src/common/providers/AuthProvider.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react';
22

33
import { AuthContext, AuthContextValue } from './AuthContext';
44
import { useGetUserTokens } from 'common/api/useGetUserTokens';
5-
import LoaderSpinner from 'common/components/Loader/LoaderSpinner';
5+
import Spinner from 'common/components/Loader/Spinner';
66

77
/**
88
* The `AuthContextProvider` React component creates, maintains, and provides
@@ -33,7 +33,9 @@ const AuthContextProvider = ({ children }: PropsWithChildren): JSX.Element => {
3333
{!isReady && (
3434
<div className="h-[50vh]" data-testid="provider-auth">
3535
<div className="flex h-full items-center justify-center text-2xl">
36-
<LoaderSpinner text="Signing in..." />
36+
<Spinner>
37+
<Spinner.Text>Signing in...</Spinner.Text>
38+
</Spinner>
3739
</div>
3840
</div>
3941
)}

src/pages/Auth/Signout/SignoutPage.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
22
import { useNavigate } from 'react-router-dom';
33

44
import { useSignout } from './api/useSignout';
5-
import LoaderSpinner from 'common/components/Loader/LoaderSpinner';
5+
import Spinner from 'common/components/Loader/Spinner';
66
import Page from 'common/components/Content/Page';
77
import Container from 'common/components/Content/Container';
88

@@ -30,7 +30,9 @@ const SignoutPage = (): JSX.Element => {
3030
<Page testId="page-signout">
3131
<Container className="h-[50vh]">
3232
<div className="flex h-full items-center justify-center text-2xl">
33-
<LoaderSpinner text="Signing out..." />
33+
<Spinner>
34+
<Spinner.Text>Signing out...</Spinner.Text>
35+
</Spinner>
3436
</div>
3537
</Container>
3638
</Page>

src/pages/Components/ComponentsPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ const ComponentsPage = (): JSX.Element => {
8585
<MenuNavLink to="skeleton" styleActive>
8686
Skeleton
8787
</MenuNavLink>
88+
<MenuNavLink to="spinner" styleActive>
89+
Spinner
90+
</MenuNavLink>
8891
<MenuNavLink to="tabs" styleActive>
8992
Tabs
9093
</MenuNavLink>

0 commit comments

Comments
 (0)