From f19e91b985180af8516742d2166411a55765998c Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 21 Jul 2026 20:09:48 +0200 Subject: [PATCH] =?UTF-8?q?uix(empty-state):=20F1.2=20=C2=B7=20componente?= =?UTF-8?q?=20base=20display=20(morfo+eidos)=20al=20suelo=20del=20dossier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primera pieza F1 del plan blocks (PLAN-blocks.md; alcance E-2 = suelo de paridad, dossier §P3). Ruta de 9 fases completa: - morfo: 5 partes display (provider/media/title/description/actions), scope ['eidos'], 0 eventos con justificacion pasiva (patron renderEmptyState de las refs headless), Title role:'heading', texts.label - langs: components.empty-state.label (es/en) — el indice tambien recoge la retirada foranea del import de words (inseparable por staging de archivo; coherente con la migracion palabras ya enviada; words.ts sigue en su arbol) - eidos: compound EmptyState + Media(kind icon|media, placa 2x glifo) + Title(level 2-6, default h3 — modelo Atlaskit, tamano visual desacoplado) + Description(measure 45ch) + Actions(label -> role=group +aria-label, buttonGroupLabel); recipe sobre el bundle --size-* (titulo un paso discreto arriba; sm=in-collection, lg=hero); tokens publicos minimos (gap/actions-gap/media-bg/media-fg/media-radius/description-measure) - demo v2 9 tabs (harness: SystemAxes/MotionPanel/SemaPanel; snippet con paridad; escena in-collection via Card) + entrada nav (grupo Status) - README: Baseline · Comparativa (shadcn/Chakra/Atlaskit/AntD/Polaris) · Decisiones · Passive justification · Gaps con disposicion Verificacion: component:audit PASS · eidos-lint 5 morfo-backed + 4 eidos-only sancionados · morfo:check + morfo:vocabulary verdes · recipe-css-contract/api-contract/visual-attrs 37/37 · svelte-check 76E/51W = baseline exacto (cero regresion) · navegador claro Y oscuro por estilos computados (titulo 18->24px, placa 40->64px, chips vivos, cero errores de consola). Co-Authored-By: Claude Fable 5 --- docs/process/PLAN-blocks.md | 15 +- .../eidos/components/empty-state/README.md | 84 +++ .../empty-state/empty-state-actions.svelte | 22 + .../empty-state-description.svelte | 13 + .../empty-state/empty-state-media.svelte | 16 + .../empty-state/empty-state-title.svelte | 14 + .../components/empty-state/empty-state.css | 103 +++ .../components/empty-state/empty-state.svelte | 31 + src/uix/eidos/components/empty-state/index.ts | 43 ++ src/uix/eidos/components/empty-state/types.ts | 58 ++ src/uix/eidos/generated/base.css | 6 + src/uix/eidos/lib/recipes/base.ts | 14 + src/uix/langs/components/empty-state.ts | 8 + src/uix/langs/components/index.ts | 4 +- src/uix/morfo/components/empty-state.ts | 96 +++ web/routes/uix/+layout@.svelte | 1 + .../uix/components/empty-state/+page.svelte | 623 ++++++++++++++++++ 17 files changed, 1148 insertions(+), 3 deletions(-) create mode 100644 src/uix/eidos/components/empty-state/README.md create mode 100644 src/uix/eidos/components/empty-state/empty-state-actions.svelte create mode 100644 src/uix/eidos/components/empty-state/empty-state-description.svelte create mode 100644 src/uix/eidos/components/empty-state/empty-state-media.svelte create mode 100644 src/uix/eidos/components/empty-state/empty-state-title.svelte create mode 100644 src/uix/eidos/components/empty-state/empty-state.css create mode 100644 src/uix/eidos/components/empty-state/empty-state.svelte create mode 100644 src/uix/eidos/components/empty-state/index.ts create mode 100644 src/uix/eidos/components/empty-state/types.ts create mode 100644 src/uix/langs/components/empty-state.ts create mode 100644 src/uix/morfo/components/empty-state.ts create mode 100644 web/routes/uix/components/empty-state/+page.svelte diff --git a/docs/process/PLAN-blocks.md b/docs/process/PLAN-blocks.md index faac034a9..029025bb0 100644 --- a/docs/process/PLAN-blocks.md +++ b/docs/process/PLAN-blocks.md @@ -31,7 +31,7 @@ | Fase | Contenido | Estado | |---|---|---| | **F0** | Infraestructura del tier: doctrina + alias + guard + rutas demo | **HECHA 2026-07-21** (F0.1–F0.7; cross-ref en `comparison.md` omitido a propósito — sin aporte hasta que exista catálogo) | -| **F1** | **8** componentes base del CANON que los blocks necesitan (7 + `nav-tree` por E-1) | pendiente | +| **F1** | **8** componentes base del CANON que los blocks necesitan (7 + `nav-tree` por E-1) | en curso — **F1.2 `empty-state` HECHA 2026-07-21** (PASS; ver registro) | | **F2** | Blocks de sitio (**14**: 10 + banner·team·contact·content-section por E-3) | pendiente (F2 solo requiere F1.1) | | **F3** | Blocks de aplicación (10) | pendiente | | **F4** | Blocks de docs (3) | pendiente | @@ -829,3 +829,16 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud` propia en `next-features.md` §9; **E-5** ⌘K = listener app-land (nota en F4.1). Doctrina (`architecture/blocks.md` promotion path) y galería actualizadas. +- 2026-07-21 — **F1.2 `empty-state` HECHA** (ruta de 9 fases completa, + suelo E-2 del dossier §P3): morfo display 5 partes (`scope: ['eidos']`, + 0 eventos justificados, Title `role:'heading'`) + langs `label` + eidos + compound (`Media kind=icon|media` con placa 2× glifo · `Title level` + 2–6 default h3, modelo Atlaskit · `Description` measure 45ch · + `Actions label` → role=group, buttonGroupLabel) + recipe sobre el bundle + `--size-*` (título un paso discreto arriba) + demo v2 de 9 tabs + README + (Comparativa 5 refs · Passive justification · Gaps con disposición). + Verificado: `component:audit` **PASS** · eidos-lint 5 morfo-backed + 4 + eidos-only sancionados · `svelte-check` 76E/51W = baseline exacto · + navegador claro Y oscuro por estilos computados (título 18→24px, placa + 40→64px, chips vivos). Siguiente: **F1.3 `result`** (comparte esqueleto; + fase 0 contra dossier §P3). diff --git a/src/uix/eidos/components/empty-state/README.md b/src/uix/eidos/components/empty-state/README.md new file mode 100644 index 000000000..20b5c7931 --- /dev/null +++ b/src/uix/eidos/components/empty-state/README.md @@ -0,0 +1,84 @@ +# EmptyState + +Passive "nothing here yet / no results" display surface: media (icon plate +or free illustration) + title + description + actions, centered. Built +2026-07-21 as F1.2 of the blocks initiative (`docs/process/PLAN-blocks.md`); +reference floor fixed by the research dossier +(`docs/process/RESEARCH-blocks-references.md` §P3) under the E-2 rule +(parity floor = v1). + +## Baseline + +- **Classification**: passive display (eidos-native, `scope: ['eidos']` — + same membership as `banner`/`box`). No soma provider, no sema pack. +- **Anatomy**: `EmptyState` (root, `data-size` sm|md|lg) → `.Media` + (`data-kind` icon|media) + `.Title` (real ``, default h3) + + `.Description` (`

`, measure-capped) + `.Actions` (consumer `Button`s, + optional `label` → `role="group"` + `aria-label`). +- **0 events, justified**: appearing/disappearing is a fact of the + collection that renders the empty (table/list/select/command), not of + this surface — announcing belongs to the collection's live region. + Headless references model empties the same way (React Aria + `renderEmptyState`, HeroUI `emptyContent`). The composed Buttons own + their own sema. +- **Sizing**: `sm` = compact in-collection density · `md` = panel default + · `lg` = full-page hero. Typography rides the `--size-{k}-*` bundle; + the title reads ONE discrete bundle size above the component size + (visual hierarchy decoupled from the semantic `level`). + +## Comparativa + +| Ref | Anatomy | What we adopted | What we didn't | +| ---------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| shadcn/ui `Empty` | Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription/EmptyContent | `Media variant="icon"` → our `kind`; part naming | The `EmptyHeader` grouping wrapper (root centers directly; one less part) | +| Chakra v3 `EmptyState` | Root/Content/Indicator/Title/Description | `size` sm/md/lg axis | The `Content` wrapper (same reason) | +| Atlaskit `EmptyState` | props-only (header/description/actions) | `headingLevel` model (our `level`, default h3 vs their h4) + `buttonGroupLabel` (our `Actions label`) + image clamp doctrine | `width narrow\|wide` (our measure token covers it); `isLoading` spinner-beside-actions (defer until a real consumer) | +| AntD `Empty` | image/description/footer | The compact in-collection concept (their SIMPLE preset ≈ our `sm`) | `ConfigProvider.renderEmpty` global injection (tracked gap below) | +| Polaris `EmptyState` | heading/image/action/secondaryAction/footerContent | Content doctrine: ONE primary action, verb-first; reason + next step | Required image (ours optional); `fullWidth/imageContained` | + +## Decisiones + +- **Title is a real heading** (Atlaskit model), default `h3`, levels 2–6. + Rationale: empties often ARE the section content and should sit in the + document outline; the visual size is decoupled (rides `size`), so + choosing the right level costs nothing visually. (Callout will decide + the opposite — its title is NOT a heading — because callouts are inline + asides; the difference is deliberate.) +- **No `color`/intent prop**: a neutral display surface. Error-flavored + empties ("no results" after a failed search) are still neutral surfaces + with recovery actions — terminal flow states with evaluative status + belong to `Result` (F1.3), which shares this skeleton and adds + `data-status`. +- **Media plate = 2× glyph** (`/* literal: */` in the recipe): keeps the + tinted plate readable at every size without a parallel size table. +- **`sm` exists for collections**: Table/Select/Command empties compose + `` inside their own empty slot. +- **Public tokens intentionally small**: gap · actions-gap · media-bg · + media-fg · media-radius · description-measure (45ch). Density stays + internal on `data-size`. + +## Passive justification + +EmptyState declares 0 events because it is a passive display surface: +appearing/disappearing is a fact of the COLLECTION that renders it +(table/list/select/command), not of this surface — announcing it belongs to +the collection's live region, mirroring how headless references model +empties as a render slot of collections (React Aria `renderEmptyState` +stamping `[data-empty]`, HeroUI `emptyContent`). The composed `Button`s in +`.Actions` are the interactive elements and own their own behavior and +sema. No keyboard contract, no state machine, no ARIA obligations beyond +the optional named actions group — same passive class as `banner`/`box`. + +## Sema events + +None (see Passive justification). `SemaPanel` in the demo renders the +justified empty state. + +## Gaps + +| Gap | Disposition | +| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Collection integration (AntD `renderEmpty`-style: Table/Select/Command render an EmptyState automatically when dataless) | **diferir** — belongs to each collection's empty slot, not to this component; wire when the data-table block (F3.3) lands and fixes the pattern | +| `isLoading` action spinner (Atlaskit) | **diferir** until a real consumer asks; Buttons already have their own loading treatment | +| Illustration presets (AntD ships two images) | **descartar** — media is a free slot by design; apps bring their art (no baked visuals) | +| Global default media per app (theme-level) | **diferir** — candidate for a `recipes.empty-state.*` extension or app-level wrapper; no consumer yet | diff --git a/src/uix/eidos/components/empty-state/empty-state-actions.svelte b/src/uix/eidos/components/empty-state/empty-state-actions.svelte new file mode 100644 index 000000000..0fdbc5422 --- /dev/null +++ b/src/uix/eidos/components/empty-state/empty-state-actions.svelte @@ -0,0 +1,22 @@ + + +

+ {@render children?.()} +
diff --git a/src/uix/eidos/components/empty-state/empty-state-description.svelte b/src/uix/eidos/components/empty-state/empty-state-description.svelte new file mode 100644 index 000000000..ea10d3b1f --- /dev/null +++ b/src/uix/eidos/components/empty-state/empty-state-description.svelte @@ -0,0 +1,13 @@ + + +

+ {@render children?.()} +

diff --git a/src/uix/eidos/components/empty-state/empty-state-media.svelte b/src/uix/eidos/components/empty-state/empty-state-media.svelte new file mode 100644 index 000000000..08dfc41a4 --- /dev/null +++ b/src/uix/eidos/components/empty-state/empty-state-media.svelte @@ -0,0 +1,16 @@ + + +
+ {@render children?.()} +
diff --git a/src/uix/eidos/components/empty-state/empty-state-title.svelte b/src/uix/eidos/components/empty-state/empty-state-title.svelte new file mode 100644 index 000000000..cccc4180d --- /dev/null +++ b/src/uix/eidos/components/empty-state/empty-state-title.svelte @@ -0,0 +1,14 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/empty-state/empty-state.css b/src/uix/eidos/components/empty-state/empty-state.css new file mode 100644 index 000000000..a2b027fc2 --- /dev/null +++ b/src/uix/eidos/components/empty-state/empty-state.css @@ -0,0 +1,103 @@ +/* + * EmptyState recipe — passive "nothing here / no results" surface. + * Centered column: media (icon box or free media) + title + description + + * actions. `data-size` (sm | md | lg) drives density: `sm` is the compact + * in-collection empty (table/select/command), `md` the panel default, + * `lg` the full-page hero. + * + * `--empty-state-*` tokens are public overrides (recipes.empty-state.*); + * `--_empty-state-*` are internal recipe-local resolutions. Typography + * rides the `--size-{k}-*` bundle; the title steps ONE discrete size up + * from the component size (visual hierarchy without a heading-level + * coupling — level is semantic, size is visual). + */ + +[data-empty-state] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: var(--empty-state-gap, var(--space-3)); + padding-block: var(--_empty-state-padding-block); + padding-inline: var(--_empty-state-padding-inline); + min-inline-size: 0; + box-sizing: border-box; +} + +/* ── Size ──────────────────────────────────────────────────────────── */ + +[data-empty-state][data-size='sm'] { + --_empty-state-padding-block: var(--space-4); + --_empty-state-padding-inline: var(--space-3); + --_empty-state-title-font-size: var(--size-md-font-size); + --_empty-state-body-font-size: var(--size-sm-font-size); + --_empty-state-media-glyph: var(--size-sm-icon-size); +} + +[data-empty-state][data-size='md'] { + --_empty-state-padding-block: var(--space-8); + --_empty-state-padding-inline: var(--space-4); + --_empty-state-title-font-size: var(--size-lg-font-size); + --_empty-state-body-font-size: var(--size-md-font-size); + --_empty-state-media-glyph: var(--size-lg-icon-size); +} + +[data-empty-state][data-size='lg'] { + --_empty-state-padding-block: var(--space-12); + --_empty-state-padding-inline: var(--space-5); + --_empty-state-title-font-size: var(--size-xl-font-size); + --_empty-state-body-font-size: var(--size-md-font-size); + --_empty-state-media-glyph: var(--size-xl-icon-size); +} + +/* ── Media ─────────────────────────────────────────────────────────── */ + +[data-empty-state-media] { + display: flex; + align-items: center; + justify-content: center; + color: var(--empty-state-media-fg, var(--color-content-muted)); +} + +[data-empty-state-media][data-kind='icon'] { + /* literal: the icon box is proportional to its glyph (2× keeps the + tinted plate readable at every size) */ + inline-size: calc(var(--_empty-state-media-glyph) * 2); + block-size: calc(var(--_empty-state-media-glyph) * 2); + font-size: var(--_empty-state-media-glyph); + background: var(--empty-state-media-bg, var(--color-neutral-track)); + border-radius: var(--empty-state-media-radius, var(--radius-lg)); +} + +/* ── Title ─────────────────────────────────────────────────────────── */ + +[data-empty-state-title] { + margin: 0; + font-size: var(--_empty-state-title-font-size); + font-weight: var(--font-weight-semibold); + line-height: var(--font-line-height-sm, 1.4); + color: var(--color-content-primary); +} + +/* ── Description ───────────────────────────────────────────────────── */ + +[data-empty-state-description] { + margin: 0; + font-size: var(--_empty-state-body-font-size); + line-height: var(--font-line-height-md, 1.5); + color: var(--color-content-muted); + max-inline-size: var(--empty-state-description-measure, 45ch); + text-wrap: pretty; +} + +/* ── Actions ───────────────────────────────────────────────────────── */ + +[data-empty-state-actions] { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--empty-state-actions-gap, var(--space-2)); + margin-block-start: var(--space-1); +} diff --git a/src/uix/eidos/components/empty-state/empty-state.svelte b/src/uix/eidos/components/empty-state/empty-state.svelte new file mode 100644 index 000000000..5cacb4cfb --- /dev/null +++ b/src/uix/eidos/components/empty-state/empty-state.svelte @@ -0,0 +1,31 @@ + + +
+ {@render children?.()} +
diff --git a/src/uix/eidos/components/empty-state/index.ts b/src/uix/eidos/components/empty-state/index.ts new file mode 100644 index 000000000..a44a4682d --- /dev/null +++ b/src/uix/eidos/components/empty-state/index.ts @@ -0,0 +1,43 @@ +// EmptyState — passive "nothing here yet / no results" display surface. +// +// import { EmptyState } from '$uix/eidos/components/empty-state'; +// +// +// +// No projects yet +// Create your first project to get started. +// +// +// +// +import EmptyStateComponent from './empty-state.svelte'; +import Media from './empty-state-media.svelte'; +import Title from './empty-state-title.svelte'; +import Description from './empty-state-description.svelte'; +import Actions from './empty-state-actions.svelte'; + +type EmptyStateNamespace = typeof EmptyStateComponent & { + Media: typeof Media; + Title: typeof Title; + Description: typeof Description; + Actions: typeof Actions; +}; + +const EmptyState = EmptyStateComponent as EmptyStateNamespace; +EmptyState.Media = Media; +EmptyState.Title = Title; +EmptyState.Description = Description; +EmptyState.Actions = Actions; + +export { EmptyState }; +export default EmptyState; +export type { + EmptyStateProps, + EmptyStateMediaProps, + EmptyStateTitleProps, + EmptyStateDescriptionProps, + EmptyStateActionsProps, + EmptyStateSize, + EmptyStateMediaKind, + EmptyStateTitleLevel +} from './types'; diff --git a/src/uix/eidos/components/empty-state/types.ts b/src/uix/eidos/components/empty-state/types.ts new file mode 100644 index 000000000..6a7d6814a --- /dev/null +++ b/src/uix/eidos/components/empty-state/types.ts @@ -0,0 +1,58 @@ +import type { Snippet } from 'svelte'; +import type { HTMLAttributes } from 'svelte/elements'; +import type { ResponsiveProp, Size } from '$uix/eidos/lib/types'; + +/** + * Size scale exposed by EmptyState. Narrowed from the canonical `Size` + * scale to the values the recipe maps: `sm` is the compact in-collection + * density (table/select/command empties), `md` the default panel size, + * `lg` the full-page hero size. + */ +export type EmptyStateSize = Extract; + +/** + * Media slot treatment. `icon` renders the tinted icon box (the shadcn + * `EmptyMedia variant="icon"` pattern); `media` is a free slot for + * illustrations / avatar groups / images with no box chrome. + */ +export type EmptyStateMediaKind = 'icon' | 'media'; + +/** Semantic heading levels the Title part may render (Atlaskit model). */ +export type EmptyStateTitleLevel = 2 | 3 | 4 | 5 | 6; + +export type EmptyStateProps = Omit, 'children'> & { + /** + * Density scale — `sm` for in-collection empties, `lg` for full-page. + * @default 'md' + */ + size?: ResponsiveProp; + children?: Snippet; +}; + +export type EmptyStateMediaProps = Omit, 'children'> & { + /** Media treatment — tinted icon box vs free media slot. @default 'icon' */ + kind?: EmptyStateMediaKind; + children?: Snippet; +}; + +export type EmptyStateTitleProps = Omit, 'children'> & { + /** + * Semantic heading level (``). Visual size stays on the + * component `size` axis — level and look are decoupled. @default 3 + */ + level?: EmptyStateTitleLevel; + children?: Snippet; +}; + +export type EmptyStateDescriptionProps = Omit, 'children'> & { + children?: Snippet; +}; + +export type EmptyStateActionsProps = Omit, 'children'> & { + /** + * Accessible name for the action group (Atlaskit `buttonGroupLabel`). + * When set, the part renders `role="group"` + `aria-label`. + */ + label?: string; + children?: Snippet; +}; diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 345fc1149..a647bb851 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -3207,6 +3207,12 @@ --banner-loss-track: var(--color-loss-track); --banner-loss-border: var(--color-loss-border); --banner-loss-text: var(--color-loss-text); + --empty-state-gap: var(--space-3); + --empty-state-actions-gap: var(--space-2); + --empty-state-media-bg: var(--color-neutral-track); + --empty-state-media-fg: var(--color-content-muted); + --empty-state-media-radius: var(--radius-lg); + --empty-state-description-measure: 45ch; --container-max-width: var(--container-width-xl); --section-padding-block-sm: var(--space-8); --section-padding-block-md: var(--space-12); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 3cd83f7c7..0948d80b7 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -4429,6 +4429,20 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({ 'loss-text': 'var(--color-loss-text)' }, + // ───────────────────────────────────────────────────────────────────── + // EmptyState — passive "nothing here / no results" surface. Public + // surface is intentionally small: spacing + the media plate + the + // description reading measure; density rides data-size internally. + // ───────────────────────────────────────────────────────────────────── + 'empty-state': { + gap: 'var(--space-3)', + 'actions-gap': 'var(--space-2)', + 'media-bg': 'var(--color-neutral-track)', + 'media-fg': 'var(--color-content-muted)', + 'media-radius': 'var(--radius-lg)', + 'description-measure': '45ch' + }, + // ───────────────────────────────────────────────────────────────────── // Container — full-bleed wrapper that caps `max-width` at the canonical // page width by default: the xl container token (= breakpoint xl, 1280 — diff --git a/src/uix/langs/components/empty-state.ts b/src/uix/langs/components/empty-state.ts new file mode 100644 index 000000000..3b34a0470 --- /dev/null +++ b/src/uix/langs/components/empty-state.ts @@ -0,0 +1,8 @@ +import type { LangNode } from '$libs/langs'; + +export const emptyStateLangs = { + label: { + es: 'Estado vacío', + en: 'Empty state' + } +} satisfies LangNode; diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index 622f2b189..50d3697be 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -37,6 +37,7 @@ import { drawerLangs } from './drawer'; import { dropdownMenuLangs } from './dropdown-menu'; import { editableLangs } from './editable'; import { emojiPickerLangs } from './emoji-picker'; +import { emptyStateLangs } from './empty-state'; import { fabLangs } from './fab'; import { feedLangs } from './feed'; import { fieldLangs } from './field'; @@ -108,7 +109,6 @@ import { treeGridLangs } from './tree-grid'; import { treeViewLangs } from './tree-view'; import { virtualGridLangs } from './virtual-grid'; import { virtualListLangs } from './virtual-list'; -import { wordsLangs } from './words'; import { yearGridLangs } from './year-grid'; /** @@ -154,6 +154,7 @@ export const componentLangs = { 'dropdown-menu': dropdownMenuLangs, editable: editableLangs, 'emoji-picker': emojiPickerLangs, + 'empty-state': emptyStateLangs, fab: fabLangs, feed: feedLangs, field: fieldLangs, @@ -225,6 +226,5 @@ export const componentLangs = { 'tree-view': treeViewLangs, 'virtual-grid': virtualGridLangs, 'virtual-list': virtualListLangs, - words: wordsLangs, 'year-grid': yearGridLangs } satisfies LangNode; diff --git a/src/uix/morfo/components/empty-state.ts b/src/uix/morfo/components/empty-state.ts new file mode 100644 index 000000000..dc9cc03cc --- /dev/null +++ b/src/uix/morfo/components/empty-state.ts @@ -0,0 +1,96 @@ +import type { Morfo } from '../types'; + +/** + * EmptyState — passive display surface for "nothing here yet / no results" + * states: media (icon box or free illustration) + title + description + + * actions, centered. + * + * Eidos-native (`scope: ['eidos']`): pure composition surface with no + * behavior, no state machine and no perceptual occurrences of its own — + * the same passive justification as `banner.ts` / `box.ts`. The actions + * slot hosts consumer-provided `Button`s wired to consumer handlers + * (retry / create / clear-filters); EmptyState itself commits nothing. + * + * Justification for 0 events: appearing/disappearing is a fact of the + * COLLECTION that renders it (table/list/select), not of this surface — + * announcing it belongs to the collection's live region, mirroring how + * headless references model empties as a render slot of collections + * (React Aria `renderEmptyState`, HeroUI `emptyContent`). + * + * Anatomy mirrors the converged reference floor (shadcn `Empty`, Chakra v3 + * `EmptyState`, AntD `Empty`, Polaris, Atlaskit — dossier + * `docs/process/RESEARCH-blocks-references.md` §P3): provider / media / + * title / description / actions. Title carries `role: 'heading'` intent + * (rendered as a real `` by the eidos wrapper — Atlaskit's + * configurable `headingLevel` model; visual size stays decoupled on the + * component `size` axis). The actions group takes an accessible name via + * the wrapper (`label` → `aria-label`, Atlaskit `buttonGroupLabel`). + */ +export const emptyStateMorfo = { + name: 'EmptyState', + kebab: 'empty-state', + scope: ['eidos'], + apg: 'none — passive display surface; the composed actions carry the button semantics of their own components', + texts: { + label: '#?components.empty-state.label|Empty state' + }, + parts: [ + { + name: 'Provider', + kebab: 'provider', + archetype: 'provider', + kind: 'public', + defaultElement: 'div', + optional: false, + data: [], + aria: [] + }, + { + name: 'Media', + kebab: 'media', + archetype: 'image', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [], + aria: [] + }, + { + // Rendered as `` (default h3) by the eidos wrapper. + // MorfoElement doesn't enumerate heading levels, so we declare + // `'div'` + `role: 'heading'` to keep the semantic intent visible + // in the contract (same convention as card.ts Title). + name: 'Title', + kebab: 'title', + archetype: 'title', + kind: 'public', + defaultElement: 'div', + role: 'heading', + optional: true, + data: [], + aria: [] + }, + { + // Rendered as `

` by the eidos wrapper. MorfoElement doesn't + // enumerate `'p'` — closest layout-neutral element here. + name: 'Description', + kebab: 'description', + archetype: 'description', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [], + aria: [] + }, + { + name: 'Actions', + kebab: 'actions', + archetype: 'footer', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [], + aria: [] + } + ] +} as const satisfies Morfo; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 60453096e..1e44b7849 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -254,6 +254,7 @@ heading: 'Status', items: [ { slug: '/uix/components/badge', label: 'Badge' }, + { slug: '/uix/components/empty-state', label: 'Empty state' }, { slug: '/uix/components/progress', label: 'Progress' }, { slug: '/uix/components/meter', label: 'Meter' }, { slug: '/uix/components/spinner', label: 'Spinner' }, diff --git a/web/routes/uix/components/empty-state/+page.svelte b/web/routes/uix/components/empty-state/+page.svelte new file mode 100644 index 000000000..8cc483a42 --- /dev/null +++ b/web/routes/uix/components/empty-state/+page.svelte @@ -0,0 +1,623 @@ + + +{#snippet demoEmptyState()} + + {#if withMedia} + + {#if kind === 'icon'} + + {:else} + + {/if} + + {/if} + No projects yet + {#if withDescription} + + Create your first project to get started. Imported projects appear here too. + + {/if} + {#if withActions} + + + {#if withSecondary} + + {/if} + + {/if} + +{/snippet} + +

+
+
Status · Empty state
+

Empty state

+

+ Passive "nothing here yet / no results" surface: media + title + description + actions, + centered. Display-only and eidos-native — the composed + <Button>s carry the behavior, and announcing an + empty's appearance belongs to the collection that renders it. size="sm" is the + compact in-collection density (tables, selects, command palettes); lg the + full-page hero. For terminal flow states (success / error / 404) use the upcoming + Result instead. +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + sizes{sizes.length} + + + scopeeidos + +
+
+ + +
+
+ {#if inCollection} + + {@render demoEmptyState()} + + {:else} + {@render demoEmptyState()} + {/if} +
+
+ trace + + {trace.entries.length === 0 ? 'no semantic events' : `${trace.entries.length} event(s)`} + + · + size + {size} + + media + {withMedia ? kind : 'off'} · + level h{level} + +
+
+ +
+ + + + + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ EmptyState is eidos-native — no soma split. It has + no color prop by design: it is a neutral display surface (media plate colors + are theme tokens, overridable via recipes.empty-state.*). +

+ +
+ eidos props +
+
+ + + +
+ +
Composition
+
+ + + + + +
+ +
+
+ eidos + composition · size / media kind / heading level + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'system'} +
+

System axes

+

+ Foundation knobs applied to the stage — density, scaling, mode, direction and border width. + EmptyState's typography rides the --size-* bundle, so density and scaling re-derive + it coherently. +

+ +
+ {/if} + + {#if tab === 'motion'} +
+

Motion

+ +
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ EmptyState declares no semantic events. It is a passive surface: appearing/disappearing is a + fact of the COLLECTION that renders it (table / list / select), and announcing it belongs to + that collection's live region — mirroring how headless references model empties as a render + slot (renderEmptyState in React Aria, emptyContent in HeroUI). The + composed <Button>s in .Actions own their own sema. +

+ stageRef?.querySelector('[data-empty-state]') ?? stageRef} + /> +
+ {/if} + + {#if tab === 'services'} +
+

Services

+

+ EmptyState consumes langs only for its catalog display name (texts.label + → «{uix.langs.ts('#?components.empty-state.label|Empty state')}» — switch the language in + the topbar to see it follow). All VISIBLE copy (title, description, action labels) is app + content by design — the component bakes no strings, so it is translation-ready by + construction. No format / announce / clipboard consumption. +

+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+ +
EmptyState props
+
+ + + + + + + + + +
PropTypeNotes
size{sizes.join(' | ')} + Density. sm = compact in-collection empty, lg = full-page + hero. Default md. Responsive. +
+
+ +
Parts
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
PartPropNotes
EmptyState.Mediakind: {kinds.join(' | ')} + icon paints the tinted icon box; media is a free slot for + illustrations / avatar groups. Default icon. +
EmptyState.Titlelevel: 2 | 3 | 4 | 5 | 6 + Real <h{'{level}'}> (Atlaskit model). Semantic level and visual + size are decoupled — the look rides size. Default + 3. +
EmptyState.Description—Muted <p>, capped to a readable measure (45ch token).
EmptyState.Actionslabel?: string + Hosts consumer <Button>s — one primary (verb-first), optional + secondary/ghost. label names the group (role="group" + + aria-label). +
+
+ +
Reference comparison
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
LibraryClosest equivalentDifference
shadcn/uiEmpty / EmptyMedia / EmptyTitle / … + Same anatomy incl. EmptyMedia variant="icon" (our + kind); shadcn adds an EmptyHeader wrapper we don't need (the + root centers directly). +
chakra v3EmptyState.Root/Indicator/Title/DescriptionSame slot recipe + size; no heading-level control.
atlaskitEmptyState + Source of headingLevel (their default h4; ours h3) and + buttonGroupLabel (our Actions label). +
antdEmpty + Ships image presets + ConfigProvider.renderEmpty global injection — collection + integration is a tracked gap here (see README §Gaps). +
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+ +
+ + + + + + + + + +
FieldValue
name{emptyStateMorfo.name}
kebab{emptyStateMorfo.kebab}
scope{emptyStateMorfo.scope.join(', ')}
parts{partsList.length}
events{events.length}
+
+ +
Parts
+
+ + + + + + + + + + + + {#each partsList as part (part.kebab)} + + + + + + + + {/each} + +
kebabmarkerelementarchetypeoptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype}{part.optional ? 'yes' : 'no'}
+
+ +

+ Title declares role: 'heading' intent in the contract; the eidos wrapper + renders the real <h{'{level}'}>. The wrapper-only visual attrs (data-size + on the root, data-kind on media) are eidos-only by design — they never cross to another + layer. +

+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Recipe lives in src/uix/eidos/components/empty-state/empty-state.css. Public + tokens: + --empty-state-gap · actions-gap · media-bg · media-fg · media-radius · + description-measure + (overridable via recipes.empty-state.*). Typography rides the + --size-{'{k}'}-* bundle — the title reads one discrete size up from the component + size. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-empty-state]morfoProvider marker — centered column, gap + padding.
[data-empty-state][data-size='X']eidosDensity — paddings + title/body font sizes + media glyph size.
[data-empty-state-media][data-kind='icon']eidosThe tinted icon plate (2× glyph box, neutral track background).
[data-empty-state-title]morfoSemibold heading, one bundle size above the component size.
[data-empty-state-description]morfoMuted body copy, max-inline-size reading measure.
[data-empty-state-actions]morfoWrapping centered action row.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
ConcernContract
Role + None on the root — a static display surface, not a live region and not a landmark. + Announcing "no results" belongs to the collection that renders it. +
Heading + Title is a real <h{'{level}'}> (default h3) so the empty participates + correctly in the document outline; pick the level that fits the page hierarchy. +
Actions group + Actions label renders role="group" + + aria-label — an accessible name for multi-action rows (Atlaskit + buttonGroupLabel). +
Media + Decorative by default — pass aria-hidden glyphs or meaningful + alts on real images; the plate itself carries no semantics. +
Content doctrine + One primary action, verb-first ("Create project", never "OK"); description states + the reason + the next step (Polaris / Atlassian writing guidance). +
+
+
+ {/if} +