diff --git a/AGENTS.md b/AGENTS.md index af6543f..fa6431f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,6 +291,16 @@ Use `Dialog` for focused confirmation or interruption. Tones are `primary` and ` - Do not open dialogs for information that can remain inline. - Do not add a second focus trap, backdrop, escape listener, or portal layer. +### EmptyState + +Use `EmptyState` to explain an absence and offer the next useful action. + +- Distinguish the reason for absence in the copy: no data yet, no filtered matches, or no configuration. +- Set `headingLevel` to preserve the surrounding document hierarchy. +- Keep the description concrete and within the component's readable measure. +- Provide one focused action when the user can resolve the absence. +- Use Alert for failures; an error is not an empty state. + ## Content quality - Prefer concrete labels: “Create project” over “Submit,” “Delete report” over “Confirm.” diff --git a/README.md b/README.md index a235850..c3c7f0b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ All JavaScript entry points ship ESM, CommonJS, and TypeScript declarations. Exp - `@16-bits-design/ui/button` - `@16-bits-design/ui/card` - `@16-bits-design/ui/dialog` +- `@16-bits-design/ui/empty-state` - `@16-bits-design/ui/input` - `@16-bits-design/ui/meter` - `@16-bits-design/ui/select` @@ -60,6 +61,23 @@ All JavaScript entry points ship ESM, CommonJS, and TypeScript declarations. Exp - `@16-bits-design/ui/typography` - `@16-bits-design/ui/styles.css` +### EmptyState + +Use `EmptyState` to explain an absence and offer the next useful action. Descriptions stay within a readable measure, the default pixel motif follows the active theme, and `headingLevel` preserves the surrounding document hierarchy. + +```tsx +import { EmptyState } from '@16-bits-design/ui/empty-state'; +import { Button } from '@16-bits-design/ui/button'; + +Create deployment} +> + Create a deployment to connect a repository and start tracking releases. + +``` + ### Spinner Use `Spinner` for indeterminate waits. Its required label is announced through a polite live region, can remain visible or be visually hidden, and the stepped animation becomes a static bar when reduced motion is requested. diff --git a/docs/evidence/issue-5-empty-state-ember.png b/docs/evidence/issue-5-empty-state-ember.png new file mode 100644 index 0000000..0e46dbd Binary files /dev/null and b/docs/evidence/issue-5-empty-state-ember.png differ diff --git a/docs/evidence/issue-5-empty-state-ocean.png b/docs/evidence/issue-5-empty-state-ocean.png new file mode 100644 index 0000000..82690f7 Binary files /dev/null and b/docs/evidence/issue-5-empty-state-ocean.png differ diff --git a/package.json b/package.json index f693751..21ea813 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,11 @@ "import": "./dist/dialog.js", "require": "./dist/dialog.cjs" }, + "./empty-state": { + "types": "./dist/empty-state.d.ts", + "import": "./dist/empty-state.js", + "require": "./dist/empty-state.cjs" + }, "./input": { "types": "./dist/input.d.ts", "import": "./dist/input.js", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 5a65a56..512a176 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -21,11 +21,12 @@ const esm = await import('@16-bits-design/ui'); const esmAlert = await import('@16-bits-design/ui/alert'); const esmCard = await import('@16-bits-design/ui/card'); const esmTheme = await import('@16-bits-design/ui/theme'); +const esmEmptyState = await import('@16-bits-design/ui/empty-state'); const esmSpinner = await import('@16-bits-design/ui/spinner'); const esmSegmented = await import('@16-bits-design/ui/segmented'); const esmTable = await import('@16-bits-design/ui/table'); -if (esm.Alert !== esmAlert.Alert || esm.Card !== esmCard.Card || esm.Segmented !== esmSegmented.Segmented || esm.Spinner !== esmSpinner.Spinner || esm.ThemeProvider !== esmTheme.ThemeProvider || esm.Table !== esmTable.Table) { +if (esm.Alert !== esmAlert.Alert || esm.Card !== esmCard.Card || esm.EmptyState !== esmEmptyState.EmptyState || esm.Segmented !== esmSegmented.Segmented || esm.Spinner !== esmSpinner.Spinner || esm.ThemeProvider !== esmTheme.ThemeProvider || esm.Table !== esmTable.Table) { throw new Error('ESM root exports do not resolve to the public component subpaths.'); } @@ -34,11 +35,12 @@ const cjs = require('@16-bits-design/ui'); const cjsAlert = require('@16-bits-design/ui/alert'); const cjsCard = require('@16-bits-design/ui/card'); const cjsTheme = require('@16-bits-design/ui/theme'); +const cjsEmptyState = require('@16-bits-design/ui/empty-state'); const cjsSpinner = require('@16-bits-design/ui/spinner'); const cjsSegmented = require('@16-bits-design/ui/segmented'); const cjsTable = require('@16-bits-design/ui/table'); -if (cjs.Alert !== cjsAlert.Alert || cjs.Card !== cjsCard.Card || cjs.Segmented !== cjsSegmented.Segmented || cjs.Spinner !== cjsSpinner.Spinner || cjs.ThemeProvider !== cjsTheme.ThemeProvider || cjs.Table !== cjsTable.Table) { +if (cjs.Alert !== cjsAlert.Alert || cjs.Card !== cjsCard.Card || cjs.EmptyState !== cjsEmptyState.EmptyState || cjs.Segmented !== cjsSegmented.Segmented || cjs.Spinner !== cjsSpinner.Spinner || cjs.ThemeProvider !== cjsTheme.ThemeProvider || cjs.Table !== cjsTable.Table) { throw new Error('CommonJS root exports do not resolve to the public component subpaths.'); } diff --git a/src/components/EmptyState.stories.tsx b/src/components/EmptyState.stories.tsx new file mode 100644 index 0000000..5521f11 --- /dev/null +++ b/src/components/EmptyState.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Button } from './Button'; +import { EmptyState } from './EmptyState'; + +const meta = { + title: 'Components/EmptyState', + component: EmptyState, + tags: ['autodocs'], + args: { + title: 'No deployments yet', + headingLevel: 3, + children: 'Create a deployment to connect a repository and start tracking releases.', + }, + argTypes: { + headingLevel: { control: 'inline-radio', options: [2, 3, 4] }, + action: { control: false }, + illustration: { control: false }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => Create deployment} />, +}; + +export const FilteredResults: Story = { + render: () => ( + + No deployments match the current owner and status filters. Adjust the filters to see more results. + + ), +}; + +export const CustomIllustration: Story = { + render: () => ( + + ), +}; + +export const InContext: Story = { + render: () => ( +
+

Deployment history

+ Create deployment} + > + Create a deployment to connect a repository and start tracking releases. + +
+ ), +}; + +export const NarrowViewport: Story = { + render: () => ( +
+ Clear filters} + > + Adjust the owner and status filters to see deployments from the rest of the workspace. + +
+ ), +}; diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..8eb3e78 --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from 'react'; +import type { HTMLAttributes, ReactNode } from 'react'; +import { cn } from '../lib/cn'; + +export type EmptyStateHeadingLevel = 2 | 3 | 4; + +export type EmptyStateProps = Omit, 'title'> & { + title: ReactNode; + headingLevel?: EmptyStateHeadingLevel; + illustration?: ReactNode; + action?: ReactNode; +}; + +function PixelMotif() { + return ( +