Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/loading-spinner-v5-codemod.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@lg-tools/codemods': minor
'@lg-tools/cli': minor
---

Updates `loading-spinner-v5` codemod to convert `displayOption` prop to `size` and `direction` props. The codemod now keeps the `description` and `baseFontSize` props instead of removing them.

22 changes: 22 additions & 0 deletions .changeset/spinner-description-direction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@leafygreen-ui/loading-indicator': minor
---

Adds `description` and `direction` props to the `Spinner` component to support text rendering alongside the spinner.

- `description`: Optional text to display alongside the spinner
- `direction`: Controls the layout of the spinner and description (`vertical` or `horizontal`)
- `baseFontSize`: Controls the font size of the description text
- `svgProps`: Pass-through props for the SVG element

```tsx
<Spinner
size="large"
direction="horizontal"
description="Loading..."
className=""
svgProps={{...}}
/>
```


67 changes: 54 additions & 13 deletions packages/loading-indicator/src/Spinner/Spinner.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';

import { Size } from '@leafygreen-ui/tokens';

import { getTestUtils } from '../testing';

import { Spinner } from '.';
Expand All @@ -16,33 +14,76 @@ describe('packages/loading-spinner', () => {
expect(results).toHaveNoViolations();
});
});

test('renders with default props', () => {
render(<Spinner />);
const { getSpinner } = getTestUtils();

expect(getSpinner()).toBeInTheDocument();
});

test('renders with custom size', () => {
render(<Spinner size={Size.Large} />);
test('renders with colorOverride', () => {
render(<Spinner colorOverride="red" />);
const { getSpinner } = getTestUtils();

expect(getSpinner()).toBeInTheDocument();
});

test('renders with custom size in pixels', () => {
render(<Spinner size={100} />);
test('wraps spinner in a div element', () => {
render(<Spinner />);
const { getSpinner } = getTestUtils();
const spinner = getSpinner();

expect(spinner).toBeInTheDocument();
expect(spinner).toHaveAttribute('viewBox', '0 0 100 100');
expect(spinner.tagName).toBe('DIV');
expect(spinner.querySelector('svg')).toBeInTheDocument();
});

test('renders with colorOverride', () => {
render(<Spinner colorOverride="red" />);
const { getSpinner } = getTestUtils();
describe('description prop', () => {
test('renders description text when provided', () => {
render(<Spinner description="Loading..." />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});

expect(getSpinner()).toBeInTheDocument();
test('does not render description when not provided', () => {
render(<Spinner />);
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
});

describe('props pass-through', () => {
test('passes className to wrapper div', () => {
render(<Spinner className="custom-class" />);
const { getSpinner } = getTestUtils();
const spinner = getSpinner();

expect(spinner).toHaveClass('custom-class');
});

test('passes other props to wrapper div', () => {
render(<Spinner data-custom="test-value" />);
const { getSpinner } = getTestUtils();
const spinner = getSpinner();

expect(spinner).toHaveAttribute('data-custom', 'test-value');
});

test('passes svgProps to svg element', () => {
render(
<Spinner svgProps={{ 'aria-label': 'Loading spinner', role: 'img' }} />,
);
const { getSpinner } = getTestUtils();
const svg = getSpinner().querySelector('svg');

expect(svg).toHaveAttribute('aria-label', 'Loading spinner');
expect(svg).toHaveAttribute('role', 'img');
});

test('passes className into svg', () => {
render(<Spinner svgProps={{ className: 'svg-custom-class' }} />);
const { getSpinner } = getTestUtils();
const svg = getSpinner().querySelector('svg');

expect(svg).toHaveClass('svg-custom-class');
});
});
});
42 changes: 42 additions & 0 deletions packages/loading-indicator/src/Spinner/Spinner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StoryObj } from '@storybook/react';

import { Size } from '@leafygreen-ui/tokens';

import { SpinnerDirection } from './Spinner.types';
import { Spinner } from '.';

export default {
Expand All @@ -20,9 +21,17 @@ export default {
colorOverride: {
control: 'color',
},
description: {
control: 'text',
},
direction: {
control: 'select',
options: Object.values(SpinnerDirection),
},
},
args: {
size: Size.Default,
direction: SpinnerDirection.Vertical,
},
} satisfies StoryMetaType<typeof Spinner>;

Expand All @@ -35,6 +44,31 @@ export const LiveExample: StoryObj<typeof Spinner> = {
},
};

export const WithDescription: StoryObj<typeof Spinner> = {
render: args => <Spinner {...args} />,
args: {
description: 'Loading...',
},
parameters: {
chromatic: {
disableSnapshot: true,
},
},
};

export const HorizontalDirection: StoryObj<typeof Spinner> = {
render: args => <Spinner {...args} />,
args: {
description: 'Loading...',
direction: SpinnerDirection.Horizontal,
},
parameters: {
chromatic: {
disableSnapshot: true,
},
},
};

export const Generated: StoryObj<typeof Spinner> = {
render: () => <></>,
parameters: {
Expand All @@ -46,7 +80,15 @@ export const Generated: StoryObj<typeof Spinner> = {
darkMode: [false, true],
size: [...Object.values(Size), 87],
colorOverride: [undefined, '#f00'],
description: [undefined, 'Loading...'],
direction: Object.values(SpinnerDirection),
},
excludeCombinations: [
{
description: undefined,
direction: SpinnerDirection.Horizontal,
},
],
},
},
};
30 changes: 30 additions & 0 deletions packages/loading-indicator/src/Spinner/Spinner.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import { color, Size } from '@leafygreen-ui/tokens';

import {
DASH_DURATION,
getHorizontalGap,
getPadding,
getSpinnerSize,
getStrokeWidth,
getVerticalGap,
ROTATION_DURATION,
} from './constants';
import { SpinnerDirection } from './Spinner.types';

/**
* Defines the outer SVG element keyframes
Expand Down Expand Up @@ -165,3 +168,30 @@ export const getCircleSVGArgs = (size: Size | number) => {
r: (sizeInPx - strokeWidth) / 2,
};
};

/**
* Returns the wrapper div styles based on direction and size
*/
export const getWrapperStyles = ({
direction,
size,
}: {
direction: SpinnerDirection;
size: Size | number;
}) =>
cx(
css`
display: flex;
align-items: center;
`,
{
[css`
flex-direction: column;
gap: ${getVerticalGap(size)}px;
`]: direction === SpinnerDirection.Vertical,
[css`
flex-direction: row;
gap: ${getHorizontalGap(size)}px;
`]: direction === SpinnerDirection.Horizontal,
},
);
56 changes: 40 additions & 16 deletions packages/loading-indicator/src/Spinner/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ import React from 'react';
import { cx } from '@leafygreen-ui/emotion';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { Size } from '@leafygreen-ui/tokens';
import { Body, useUpdatedBaseFontSize } from '@leafygreen-ui/typography';

import { descriptionThemeColor } from '../LoadingIndicator.styles';
import { getLgIds } from '../utils/getLgIds';

import { getSpinnerSize } from './constants';
import {
getCircleStyles,
getCircleSVGArgs,
getSvgStyles,
getWrapperStyles,
} from './Spinner.styles';
import { SpinnerProps } from './Spinner.types';
import { SpinnerDirection, SpinnerProps } from './Spinner.types';

/**
* SVG-based spinner loading indicator
Expand All @@ -21,38 +24,59 @@ import { SpinnerProps } from './Spinner.types';
* or provide a custom number in px
*
* @param {SpinnerProps} props - Props for the Spinner component.
* @returns {JSX.Element} SVG element representing the loading spinner.
* @returns {JSX.Element} Div element containing the loading spinner SVG.
*/
export const Spinner = ({
size = Size.Default,
disableAnimation = false,
colorOverride,
darkMode,
className,
description,
direction = SpinnerDirection.Vertical,
baseFontSize: baseFontSizeProp,
svgProps,
'data-lgid': lgid,
...rest
}: SpinnerProps) => {
const sizeInPx = getSpinnerSize(size);
const { theme } = useDarkMode(darkMode);
const baseFontSize = useUpdatedBaseFontSize(baseFontSizeProp);

return (
<svg
className={cx(getSvgStyles({ size, disableAnimation }), className)}
viewBox={`0 0 ${sizeInPx} ${sizeInPx}`}
xmlns="http://www.w3.org/2000/svg"
<div
className={cx(getWrapperStyles({ direction, size }), className)}
data-lgid={getLgIds(lgid).spinner}
data-testid={getLgIds(lgid).spinner}
{...rest}
>
<circle
className={getCircleStyles({
size,
theme,
colorOverride,
disableAnimation,
})}
{...getCircleSVGArgs(size)}
/>
</svg>
<svg
className={cx(
getSvgStyles({ size, disableAnimation }),
svgProps?.className,
)}
viewBox={`0 0 ${sizeInPx} ${sizeInPx}`}
xmlns="http://www.w3.org/2000/svg"
{...svgProps}
>
<circle
className={getCircleStyles({
size,
theme,
colorOverride,
disableAnimation,
})}
{...getCircleSVGArgs(size)}
/>
</svg>
{description && (
<Body
className={descriptionThemeColor[theme]}
baseFontSize={baseFontSize}
>
{description}
</Body>
)}
</div>
);
};
Loading
Loading