From 99079257257533708c18965d5516f3fab44ec9e3 Mon Sep 17 00:00:00 2001 From: Maxi Date: Wed, 2 Sep 2026 08:45:08 +0100 Subject: [PATCH 1/2] feat(empty-state): add reusable absence messaging --- AGENTS.md | 10 +++ README.md | 18 +++++ docs/evidence/issue-5-empty-state-ember.png | Bin 0 -> 9068 bytes docs/evidence/issue-5-empty-state-ocean.png | Bin 0 -> 9756 bytes package.json | 5 ++ scripts/verify-package.mjs | 6 +- src/components/EmptyState.stories.tsx | 73 ++++++++++++++++++++ src/components/EmptyState.tsx | 57 +++++++++++++++ src/components/components.test.tsx | 18 +++++ src/entries/empty-state.ts | 2 + src/index.ts | 3 + src/stories/story.css | 1 + src/styles.css | 18 +++++ tsup.config.ts | 1 + 14 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/issue-5-empty-state-ember.png create mode 100644 docs/evidence/issue-5-empty-state-ocean.png create mode 100644 src/components/EmptyState.stories.tsx create mode 100644 src/components/EmptyState.tsx create mode 100644 src/entries/empty-state.ts diff --git a/AGENTS.md b/AGENTS.md index 9256aa1..db1ef37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,6 +251,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 ae7aab9..7a2f925 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,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` @@ -56,6 +57,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. + +``` + ## Theming The stylesheet bundles Latin JetBrains Mono and Silkscreen font files. CSS custom properties are the theming API; JavaScript configuration is not required. Override variables globally or on any scoped container: 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 0000000000000000000000000000000000000000..0e46dbdc78436b054f8736cd0238bc12052aee6a GIT binary patch literal 9068 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..82690f7db310bd5dc641da38190ad9aa19ae58fb GIT binary patch literal 9756 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 Z2G$r@V_=PeH3rrgSYzPN!hoTy?;j74^ilu- literal 0 HcmV?d00001 diff --git a/package.json b/package.json index baa84af..92721ea 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,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 2a94f89..23ff32f 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -20,8 +20,9 @@ for (const target of Object.values(manifest.exports)) { const esm = await import('@16-bits-design/ui'); 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'); -if (esm.Card !== esmCard.Card || esm.ThemeProvider !== esmTheme.ThemeProvider) { +if (esm.Card !== esmCard.Card || esm.EmptyState !== esmEmptyState.EmptyState || esm.ThemeProvider !== esmTheme.ThemeProvider) { throw new Error('ESM root exports do not resolve to the public component subpaths.'); } @@ -29,8 +30,9 @@ const require = createRequire(import.meta.url); const cjs = require('@16-bits-design/ui'); 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'); -if (cjs.Card !== cjsCard.Card || cjs.ThemeProvider !== cjsTheme.ThemeProvider) { +if (cjs.Card !== cjsCard.Card || cjs.EmptyState !== cjsEmptyState.EmptyState || cjs.ThemeProvider !== cjsTheme.ThemeProvider) { 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..2f8fcbb --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,57 @@ +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 ( +