Skip to content

feat(empty): empty-state component - #624

Merged
nhobes merged 4 commits into
mainfrom
feat/empty
Aug 24, 2026
Merged

nhobes merged 4 commits into
mainfrom
feat/empty

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #603

Summary

<.empty> is the empty-state primitive: what a list, table, inbox or search renders when it has nothing to show. Media, title, description, an actions row and an optional trailing line, centred in a column. Every part is optional, so a title on its own renders fine. Pure markup and CSS - no JS, no hook, no LiveView.JS.

Four variants (default, compact, card, dashed), three sizes (sm/md/lg) that scale media, type and spacing together, and a default media treatment - a muted circle with a dashed ring around hero-inbox, aria-hidden because it is decorative - which the :icon slot replaces.

It drops straight into <.data_table>'s existing :empty slot, so a table's empty state and a page's are the same component. No changes to data_table itself.

Files

  • lib/petal_components/empty.ex - the component
  • lib/petal_components/showcase/empty.ex - four examples (search, first run, four treatments, inside a data table)
  • test/petal/empty_test.exs - 19 tests
  • assets/default.css - appended pc-empty section, light + dark
  • dev.exs - nav entry + /c/empty page with variant / size / actions dials and a live snippet
  • lib/petal_components.ex, lib/petal_components/showcase/registry.ex, CHANGELOG.md

Deviations from the issue's API sketch

None to the API. Attrs, slots, values and class names are exactly as sketched: title, description, variant, size, class, rest, and the :icon / :actions / inner_block slots; classes are pc-empty, pc-empty--{variant}, pc-empty--{size}, pc-empty__media, __title, __description, __actions, __footer.

Two additions inside that shape:

  • pc-empty__media--default is a second class on the media wrapper, only when the :icon slot is empty. The circle-and-dashed-ring treatment has to be removable when a custom illustration comes in, and a modifier is cheaper than a second base class. pc-empty__icon sizes the default glyph per size band.
  • The title renders as a <p> with heading styles, not an h* - as the issue argued. An empty state's outline level depends on its context, and the component cannot know it.

Implementation decisions

  • No role, no aria-live on the root. An empty state is static content. Whatever changed the result set is what should announce the change; a live region here would double-announce inside a data table that already manages its own updates. Covered by a test.
  • card and dashed reuse the panel radius formula (min(calc(var(--pc-radius) * 1.2), 1.25rem)) and the same border tones pc-card--basic uses, so an empty card sits flush next to a real one at any radius setting. No new grays.
  • Sizes are descendant rules (.pc-empty--sm .pc-empty__title etc.) rather than per-part modifier classes - one dial on the root, and a custom :icon slot still gets scaled spacing around it without having to opt in.
  • compact overrides two spacing steps on top of its size band. It lives inside a table frame or a list, where page-level air reads as a gap.
  • The CSS section is appended to assets/default.css; no existing rules were touched or reflowed.

Verification

  • mix format --check-formatted clean; mix credo reports nothing in the new files (pre-existing findings only)
  • mix test: 932 tests, 0 failures, 1 skipped (baseline 913, +19 new)
  • npm test: 157 passing (unchanged - no JS ships with this)
  • Live-verified at /c/empty: every dial exercised, all four variants and three sizes, light and dark, plus the data_table composition rendering inside the table frame

Gaps

Screenshots are not attached to this PR - the light/dark verification was done in the playground but I have no way to upload images to the PR body from the CLI. Happy for them to be added on review, or say the word and I will describe each state in a comment.

<.empty> is the empty-state primitive - what a list, table, inbox or
search renders when it has nothing to show. Pure markup and CSS, no JS,
no hook.

- media, title, description, actions row, trailing line; every part
  optional, centred in a column, DOM order preserved for screen readers
- four variants: default (bare centred column), compact (tighter, for
  table frames and lists), card (the panel surface, same radius formula
  as pc-card), dashed (drop-target look, visual only)
- sizes sm/md/lg scale media, type and spacing together
- default media treatment when no :icon slot - a muted circle with a
  dashed ring around hero-inbox, aria-hidden because it is decorative
- :icon slot replaces it with any icon or illustration
- root stays a plain div: no landmark, no live region. Announcing a
  changed result set belongs to whatever changed it
- drops into <.data_table>'s :empty slot, so a table's empty state and a
  page's are one component
- pc-empty CSS section in assets/default.css, light and dark

Showcase: search, first run, four treatments, inside a data table.
Playground: /c/empty with variant / size / actions dials and a live
snippet.

19 component tests. 932 Elixir (was 913), 157 JS unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.23810% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.46%. Comparing base (60cd97b) to head (cf1dfd5).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
lib/petal_components/showcase/empty.ex 92.85% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #624      +/-   ##
==========================================
+ Coverage   93.43%   93.46%   +0.03%     
==========================================
  Files         119      121       +2     
  Lines        5297     5339      +42     
==========================================
+ Hits         4949     4990      +41     
- Misses        348      349       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Match the repo's dominant style (691 bare vs 370 parenthesised) and the
newest reference component, data_table. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Playground page at /c/empty, captured at 1280px wide. Shows the variant / size / actions dials, the compact search-empty example, and the card first-run example.

Light

Empty state, light mode

Dark

Empty state, dark mode

Images live on the pr-assets branch, which exists only to host PR screenshots — it never merges to main and ships in no Hex release.

nhobes added 2 commits August 13, 2026 09:23
…he aria-hidden slot

Audit round on the bulk-built PR. (1) The appended pc-empty section
landed AFTER the file's last @layer components block closed, so its
rules were unlayered - and unlayered author CSS beats every layer in
Tailwind v4, which would let .pc-empty { items-center } defeat a user's
items-start utility, breaking the pc-* override contract. Wrapped in
its own layer with a comment carrying the reason. (2) The brief's
deliberate title-is-a-p decision (empty states must not enter the
heading outline) is now test-pinned. (3) The :icon slot doc states the
media wrapper is always aria-hidden, so meaningful text goes in
title/description.
# Conflicts:
#	CHANGELOG.md
#	dev.exs
@nhobes
nhobes marked this pull request as ready for review August 24, 2026 02:59
@nhobes
nhobes merged commit 3df161d into main Aug 24, 2026
5 checks passed
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a public, server-rendered empty-state component with configurable variants, sizes, media, actions, and footer content.

  • Exposes <.empty> through the main PetalComponents import surface.
  • Adds light and dark component styling, showcase examples, playground controls, documentation, and rendering tests.
  • Supports composition through the existing data-table empty slot.

Confidence Score: 4/5

The default icon sizing should be made resilient to supported downstream Heroicon CSS before merging.

The component's default glyph uses ordinary component-layer width and height declarations, so supported Tailwind v4 consumer styles can override all three size variants and visibly break the shipped default media treatment.

Files Needing Attention: assets/default.css

Important Files Changed

Filename Overview
lib/petal_components/empty.ex Defines the public empty-state API and optional slot-driven markup; the default media path exposes the new CSS icon-sizing regression.
assets/default.css Adds all empty-state variants and responsive size bands, but its default Heroicon geometry is vulnerable to supported downstream cascade ordering.
test/petal/empty_test.exs Covers markup, modifiers, slots, accessibility attributes, global attributes, and data-table composition, but cannot detect downstream Heroicon cascade behavior.
dev.exs Adds playground navigation, controls, generated snippets, and examples for the component.
lib/petal_components/showcase/empty.ex Adds representative search, first-run, variant, and data-table compositions.
lib/petal_components.ex Adds the component to the intended unqualified public import surface.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Caller[HEEx caller] --> Empty[empty component]
  Empty --> Media{Custom icon supplied?}
  Media -- No --> Default[Decorative inbox media]
  Media -- Yes --> Custom[Slotted illustration]
  Empty --> Text[Title and description]
  Empty --> Actions[Actions slot]
  Empty --> Footer[Trailing content]
  Empty --> Classes[Variant and size classes]
  Classes --> CSS[Default component stylesheet]
Loading

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread assets/default.css
Comment on lines +8477 to +8478
.pc-empty--sm .pc-empty__icon {
@apply w-5 h-5;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Heroicon size loses cascade

When a Tailwind v4 consumer emits Heroicon sizing in the utilities layer or as unlayered CSS, those rules override the ordinary component-layer dimensions for pc-empty__icon, causing the default inbox glyph to render at the wrong size inside its fixed media circle.

Knowledge Base Used:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Component: Empty

2 participants