From 6f96e0522e2c6df0386b1926b42b8636b6f8823a Mon Sep 17 00:00:00 2001 From: Gab Date: Wed, 22 Jul 2026 17:29:12 -0300 Subject: [PATCH 1/4] feat(webkit): standardize EmptyState adornment on an icon-driven featured tile Replace the bundled EmptyStateIllustration with a size-scaled featured-icon tile driven by a new `icon` prop (PrimeIcons/Azion class). The `icon` slot still overrides it, and omitting both prop and slot now renders no adornment. Scale the title/description typography on the same harmonic size ramp. --- .specs/empty-state.md | 46 ++++++++----- .../empty-state/EmptyState.stories.js | 65 ++++++++++++++++--- packages/webkit/catalog.json | 13 +++- .../feedback/empty-state/empty-state.test.ts | 26 +++++--- .../feedback/empty-state/empty-state.vue | 39 ++++++++--- 5 files changed, 145 insertions(+), 44 deletions(-) diff --git a/.specs/empty-state.md b/.specs/empty-state.md index 15df28fd9..24fa3a8fc 100644 --- a/.specs/empty-state.md +++ b/.specs/empty-state.md @@ -3,20 +3,20 @@ name: empty-state category: feedback structure: monolithic status: approved -spec_version: 1 +spec_version: 2 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=446-864 node_id: 446:864 -checksum: 29f1b8b4fe22c0f15d54fbc3611a02685f3d3cf8133ec27d7be66ce12549d023 +checksum: 4ee0f68c74c6046d625e69c006af16abf34e63dfac4b3d569eb229ac0207cd20 created: 2026-06-25 -last_updated: 2026-06-25 +last_updated: 2026-07-22 --- # Empty State — Component Spec ## Purpose -Centered placeholder shown when a resource list or section has no content yet. Stacks a decorative illustration, a title, an optional description, and a consumer-composed actions area (buttons + documentation link) to guide the user toward a first action. Unlike `Message`, which is an inline severity banner, `EmptyState` is a full-region zero-data state and renders either inside a bordered surface card or on a plain transparent background. +Centered placeholder shown when a resource list or section has no content yet. Stacks an optional standardized adornment, a title, an optional description, and a consumer-composed actions area (buttons + documentation link) to guide the user toward a first action. The adornment is a standardized, size-scaled featured-icon tile driven by the `icon` prop; the consumer may instead supply custom content via the `icon` slot, or omit both for no adornment. Unlike `Message`, which is an inline severity banner, `EmptyState` is a full-region zero-data state and renders either inside a bordered surface card or on a plain transparent background. ## Usage @@ -50,7 +50,8 @@ import MiniButton from '@aziontech/webkit/mini-button' |---|---|---|---|---| | `title` | `string` | `—` | yes | Primary heading announcing the empty resource. | | `description` | `string` | `''` | no | Supporting body copy below the title. | -| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | no | Size token; affects the illustration size, the surrounding padding, and the gaps between illustration, text, and actions. | +| `icon` | `string` | `''` | no | PrimeIcons/Azion icon class for the standardized adornment (e.g. `pi pi-inbox`). When set, renders a size-scaled featured-icon tile; when empty and the `icon` slot is unused, no adornment renders. | +| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | no | Size token; scales the whole block on one harmonic ramp — the adornment size, the title and description typography, the surrounding padding, and the gaps between adornment, text, and actions all step up together. | | `bordered` | `boolean` | `false` | no | When true, wraps the content in a bordered surface card; otherwise renders on a transparent background. | ## Events @@ -61,7 +62,7 @@ import MiniButton from '@aziontech/webkit/mini-button' | Slot | Scope | Notes | |---|---|---| -| `icon` | — | Decorative illustration centered above the title; rendered inside an `aria-hidden` container. Defaults to the bundled `EmptyStateIllustration` sub-component when not provided. | +| `icon` | — | Custom adornment centered above the title, rendered inside an `aria-hidden` container. Overrides the `icon` prop's featured-icon tile. When neither this slot nor the `icon` prop is provided, no adornment renders. | | `actions` | — | Action area below the description; consumer composes buttons and the documentation link. | ## States @@ -78,23 +79,36 @@ _none_ | Region | Token (DESIGN.md) | |---|---| -| title typography | `.text-body-sm` | -| description typography | `.text-body-xs` | +| title typography | per-`size` — see harmonic scale below | +| description typography | per-`size` — see harmonic scale below | | surface (bordered) | `var(--bg-surface)` | | border (bordered) | `var(--border-default)` | | title text | `var(--text-default)` | | description text | `var(--text-muted)` | +| adornment tile surface | `var(--bg-surface)` | +| adornment tile border | `var(--border-default)` | +| adornment decorative layers | `var(--bg-canvas)` · `var(--border-strong)` | +| adornment shape | `var(--shape-card)` · `var(--shape-elements)` | +| adornment icon | `var(--text-default)` | | shape (bordered) | `var(--shape-button)` | -| spacing (padding, medium) | `var(--spacing-8)` (x) · `var(--spacing-12)` (y) | -| spacing (stack gap, medium) | `var(--spacing-6)` | | spacing (title gap) | `var(--spacing-xs)` | | spacing (actions gap) | `var(--spacing-xs)` | +### Harmonic scale (per `size`) + +Every axis steps up one rung per size on a single harmonic ramp. The title climbs the **heading** scale (`heading` tokens only — never `body`); the description climbs the **body** scale one rung below it, so the pair stays in proportion at every size. + +| Axis | `small` | `medium` | `large` | +|---|---|---|---| +| title typography | `.text-heading-xxs` (14px) | `.text-heading-sm` (14 → 18px) | `.text-heading-md` (16 → 24px) | +| description typography | `.text-body-xs` (12px) | `.text-body-sm` (14px) | `.text-body-md` (16px) | +| adornment tile | `size-8` (32px) | `size-10` (40px) | `size-12` (48px) | +| padding (x · y) | `var(--spacing-6)` · `var(--spacing-8)` | `var(--spacing-8)` · `var(--spacing-12)` | `var(--spacing-12)` · `var(--spacing-16)` | +| stack gap | `var(--spacing-4)` | `var(--spacing-6)` | `var(--spacing-8)` | + ## Theme gaps -| Figma variable | Temporary primitive | Follow-up | -|---|---|---| -| `Typography/Heading/xss` (title, 14px regular) | `.text-body-sm` (14px regular — size/weight match) | `TODO: catalogar` — naming-only gap; no heading-named token exists at 14px. | +_none_ — the title now uses `heading` tokens at every size (`.text-heading-xxs` is 14px regular, matching the Figma `Typography/Heading/xss` title), so the previous body-token workaround is retired. ## Accessibility (WCAG 2.1 AA) @@ -107,8 +121,10 @@ _none_ ## Stories (Storybook) -- Default -- Sizes — composite story rendering every `size` value side by side. +- Default — renders the standardized featured-icon tile via the `icon` prop. +- Sizes — composite story rendering every `size` value side by side (adornment scales with `size`). +- Icon — the standardized featured-icon tile adornment driven by the `icon` prop; justified because the tile is the primary adornment path and must be documented distinctly from custom slot content. +- NoAdornment — omits both the `icon` prop and the `icon` slot; justified because "no adornment" is a distinct rendered state (the adornment container does not render) that the default illustration previously masked. - Bordered — state story (args delta `bordered: true`); justified because the bordered surface card is a distinct top-level Figma frame (446:864) that materially changes the rendered container. ## Constraints — DO NOT diff --git a/apps/storybook/src/stories/components/feedback/empty-state/EmptyState.stories.js b/apps/storybook/src/stories/components/feedback/empty-state/EmptyState.stories.js index 59873f0dd..37b3b9498 100644 --- a/apps/storybook/src/stories/components/feedback/empty-state/EmptyState.stories.js +++ b/apps/storybook/src/stories/components/feedback/empty-state/EmptyState.stories.js @@ -39,7 +39,7 @@ const meta = { docs: { description: { component: - 'A full-region placeholder shown when a list or section has no content yet. It stacks a decorative illustration (the bundled `EmptyStateIllustration` by default), a title, an optional description, and a consumer-composed actions area, and renders on a plain background or inside a bordered surface card (`bordered`).' + 'A full-region placeholder shown when a list or section has no content yet. It stacks an optional standardized featured-icon tile (driven by the `icon` prop), a title, an optional description, and a consumer-composed actions area, and renders on a plain background or inside a bordered surface card (`bordered`). Supply custom adornment content via the `icon` slot, or omit both `icon` prop and slot for no adornment.' }, canvas: { sourceState: 'shown' } } @@ -59,25 +59,25 @@ const meta = { control: 'select', options: ['small', 'medium', 'large'], description: - 'Size token; affects the illustration size, the surrounding padding, and the gaps between illustration, text, and actions.', + 'Size token; affects the adornment size, the surrounding padding, and the gaps between adornment, text, and actions.', table: { category: 'props', type: { summary: "'small' | 'medium' | 'large'" }, defaultValue: { summary: "'medium'" } } }, + icon: { + control: 'text', + description: + 'PrimeIcons/Azion icon class for the standardized adornment; renders a size-scaled featured-icon tile. The `icon` slot overrides it; omit both for no adornment.', + table: { category: 'props', type: { summary: 'string' }, defaultValue: { summary: "''" } } + }, bordered: { control: 'boolean', description: 'When true, wraps the content in a bordered surface card; otherwise renders on a transparent background.', table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } }, - icon: { - control: false, - description: - 'Decorative illustration centered above the title; rendered inside an aria-hidden container. Defaults to the bundled EmptyStateIllustration.', - table: { category: 'slots', type: { summary: 'slot' } } - }, actions: { control: false, description: @@ -88,6 +88,7 @@ const meta = { args: { title: 'No resource yet', description: 'Get started by creating your first resource.', + icon: 'pi pi-inbox', size: 'medium', bordered: false } @@ -108,6 +109,7 @@ ${ACTIONS_MARKUP} const DEFAULT_MARKUP = ` ${ACTIONS_MARKUP} ` @@ -118,7 +120,7 @@ export const Default = { parameters: { docs: { description: { - story: 'Default empty state: the illustration tile, title, description, and actions.' + story: 'Default empty state: the standardized featured-icon tile, title, description, and actions.' }, source: { code: toSfc(IMPORTS, DEFAULT_MARKUP) } } @@ -132,6 +134,7 @@ const SIZES_TEMPLATE = `
${ACTIONS_MARKUP} @@ -149,9 +152,53 @@ export const Sizes = { } } +const ICON_MARKUP = ` +${ACTIONS_MARKUP} +` + +/** @type {import('@storybook/vue3').StoryObj} */ +export const Icon = { + args: { icon: 'pi pi-inbox' }, + render: Template, + parameters: { + docs: { + description: { + story: 'The standardized featured-icon tile adornment, driven by the `icon` prop.' + }, + source: { code: toSfc(IMPORTS, ICON_MARKUP) } + } + } +} + +const NO_ADORNMENT_MARKUP = ` +${ACTIONS_MARKUP} +` + +/** @type {import('@storybook/vue3').StoryObj} */ +export const NoAdornment = { + args: { icon: '' }, + render: Template, + parameters: { + docs: { + description: { + story: 'No adornment: neither the `icon` prop nor the `icon` slot is provided, so the adornment container does not render.' + }, + source: { code: toSfc(IMPORTS, NO_ADORNMENT_MARKUP) } + } + } +} + const BORDERED_MARKUP = ` ${ACTIONS_MARKUP} diff --git a/packages/webkit/catalog.json b/packages/webkit/catalog.json index e1bc72c2b..b1e4c7e4b 100644 --- a/packages/webkit/catalog.json +++ b/packages/webkit/catalog.json @@ -4242,12 +4242,19 @@ "required": "no", "doc": "Supporting body copy below the title." }, + { + "name": "icon", + "type": "string", + "default": "''", + "required": "no", + "doc": "PrimeIcons/Azion icon class for the standardized adornment (e.g. `pi pi-inbox`). When set, renders a size-scaled featured-icon tile; when empty and the `icon` slot is unused, no adornment renders." + }, { "name": "size", "type": "'small' | 'medium' | 'large'", "default": "'medium'", "required": "no", - "doc": "Size token; affects the illustration size, the surrounding padding, and the gaps between illustration, text, and actions." + "doc": "Size token; scales the whole block on one harmonic ramp — the adornment size, the title and description typography, the surrounding padding, and the gaps between adornment, text, and actions all step up together." }, { "name": "bordered", @@ -4261,7 +4268,7 @@ { "name": "icon", "scope": "—", - "notes": "Decorative illustration centered above the title; rendered inside an `aria-hidden` container. Defaults to the bundled `EmptyStateIllustration` sub-component when not provided." + "notes": "Custom adornment centered above the title, rendered inside an `aria-hidden` container. Overrides the `icon` prop's featured-icon tile. When neither this slot nor the `icon` prop is provided, no adornment renders." }, { "name": "actions", @@ -4269,7 +4276,7 @@ "notes": "Action area below the description; consumer composes buttons and the documentation link." } ], - "purpose": "Centered placeholder shown when a resource list or section has no content yet. Stacks a decorative illustration, a title, an optional description, and a consumer-composed actions area (buttons + documentation link) to guide the user toward a first action. Unlike `Message`, which is an inline severity banner, `EmptyState` is a full-region zero-data state and renders either inside a bordered surface card or on a plain transparent background." + "purpose": "Centered placeholder shown when a resource list or section has no content yet. Stacks an optional standardized adornment, a title, an optional description, and a consumer-composed actions area (buttons + documentation link) to guide the user toward a first action. The adornment is a standardized, size-scaled featured-icon tile driven by the `icon` prop; the consumer may instead supply custom content via the `icon` slot, or omit both for no adornment. Unlike `Message`, which is an inline severity banner, `EmptyState` is a full-region zero-data state and renders either inside a bordered surface card or on a plain transparent background." }, "status-indicator": { "import": "@aziontech/webkit/status-indicator", diff --git a/packages/webkit/src/components/feedback/empty-state/empty-state.test.ts b/packages/webkit/src/components/feedback/empty-state/empty-state.test.ts index afcf8d793..39df81209 100644 --- a/packages/webkit/src/components/feedback/empty-state/empty-state.test.ts +++ b/packages/webkit/src/components/feedback/empty-state/empty-state.test.ts @@ -28,7 +28,7 @@ describe('EmptyState', () => { it('honours a consumer-supplied data-testid across the whole subtree', () => { const { getByTestId } = within( render(EmptyState, { - props: { title: 'Empty' }, + props: { title: 'Empty', icon: 'pi pi-inbox' }, attrs: { 'data-testid': 'custom-empty' } }).container ) @@ -51,19 +51,27 @@ describe('EmptyState', () => { expect(withoutDesc.queryByTestId('feedback-empty-state__description')).toBeNull() }) - it('marks the icon container aria-hidden and renders the default illustration', () => { - const { getByTestId } = within(render(EmptyState, { props: { title: 'Empty' } }).container) + it('renders no adornment when neither the icon prop nor the icon slot is provided', () => { + const { queryByTestId } = within(render(EmptyState, { props: { title: 'Empty' } }).container) + + expect(queryByTestId('feedback-empty-state__icon')).toBeNull() + }) + + it('renders the standardized featured-icon tile when the icon prop is set', () => { + const { getByTestId } = within( + render(EmptyState, { props: { title: 'Empty', icon: 'pi pi-inbox' } }).container + ) const icon = getByTestId('feedback-empty-state__icon') expect(icon.getAttribute('aria-hidden')).toBe('true') - // Default slot renders the bundled illustration (an svg/element inside the tile). - expect(icon.children.length).toBeGreaterThan(0) + // The tile carries the consumer-supplied icon glyph. + expect(icon.querySelector('i.pi.pi-inbox')).toBeTruthy() }) - it('renders custom icon slot content in place of the default illustration', () => { + it('renders custom icon slot content, overriding the icon prop tile', () => { const { getByTestId } = within( render(EmptyState, { - props: { title: 'Empty' }, + props: { title: 'Empty', icon: 'pi pi-inbox' }, slots: { icon: 'glyph' } }).container ) @@ -71,6 +79,8 @@ describe('EmptyState', () => { const icon = getByTestId('feedback-empty-state__icon') expect(icon.getAttribute('aria-hidden')).toBe('true') expect(getByTestId('my-icon').textContent).toBe('glyph') + // Slot wins: the prop's default tile glyph is not rendered. + expect(icon.querySelector('i.pi.pi-inbox')).toBeNull() }) it('renders the actions region only when the actions slot is supplied', () => { @@ -109,7 +119,7 @@ describe('EmptyState', () => { 'mirrors size=%s onto data-size of the root and the icon tile', (size) => { const { getByTestId } = within( - render(EmptyState, { props: { title: 'Empty', size } }).container + render(EmptyState, { props: { title: 'Empty', size, icon: 'pi pi-inbox' } }).container ) expect(getByTestId('feedback-empty-state').getAttribute('data-size')).toBe(size) expect(getByTestId('feedback-empty-state__icon').getAttribute('data-size')).toBe(size) diff --git a/packages/webkit/src/components/feedback/empty-state/empty-state.vue b/packages/webkit/src/components/feedback/empty-state/empty-state.vue index 21f9bcb53..cc2f348a6 100644 --- a/packages/webkit/src/components/feedback/empty-state/empty-state.vue +++ b/packages/webkit/src/components/feedback/empty-state/empty-state.vue @@ -1,9 +1,7 @@