Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/empty-state-sizes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@launchpad-ui/components': minor
'@launchpad-ui/icons': minor
---

Add `medium` and `small` sizes to `EmptyState`. `EmptyState` now scales its `BadgeIcon`, heading, description, and buttons together, so set `size` on `EmptyState` rather than on the children. Use `large` for full-page and main-content empty states, `medium` inside cards, panels, and table bodies, and `small` in dense containers. Only `large` has padding of its own; `medium` and `small` leave it to the surrounding container. `BadgeIcon` gains a `BadgeIconContext` so a composing component can drive its size.
40 changes: 39 additions & 1 deletion packages/components/__tests__/EmptyState.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { render, screen } from '@launchpad-ui/test-utils';

import { Button, ButtonGroup, EmptyState, Heading, Text } from '../src';

import badgeIconStyles from '../../icons/src/styles/BadgeIcon.module.css';
import buttonStyles from '../src/styles/Button.module.css';
import emptyStateStyles from '../src/styles/EmptyState.module.css';

describe('EmptyState', () => {
it('renders heading and description', () => {
render(
<EmptyState>
<BadgeIcon size="large" aria-hidden>
<BadgeIcon aria-hidden>
<Icon name="folders" />
</BadgeIcon>
<Heading>No projects yet</Heading>
Expand Down Expand Up @@ -67,4 +69,40 @@ describe('EmptyState', () => {
const singleButton = singleButtonContainer.querySelector('button');
expect(singleButton?.parentElement).toHaveClass(emptyStateStyles.base);
});

it.each([
['large', emptyStateStyles.large, badgeIconStyles.large, buttonStyles.large],
['medium', emptyStateStyles.medium, badgeIconStyles.medium, buttonStyles.medium],
['small', emptyStateStyles.small, badgeIconStyles.small, buttonStyles.small],
] as const)('scales the badge icon and button to match size %s', (size, rootClass, badgeClass, buttonClass) => {
const { container } = render(
<EmptyState size={size}>
<BadgeIcon aria-hidden data-test-id="badge">
<Icon name="folders" />
</BadgeIcon>
<Heading>No projects yet</Heading>
<Text>Create a project to get started.</Text>
<Button variant="primary">Create project</Button>
</EmptyState>,
);

expect(container.firstChild).toHaveClass(rootClass);
expect(screen.getByTestId('badge')).toHaveClass(badgeClass);
expect(screen.getByRole('button', { name: 'Create project' })).toHaveClass(buttonClass);
});

it('lets EmptyState override a size set directly on the badge icon', () => {
render(
<EmptyState size="small">
<BadgeIcon size="large" aria-hidden data-test-id="badge">
<Icon name="folders" />
</BadgeIcon>
<Heading>No projects yet</Heading>
<Text>Create a project to get started.</Text>
</EmptyState>,
);

expect(screen.getByTestId('badge')).toHaveClass(badgeIconStyles.small);
expect(screen.getByTestId('badge')).not.toHaveClass(badgeIconStyles.large);
});
});
16 changes: 10 additions & 6 deletions packages/components/figma/EmptyState.figma.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,31 @@ import figma from '@figma/code-connect';
import type { IconProps } from '@launchpad-ui/icons';
import { BadgeIcon, Icon } from '@launchpad-ui/icons';

import type { EmptyStateProps } from '../src';
import { Button } from '../src/Button';
import { EmptyState } from '../src/EmptyState';
import { Heading } from '../src/Heading';
import { Text } from '../src/Text';

figma.connect(
EmptyState,
'https://www.figma.com/design/98HKKXL2dTle29ikJ3tzk7/%F0%9F%9A%80-LaunchPad?node-id=10559-2269',
'https://www.figma.com/design/98HKKXL2dTle29ikJ3tzk7/%F0%9F%9A%80-LaunchPad?node-id=29527-96176',
{
props: {
size: figma.enum<Exclude<EmptyStateProps['size'], null>>('Size', {
Large: 'large',
Medium: 'medium',
Small: 'small',
}),
hasBorder: figma.boolean('Border?'),
heading: figma.textContent('Heading'),
description: figma.textContent('Description'),
icon: figma.instance('Icon').render<IconProps>(({ name }) => <Icon name={name} />),
action: figma.string('Button label'),
},
example: ({ hasBorder, heading, description, icon, action }) => (
<EmptyState hasBorder={hasBorder}>
<BadgeIcon size="large" aria-hidden>
{icon}
</BadgeIcon>
example: ({ size, hasBorder, heading, description, icon, action }) => (
<EmptyState size={size} hasBorder={hasBorder}>
<BadgeIcon aria-hidden>{icon}</BadgeIcon>
<Heading>{heading}</Heading>
<Text>{description}</Text>
<Button variant="primary">{action}</Button>
Expand Down
17 changes: 11 additions & 6 deletions packages/components/src/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { TextContext } from 'react-aria-components/Text';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';

import { BadgeIconContext } from '@launchpad-ui/icons';

import { ButtonContext } from './Button';
import { ButtonGroupContext } from './ButtonGroup';

Expand All @@ -14,6 +16,8 @@ const emptyStateStyles = cva(styles.base, {
variants: {
size: {
large: styles.large,
medium: styles.medium,
small: styles.small,
},
hasBorder: {
true: styles.bordered,
Expand All @@ -38,21 +42,22 @@ interface EmptyStateProps extends HTMLAttributes<HTMLDivElement>, EmptyStateVari
* Follows the React Spectrum IllustratedMessage composition pattern: BadgeIcon (illustration),
* Heading, Text, and optional Button (action).
*
* Use `large` for full-page and main-content empty states, `medium` inside cards, panels, and
* table bodies, and `small` in dense containers where `medium` still crowds the layout.
*
* https://react-spectrum.adobe.com/v3/IllustratedMessage.html
*/
const EmptyState = ({ className, children, size = 'large', hasBorder = false, ref, ...props }: EmptyStateProps) => {
const scale = size ?? 'large';

return (
<div ref={ref} {...props} className={emptyStateStyles({ size, hasBorder, className })}>
<Provider
values={[
[HeadingContext, { className: styles.heading }],
[TextContext, { className: styles.description }],
[
ButtonContext,
{
size: size === 'large' ? 'large' : 'medium',
},
],
[BadgeIconContext, { size: scale }],
[ButtonContext, { size: scale }],
[ButtonGroupContext, { className: styles.actions }],
]}
>
Expand Down
33 changes: 27 additions & 6 deletions packages/components/src/styles/EmptyState.module.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
.base {
/* Large is the default size, so its scale lives here and .medium / .small override it. */
--empty-state-gap: var(--lp-spacing-700);
--empty-state-content-gap: var(--lp-spacing-300);
--empty-state-heading-font: var(--lp-text-heading-1-semibold);
--empty-state-description-font: var(--lp-text-body-1-regular);

display: grid;
grid-template-areas:
'illustration'
Expand All @@ -9,7 +15,7 @@
grid-template-rows:
min-content
min-content
var(--lp-size-8)
var(--empty-state-content-gap)
min-content
min-content;
grid-template-columns: 1fr;
Expand All @@ -20,21 +26,36 @@
/* BadgeIcon is the first direct child div in the composition pattern. */
.base > div:first-child:not([role='group']) {
grid-area: illustration;
margin-block-end: var(--lp-size-24);
margin-block-end: var(--empty-state-gap);
}

.large {
padding: var(--lp-spacing-800);
}

/* Medium and small carry no padding; the surrounding card, panel, or table body owns it. */
.medium {
--empty-state-gap: var(--lp-spacing-500);
--empty-state-content-gap: var(--lp-spacing-200);
--empty-state-heading-font: var(--lp-text-heading-2-semibold);
--empty-state-description-font: var(--lp-text-body-2-regular);
}

.small {
--empty-state-gap: var(--lp-spacing-400);
--empty-state-content-gap: var(--lp-spacing-200);
--empty-state-heading-font: var(--lp-text-heading-3-semibold);
--empty-state-description-font: var(--lp-text-small-1-regular);
}

.bordered {
border: 1px solid var(--lp-color-border-ui-primary);
border-radius: var(--lp-border-radius-medium);
}

.heading {
grid-area: heading;
font: var(--lp-text-heading-1-semibold) !important;
font: var(--empty-state-heading-font) !important;
margin: 0;
white-space: normal;
overflow: visible;
Expand All @@ -43,18 +64,18 @@

.description {
grid-area: content;
font: var(--lp-text-body-1-regular) !important;
font: var(--empty-state-description-font) !important;
color: var(--lp-color-text-ui-secondary);
margin: 0;
}

.actions {
grid-area: actions;
margin-block-start: var(--lp-size-24);
margin-block-start: var(--empty-state-gap);
}

/* Direct child buttons participate in the grid; nested buttons inside ButtonGroup do not. */
.base > button {
grid-area: actions;
margin-block-start: var(--lp-size-24);
margin-block-start: var(--empty-state-gap);
}
8 changes: 8 additions & 0 deletions packages/components/stories/recipes/EmptyState.stories.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
/*
* Medium and small empty states have no padding of their own, so the docs examples stand in for the
* card, panel, or table body that would normally provide it.
*/
.lp-empty-state-docs-inset {
padding: var(--lp-spacing-700);
}

/*
* Counteract Storybook docs prose color overrides for EmptyState autodocs embeds.
* Target only the description Text (direct child span of EmptyState), not Button labels.
Expand Down
67 changes: 62 additions & 5 deletions packages/components/stories/recipes/EmptyState.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,21 @@ const meta: Meta<typeof EmptyState> = {
],
parameters: {
figma: {
design: 'https://www.figma.com/design/98HKKXL2dTle29ikJ3tzk7/%F0%9F%9A%80-LaunchPad?node-id=10559-2269&m=dev',
design: 'https://www.figma.com/design/98HKKXL2dTle29ikJ3tzk7/%F0%9F%9A%80-LaunchPad?node-id=29527-96176&m=dev',
},
docs: {
description: {
component: `
An empty state displays an illustration and a message, usually when there is no content to show.

Follows the [React Spectrum IllustratedMessage](https://react-spectrum.adobe.com/v3/IllustratedMessage.html) composition pattern.

An empty state should have a \`BadgeIcon\`, a short heading with no ending punctuation, a description
that highlights the value of the feature, and a button that creates the object — or, when it can't be
created in the UI, links to the setup documentation.

\`EmptyState\` scales its \`BadgeIcon\`, heading, description, and buttons together, so set \`size\` on
\`EmptyState\` rather than on the children.
`,
},
},
Expand All @@ -40,7 +47,7 @@ Follows the [React Spectrum IllustratedMessage](https://react-spectrum.adobe.com
},
size: {
control: 'inline-radio',
options: ['large'],
options: ['large', 'medium', 'small'],
},
},
};
Expand All @@ -52,7 +59,57 @@ type Story = StoryObj<typeof EmptyState>;
export const Default: Story = {
render: () => (
<EmptyState>
<BadgeIcon size="large" aria-hidden>
<BadgeIcon aria-hidden>
<Icon name="crown" />
</BadgeIcon>
<Heading>No projects yet</Heading>
<Text>Create a project to get started.</Text>
<Button variant="primary">Create project</Button>
</EmptyState>
),
};

/**
* Use `large` for full-page and main-content empty states. It is the default size and the only one
* with padding of its own.
*/
export const Large: Story = {
render: () => (
<EmptyState size="large" hasBorder>
<BadgeIcon aria-hidden>
<Icon name="crown" />
</BadgeIcon>
<Heading>No projects yet</Heading>
<Text>Create a project to get started.</Text>
<Button variant="primary">Create project</Button>
</EmptyState>
),
};

/**
* Use `medium` inside cards, panels, and table bodies, where `large` would dominate the layout.
* Medium and small carry no padding, so the surrounding container sets it.
*/
export const Medium: Story = {
render: () => (
<EmptyState size="medium" hasBorder className="lp-empty-state-docs-inset">
<BadgeIcon aria-hidden>
<Icon name="crown" />
</BadgeIcon>
<Heading>No projects yet</Heading>
<Text>Create a project to get started.</Text>
<Button variant="primary">Create project</Button>
</EmptyState>
),
};

/**
* Use `small` in dense containers where `medium` still crowds the layout.
*/
export const Small: Story = {
render: () => (
<EmptyState size="small" hasBorder className="lp-empty-state-docs-inset">
<BadgeIcon aria-hidden>
<Icon name="crown" />
</BadgeIcon>
<Heading>No projects yet</Heading>
Expand All @@ -65,7 +122,7 @@ export const Default: Story = {
export const Bordered: Story = {
render: () => (
<EmptyState hasBorder>
<BadgeIcon size="large" aria-hidden>
<BadgeIcon aria-hidden>
<Icon name="crown" />
</BadgeIcon>
<Heading>No projects yet</Heading>
Expand All @@ -78,7 +135,7 @@ export const Bordered: Story = {
export const WithSecondaryAction: Story = {
render: () => (
<EmptyState>
<BadgeIcon size="large" aria-hidden>
<BadgeIcon aria-hidden>
<Icon name="crown" />
</BadgeIcon>
<Heading>No projects yet</Heading>
Expand Down
16 changes: 13 additions & 3 deletions packages/icons/src/BadgeIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HTMLAttributes } from 'react';
import { createContext, useContext } from 'react';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';

Expand Down Expand Up @@ -41,13 +42,22 @@ const badge = cva(styles.base, {

interface BadgeIconProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof badge> {}

/**
* Lets a composing component drive the badge size. As with `IconContext`, the context value takes
* precedence over the prop so the badge cannot drift out of scale with its container.
*/
const BadgeIconContext = createContext<BadgeIconProps>({});

const BadgeIcon = ({ children, className, size = 'medium', variant = 'default', ...props }: BadgeIconProps) => {
const ctx = useContext(BadgeIconContext);
const resolvedSize = ctx.size || size;

return (
<div className={badge({ size, variant, className })} {...props}>
<IconContext.Provider value={{ size }}>{children}</IconContext.Provider>
<div className={badge({ size: resolvedSize, variant, className })} {...props}>
<IconContext.Provider value={{ size: resolvedSize }}>{children}</IconContext.Provider>
</div>
);
};

export { BadgeIcon };
export { BadgeIcon, BadgeIconContext };
export type { BadgeIconProps };
2 changes: 1 addition & 1 deletion packages/icons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type { IconProps } from './Icon';
export type { StatusIconProps } from './StatusIcon';
export type { IconName } from './types';

export { BadgeIcon } from './BadgeIcon';
export { BadgeIcon, BadgeIconContext } from './BadgeIcon';
export { Icon } from './Icon';
export { StatusIcon } from './StatusIcon';
export { icons as iconsNames } from './types';
Loading