Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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('merges svgProps className with internal className', () => {
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>
);
};
39 changes: 37 additions & 2 deletions packages/loading-indicator/src/Spinner/Spinner.types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import React from 'react';

import { DarkModeProps, LgIdProps } from '@leafygreen-ui/lib';
import { Size } from '@leafygreen-ui/tokens';
import { BaseFontSize, Size } from '@leafygreen-ui/tokens';

/**
* Determines the position of the description text relative to the spinner
*/
export const SpinnerDirection = {
Vertical: 'vertical',
Horizontal: 'horizontal',
} as const;

export type SpinnerDirection =
(typeof SpinnerDirection)[keyof typeof SpinnerDirection];

export interface SpinnerProps
extends DarkModeProps,
LgIdProps,
React.ComponentProps<'svg'> {
React.ComponentProps<'div'> {
/**
* Provide a standard `Size` enum, or a custom number in px.
*/
Expand All @@ -24,4 +35,28 @@ export interface SpinnerProps
* @internal
*/
disableAnimation?: boolean;

/**
* Description text to display alongside the spinner
*/
description?: string;

/**
* Determines the position of the description text relative to the spinner.
* - `vertical`: Description appears below the spinner
* - `horizontal`: Description appears to the right of the spinner
*
* @default 'vertical'
*/
direction?: SpinnerDirection;

/**
* The base font size of the description text.
*/
baseFontSize?: BaseFontSize;

/**
* Props to pass through to the SVG element
*/
svgProps?: React.ComponentProps<'svg'>;
}
Loading
Loading