From 4ae31646f5f2e8a63449d1c2060bb30507ef6e25 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 16:08:55 +0530 Subject: [PATCH 1/2] feat(ui): add empty, spinner and field components Generated apps have to give every list an empty state, every form labelled fields with inline errors, and every save a busy state, and the kit had none of the three, so each app hand-rolled them differently. These are the shadcn Empty, Spinner and Field, rewritten as Tier-1 class helpers the way the v1 deferral (1700c051) said the deferred components would come back. Class strings follow shadcn new-york-v4, adapted only where shadcn keys on a Radix attribute: the native checkbox and radio match [type=checkbox] and :checked instead of role and data-state, and the field separator draws its rule with ::before so it needs no separator dependency. spinner() returns an html template and branches the decorative form instead of a nullish role hole, because the server renderer would serve role="" otherwise. field.ts reuses the fieldClass and fieldLabelClass names the cn() helper module already exports. They live in different modules and both keep working, so the doc comment says which is which rather than renaming either. Closes #1569 Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- .../ui/packages/registry/components/empty.ts | 88 ++++++++++ .../ui/packages/registry/components/field.ts | 163 ++++++++++++++++++ .../packages/registry/components/spinner.ts | 63 +++++++ packages/ui/packages/registry/registry.json | 48 ++++++ packages/ui/test/add-command.test.js | 32 ++++ packages/ui/test/class-helpers.test.js | 88 ++++++++++ packages/ui/test/extract.test.js | 2 +- packages/ui/test/registry-contents.test.js | 5 +- 8 files changed, 486 insertions(+), 3 deletions(-) create mode 100644 packages/ui/packages/registry/components/empty.ts create mode 100644 packages/ui/packages/registry/components/field.ts create mode 100644 packages/ui/packages/registry/components/spinner.ts diff --git a/packages/ui/packages/registry/components/empty.ts b/packages/ui/packages/registry/components/empty.ts new file mode 100644 index 000000000..a98604eba --- /dev/null +++ b/packages/ui/packages/registry/components/empty.ts @@ -0,0 +1,88 @@ +/** + * Empty: the empty state for a list, table, search result, or any region with + * nothing to show yet. Tier-1 class helpers; compose with `
` (or a heading + * and paragraph) for each subpart. + * + * shadcn parity: + * Empty → emptyClass() + * EmptyHeader → emptyHeaderClass() + * EmptyMedia (variant: default | icon) → emptyMediaClass({ variant }) + * EmptyTitle → emptyTitleClass() + * EmptyDescription → emptyDescriptionClass() + * EmptyContent → emptyContentClass() + * + * The root carries `border-dashed` but no border width, exactly as shadcn + * does, so it renders borderless by default. Add `border` beside it for the + * dashed outline: `class=${cn(emptyClass(), 'border')}`. + * + * Design tokens used: --muted, --foreground, --muted-foreground, --primary, + * --border. + * + * A11y (required for accessible output): + * Use a REAL HEADING for `emptyTitleClass()`, at the level the surrounding + * document wants (`

` under the page `

`, `

` inside a section). The + * empty state usually replaces the list a heading already introduces, so the + * title is often a level below it. + * The media is decorative: put `aria-hidden="true"` on the icon or + * illustration, since the title already says what is missing. + * The action in `emptyContentClass()` is a real `` (navigates, e.g. + * to the create page) or a real ` + * ``` + */ +import { html } from '@webjsdev/core'; +import { cn } from '../lib/utils.ts'; + +/** Spinner classes: `size-4` plus `animate-spin`. Apply to your own ``. */ +export const spinnerClass = (): string => 'size-4 animate-spin'; + +/** + * The spinner as an inline SVG template. `class` is merged over + * `spinnerClass()` (a later size utility wins), `label` names it (default + * "Loading"), and `decorative` hides it from assistive tech for use beside + * visible text such as a busy button's label. + */ +export function spinner(opts: { class?: string; label?: string; decorative?: boolean } = {}) { + const cls = cn(spinnerClass(), opts.class); + // Branched rather than a nullish `role=${...}` hole: the server renderer + // stringifies a null attribute value, so a single template would serve + // `role=""` on the decorative form. + if (opts.decorative) { + return html``; + } + return html``; +} diff --git a/packages/ui/packages/registry/registry.json b/packages/ui/packages/registry/registry.json index 313b368dd..5f7aa33ae 100644 --- a/packages/ui/packages/registry/registry.json +++ b/packages/ui/packages/registry/registry.json @@ -232,6 +232,38 @@ } ] }, + { + "name": "empty", + "type": "registry:ui", + "registryDependencies": [ + "lib-utils" + ], + "dependencies": [ + "@webjsdev/core" + ], + "files": [ + { + "path": "components/empty.ts", + "type": "registry:ui" + } + ] + }, + { + "name": "field", + "type": "registry:ui", + "registryDependencies": [ + "lib-utils" + ], + "dependencies": [ + "@webjsdev/core" + ], + "files": [ + { + "path": "components/field.ts", + "type": "registry:ui" + } + ] + }, { "name": "hover-card", "type": "registry:ui", @@ -410,6 +442,22 @@ } ] }, + { + "name": "spinner", + "type": "registry:ui", + "registryDependencies": [ + "lib-utils" + ], + "dependencies": [ + "@webjsdev/core" + ], + "files": [ + { + "path": "components/spinner.ts", + "type": "registry:ui" + } + ] + }, { "name": "switch", "type": "registry:ui", diff --git a/packages/ui/test/add-command.test.js b/packages/ui/test/add-command.test.js index 73d7b4769..eca659206 100644 --- a/packages/ui/test/add-command.test.js +++ b/packages/ui/test/add-command.test.js @@ -279,6 +279,38 @@ test('add: local-first (no --registry) installs a real component, strips its exa } }); +test('add: local-first installs empty, spinner and field together (#1569)', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch (local-first)'); }; + const d = mkdtempSync(join(tmpdir(), 'webjsui-add-real-')); + writeFileSync(join(d, 'components.json'), JSON.stringify({ + style: 'default', + tailwind: { css: 'styles/globals.css', baseColor: 'neutral', cssVariables: true }, + aliases: { components: 'components', utils: 'lib/utils', ui: 'components/ui', lib: 'lib' }, + })); + const origLog = console.log; + console.log = () => {}; + try { + await add.parseAsync(['empty', 'spinner', 'field', '--yes', '--no-deps', '--cwd', d], { from: 'user' }); + const read = (n) => readFileSync(join(d, 'components', 'ui', `${n}.ts`), 'utf8'); + assert.match(read('empty'), /export const emptyClass/); + assert.match(read('field'), /export function fieldClass/); + const spin = read('spinner'); + assert.match(spin, /export function spinner\(/); + assert.match(spin, /from '@webjsdev\/core'/); + // The cn import is rewritten to the app's utils alias, not left relative. + assert.doesNotMatch(spin, /from '\.\.\/lib\/utils\.ts'/); + assert.match(spin, /from '\.\.\/\.\.\/lib\/utils/); + for (const n of ['empty', 'spinner', 'field']) { + assert.doesNotMatch(read(n), /@example/, `${n}: example stripped`); + assert.match(read(n), new RegExp(`npx @webjsdev/ui view ${n}`), `${n}: pointer left`); + } + } finally { + console.log = origLog; + globalThis.fetch = origFetch; + rmSync(d, { recursive: true }); + } +}); + test('ensureTheme: returns a failure (does not throw) when css path / baseColor is missing', async () => { // Defensive: a direct caller passing an incomplete config must get a // structured failure, never a synchronous crash on join(cwd, undefined). #983. diff --git a/packages/ui/test/class-helpers.test.js b/packages/ui/test/class-helpers.test.js index 57cb6e711..2bcd79629 100644 --- a/packages/ui/test/class-helpers.test.js +++ b/packages/ui/test/class-helpers.test.js @@ -248,3 +248,91 @@ test('native-select: wrapper + select + option helpers', { skip }, async () => { assertHelper(mod, name); } }); + +// ---------- empty ---------- + +test('empty: 6 subpart helpers, icon media variant', { skip }, async () => { + const mod = await import(join(COMPONENTS_DIR, 'empty.ts')); + for (const name of [ + 'emptyClass', + 'emptyHeaderClass', + 'emptyMediaClass', + 'emptyTitleClass', + 'emptyDescriptionClass', + 'emptyContentClass', + ]) assertHelper(mod, name); + assert.match(mod.emptyClass(), /border-dashed/); + assert.match(mod.emptyClass(), /md:p-12/); + assert.match(mod.emptyHeaderClass(), /max-w-sm/); + assert.match(mod.emptyMediaClass(), /bg-transparent/); + const icon = mod.emptyMediaClass({ variant: 'icon' }); + assert.match(icon, /size-10/); + assert.match(icon, /rounded-lg/); + assert.match(icon, /bg-muted/); + assert.match(icon, /text-foreground/); + assert.doesNotMatch(icon, /bg-transparent/); + assert.match(mod.emptyDescriptionClass(), /text-muted-foreground/); +}); + +// ---------- spinner ---------- + +test('spinner: spinnerClass + an inline SVG that SSRs as a named status', { skip }, async () => { + const mod = await import(join(COMPONENTS_DIR, 'spinner.ts')); + assert.equal(mod.spinnerClass(), 'size-4 animate-spin'); + const { renderToString } = await import('@webjsdev/core/server'); + const out = await renderToString(mod.spinner()); + assert.match(out, /]*role="status"/); + assert.match(out, /aria-label="Loading"/); + assert.match(out, /stroke="currentColor"/); + assert.match(out, /class="size-4 animate-spin"/); + assert.match(out, /M21 12a9 9 0 1 1-6\.219-8\.56/); +}); + +test('spinner: a size class replaces the default, label renames it', { skip }, async () => { + const mod = await import(join(COMPONENTS_DIR, 'spinner.ts')); + const { renderToString } = await import('@webjsdev/core/server'); + const out = await renderToString(mod.spinner({ class: 'size-6', label: 'Saving' })); + assert.match(out, /class="animate-spin size-6"|class="size-6 animate-spin"/); + assert.doesNotMatch(out, /size-4/); + assert.match(out, /aria-label="Saving"/); +}); + +test('spinner: decorative serves aria-hidden and NO empty role or name', { skip }, async () => { + const mod = await import(join(COMPONENTS_DIR, 'spinner.ts')); + const { renderToString } = await import('@webjsdev/core/server'); + const out = await renderToString(mod.spinner({ decorative: true })); + assert.match(out, /aria-hidden="true"/); + assert.doesNotMatch(out, /role=/); + assert.doesNotMatch(out, /aria-label=/); +}); + +// ---------- field ---------- + +test('field: 11 subpart helpers with orientation + legend variants', { skip }, async () => { + const mod = await import(join(COMPONENTS_DIR, 'field.ts')); + for (const name of [ + 'fieldSetClass', + 'fieldLegendClass', + 'fieldGroupClass', + 'fieldClass', + 'fieldContentClass', + 'fieldLabelClass', + 'fieldTitleClass', + 'fieldDescriptionClass', + 'fieldErrorClass', + 'fieldSeparatorClass', + 'fieldSeparatorContentClass', + ]) assertHelper(mod, name); + assert.match(mod.fieldClass(), /group\/field/); + assert.match(mod.fieldClass(), /data-\[invalid=true\]:text-destructive/); + assert.match(mod.fieldClass(), /flex-col/); + assert.match(mod.fieldClass({ orientation: 'horizontal' }), /flex-row items-center/); + assert.match(mod.fieldClass({ orientation: 'responsive' }), /@md\/field-group:flex-row/); + assert.match(mod.fieldLegendClass(), /text-base/); + assert.match(mod.fieldLegendClass({ variant: 'label' }), /text-sm/); + assert.match(mod.fieldGroupClass(), /@container\/field-group/); + assert.match(mod.fieldErrorClass(), /text-destructive/); + assert.match(mod.fieldErrorClass(), /text-sm/); + assert.match(mod.fieldLabelClass(), /group-data-\[disabled=true\]\/field:opacity-50/); + assert.match(mod.fieldDescriptionClass(), /text-muted-foreground/); +}); diff --git a/packages/ui/test/extract.test.js b/packages/ui/test/extract.test.js index 9fd62132a..f3b3ad4e5 100644 --- a/packages/ui/test/extract.test.js +++ b/packages/ui/test/extract.test.js @@ -58,7 +58,7 @@ test('uiComponent: null for a non-ui / unknown name', () => { test('uiInventory: one entry per registry:ui component, tier-labelled, sorted', () => { const inv = uiInventory(); - assert.equal(inv.length, 32); + assert.equal(inv.length, 35); assert.ok(inv.every((c) => c.tier === 1 || c.tier === 2)); const names = inv.map((c) => c.name); assert.deepEqual(names, [...names].sort(), 'inventory is sorted by name'); diff --git a/packages/ui/test/registry-contents.test.js b/packages/ui/test/registry-contents.test.js index 3a5d5e861..f9e6b04d7 100644 --- a/packages/ui/test/registry-contents.test.js +++ b/packages/ui/test/registry-contents.test.js @@ -38,6 +38,7 @@ const V1_COMPONENTS = [ 'dialog', 'alert-dialog', 'popover', 'tooltip', 'hover-card', 'tabs', 'accordion', 'collapsible', 'dropdown-menu', 'sonner', + 'empty', 'spinner', 'field', ]; // Components that are Tier 2, must register a custom element. @@ -59,10 +60,10 @@ function readManifest() { return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); } -test('registry.json exists and enumerates ≥32 v1 components', { skip }, () => { +test('registry.json exists and enumerates ≥35 components', { skip }, () => { const m = readManifest(); const uiItems = m.items.filter((it) => it.type === 'registry:ui'); - assert.ok(uiItems.length >= 32, `expected ≥32 registry:ui items, found ${uiItems.length}`); + assert.ok(uiItems.length >= 35, `expected ≥35 registry:ui items, found ${uiItems.length}`); }); test('every v1 component source file exists and is non-trivial', { skip }, () => { From 546a2992ae751f4956e1cb957901d4fd600bdbf4 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 16:12:07 +0530 Subject: [PATCH 2/2] docs(ui): document empty, spinner and field on every kit surface The gallery pages are the component docs, so each new component gets a live example and an API table there. The field example evaluates against the kit's fieldClass and fieldLabelClass rather than the cn() module's same-named rhythm helpers, chosen per snippet so the input and textarea examples keep rendering exactly as before. The skill gains an idiom naming which primitive covers the empty, form-error and busy states, since that is the decision an agent makes before it reaches for the components, and the package AGENTS.md and README carry the inventory rows and the a11y obligations the components push to the caller. Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- .agents/skills/webjs/references/ui-kit.md | 17 +++- README.md | 2 +- packages/ui/AGENTS.md | 10 ++- packages/ui/README.md | 8 +- website/app/ui/[name]/page.ts | 6 +- website/app/ui/layout.ts | 2 +- website/modules/ui/utils/component-api.ts | 37 ++++++++ website/modules/ui/utils/examples.ts | 104 +++++++++++++++++++++- 8 files changed, 170 insertions(+), 16 deletions(-) diff --git a/.agents/skills/webjs/references/ui-kit.md b/.agents/skills/webjs/references/ui-kit.md index ccebddb9e..bc6958bd2 100644 --- a/.agents/skills/webjs/references/ui-kit.md +++ b/.agents/skills/webjs/references/ui-kit.md @@ -81,9 +81,9 @@ So the loop is: `add` the component, then query `ui ` (MCP) or ## Inventory (run `npx webjsdev ui list` or the MCP `ui` tool for the authoritative, current set) **Tier 1 (class helpers):** accordion, alert, aspect-ratio, avatar, badge, -breadcrumb, button, card, checkbox, collapsible, input, kbd, label, -native-select, pagination, popover, progress, radio-group, separator, skeleton, -switch, table, textarea. +breadcrumb, button, card, checkbox, collapsible, empty, field, input, kbd, +label, native-select, pagination, popover, progress, radio-group, separator, +skeleton, spinner, switch, table, textarea. **Tier 2 (custom elements, own their ARIA):** alert-dialog, dialog, dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group @@ -93,6 +93,17 @@ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group - A helper is a function, so compose it: `class=${buttonClass({ variant: 'outline' })}`. The unquoted `${...}` is a normal `html` attribute hole. +- The three states a screen owes its user each have a primitive, so do not hand-roll + them. A list or table with no rows renders `empty` (title as a real heading, a + description saying what to do next, and the create action). A form field is + `field` (`fieldClass()` holding a `
.' }], props: [{ name: 'orientation', type: '"horizontal" | "vertical"', default: '"horizontal"' }], }, + empty: { + subcomponents: [ + { name: 'emptyClass()', description: 'Root. Centred column with generous padding; add border for the dashed outline.' }, + { name: 'emptyHeaderClass()', description: 'Groups the media, title, and description.' }, + { name: 'emptyMediaClass({ variant })', description: 'Icon or illustration. variant icon puts the icon on a muted tile.' }, + { name: 'emptyTitleClass() / emptyDescriptionClass()', description: 'Use a real heading for the title; the description is muted text saying what to do next.' }, + { name: 'emptyContentClass()', description: 'Row for the action button(s).' }, + ], + props: [{ name: 'variant', type: '"default" | "icon"', default: '"default"', description: 'On emptyMediaClass. Set data-variant on the same element for shadcn parity.' }], + }, + field: { + subcomponents: [ + { name: 'fieldSetClass() / fieldLegendClass({ variant })', description: 'A native
and its , which names a group of fields.' }, + { name: 'fieldGroupClass()', description: 'Stacks fields, and is the container responsive fields measure.' }, + { name: 'fieldClass({ orientation })', description: 'One label, control, and text unit. data-invalid="true" turns it destructive.' }, + { name: 'fieldContentClass()', description: 'Wraps a label and description beside a horizontal control.' }, + { name: 'fieldLabelClass() / fieldTitleClass()', description: 'The
`, + empty: ` +
+
+
+ +
+

No projects yet

+

You have not created any projects. Create your first one to get started.

+
+ +
+ `, + + spinner: ` +
+
+ + Loading invoices +
+ +
+ `, + + field: ` +
+
+ Profile +

This is how others see you.

+
+
+ + +

Shown on your public page.

+
+
+ + + +
+
Notifications
+
+ +
+ +

A summary of activity, every Monday.

+
+
+
+
+
+ `, + 'aspect-ratio': `
16:9