Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/7143-data-error-state-migration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
---

`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s
load-failure panel is now rendered by the error state instead of the empty state
(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27).

`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for
the *empty* case — passing it a destructive icon, error copy and a retry action, while
`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in
the repo. objectui#7132 closed the accessibility half of that collision (the panel now
declares `role="alert"` over the empty state's `role="status"` default) and deliberately
left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a
props-surface question plus a visual change rather than a rename.

**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored
from `DataEmptyState` in the same file rather than spelled a second way:

- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle`
glyph the component has always drawn.
- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely.
- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than
merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??`
against its own default and this does the same, against
`flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive
square `DataErrorState` already drew.

Same names, same types, same default semantics as the empty state's; nothing existing on
`DataErrorState` changed, and a call site that passes none of the three renders exactly
what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the
ruling pins three props, and this component's retry affordance is already spelled
`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control).

One non-prop addition rides along, called out rather than folded in: the icon wrapper now
carries `data-slot="data-error-state-icon"`, mirroring the empty state's
`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no
name — untestable and unstylable — and migrating a call site off `DataEmptyState` would
DROP that identifier rather than rename it.

**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call
site passes the same custom icon through the new `icon` prop, the same
`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the
error state's spelling of `description`); its retry `<Button>` moves from `action` to
`children`, which renders at the identical position. `role="alert"`, the
`data-testid="list-error-state"` hook and `data-error-kind` are untouched. The whole
rendered delta is two attributes:

- the panel root's `data-slot` becomes `data-error-state` (was `data-empty-state`);
- the icon wrapper's becomes `data-error-state-icon` (was `data-empty-state-icon`).

Both are renames, not removals. Nothing in this repo styles or selects on either — no CSS
rule and no test read them — so a stylesheet in a host app targeting
`[data-slot="data-empty-state"]` to reach *this* panel is the only way to notice, and it
should be reading `data-error-state` now. Every class on every node, and the glyphs
themselves, are byte-identical: this is a visual no-op, deliberately, so the review the
ruling asks for has a small thing to look at rather than a redesign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,11 @@ describe('DataEmptyState — role default (#7132)', () => {
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
// The borrow this arm was written for is gone — `plugin-list`'s load-failure
// panel moved to `DataErrorState` in objectui#7143 — but the mechanism it
// measures is the reason the default is safe to have, so it is kept and
// renamed rather than retired with the call site that motivated it.
it('OVERRIDE: a call site passing role="alert" keeps it', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7143 — `DataErrorState` gains `icon` / `showIcon` /
* `iconWrapperClassName`, MIRRORED from `DataEmptyState` rather than spelled a
* second way.
*
* `DataErrorState` hardcoded its glyph, which is why `plugin-list` rendered its
* load FAILURE through the component named for the *empty* case: the panel had
* to draw a network outage differently from a permission denial, and only the
* wrong component could take an icon. objectui#7132 fixed the accessibility half
* of that collision (`role`) and left the structural half; the maintainer ruling
* of 2026-09-01 approved the migration and pinned the shape to the empty state's.
*
* "Mirrored" is a claim about SEMANTICS, not just about three identifiers, so
* every arm below runs twice — once on each component — and the pairs assert the
* same thing. The semantics that can silently diverge is
* `iconWrapperClassName`: `DataEmptyState` resolves it with `??`, so it REPLACES
* the default wrapper class rather than merging with it, and `""` is therefore a
* meaningful value that strips the styling. A `cn(default, override)` reading
* would type-check, look right, and quietly keep `bg-destructive/10` under every
* override — including `plugin-list`'s `mb-3`, which exists to remove the box.
*
* SUITE DIRECTION, MEASURED by reverting both source files to the base commit
* and re-running: every `DataErrorState` arm here is RED against `origin/main`.
* The three props do not exist there — they fall into the `...props` spread and
* land on the div as unknown attributes while the hardcoded wrapper renders
* regardless — and that wrapper carries no `data-slot` to select it by. The
* DEFAULTS arm is red for the selector alone, which is the point of keeping it:
* what it pins is that after the change a call site passing none of the three
* still gets the same square and the same glyph it always got.
*
* ⚠️ The `showIcon={false}` arm was written asserting only that the named
* wrapper is ABSENT, and that arm passed on the base — the selector it looks
* for does not exist there either, so "no wrapper" and "no such name" were the
* same reading and the arm could not fail. It now names the glyph as well.
* The same trap is why the DataEmptyState mirror below does both.
*
* GREEN in both worlds, deliberately: the three `DataEmptyState` halves — the
* control that makes "same semantics" a measurement rather than a restatement of
* the new code — and the last arm, which pins that title / message / retry were
* not disturbed by growing the component.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { DataEmptyState, DataErrorState } from '../custom/view-states';

const errorBox = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state"]')!;
const errorIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state-icon"]');
const emptyIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state-icon"]');

const ERROR_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-destructive/10';
const EMPTY_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-muted';

describe('DataErrorState — the icon props mirrored from DataEmptyState (#7143)', () => {
it('DEFAULTS: no props renders the destructive square and its own glyph, unchanged', () => {
const { container } = render(<DataErrorState title="Something went wrong" />);
const box = errorBox(container);
expect(box.getAttribute('role')).toBe('alert');
const icon = errorIcon(container);
expect(icon).not.toBeNull();
expect(icon!.className).toBe(ERROR_WRAPPER_DEFAULT);
// The component's own AlertCircle, not an absent glyph.
expect(icon!.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('Something went wrong');
});

it('`icon` replaces the hardcoded glyph — the reason this migration needed props', () => {
const { container } = render(
<DataErrorState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = errorIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
// Asserted by absence too: a `??` that fell through would render BOTH.
expect(icon.querySelector('svg')).toBeNull();
});

it('MIRROR: `icon` behaves identically on DataEmptyState', () => {
const { container } = render(
<DataEmptyState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = emptyIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
expect(icon.querySelector('svg')).toBeNull();
});

it('`showIcon={false}` omits the container entirely, without collapsing the render', () => {
const { container } = render(<DataErrorState showIcon={false} title="Denied" message="No." />);
expect(errorIcon(container)).toBeNull();
// The absent-wrapper assertion ALONE cannot fail: measured against
// `origin/main`, where the prop does not exist and the wrapper carries no
// `data-slot`, the selector returns null while the hardcoded glyph is right
// there on screen. So the glyph itself is named — no icon means no icon.
expect(errorBox(container).querySelector('svg')).toBeNull();
// Guard against the arm passing over a component that rendered nothing.
expect(container.textContent).toContain('Denied');
expect(container.textContent).toContain('No.');
});

it('MIRROR: `showIcon={false}` behaves identically on DataEmptyState', () => {
const { container } = render(<DataEmptyState showIcon={false} title="Nothing here yet" />);
expect(emptyIcon(container)).toBeNull();
expect(container.querySelector('[data-slot="data-empty-state"]')!.querySelector('svg')).toBeNull();
expect(container.textContent).toContain('Nothing here yet');
});

it('`iconWrapperClassName` REPLACES the default class, it does not merge with it', () => {
const { container } = render(<DataErrorState iconWrapperClassName="mb-3" />);
const icon = errorIcon(container)!;
// Exact value, not `toContain`: merging would also satisfy "contains mb-3",
// and merging is precisely what `plugin-list`'s call site must not get — its
// `mb-3` is there to REMOVE the box, not to nudge it.
expect(icon.className).toBe('mb-3');
expect(icon.className).not.toContain('bg-destructive/10');
});

it('`iconWrapperClassName=""` strips the styling and renders the icon raw', () => {
const { container } = render(<DataErrorState iconWrapperClassName="" />);
const icon = errorIcon(container)!;
expect(icon.className).toBe('');
expect(icon.querySelector('svg')).not.toBeNull();
});

it('MIRROR: both override semantics are identical on DataEmptyState', () => {
const { container: replaced } = render(<DataEmptyState iconWrapperClassName="mb-3" />);
expect(emptyIcon(replaced)!.className).toBe('mb-3');
expect(emptyIcon(replaced)!.className).not.toContain('bg-muted');
const { container: stripped } = render(<DataEmptyState iconWrapperClassName="" />);
expect(emptyIcon(stripped)!.className).toBe('');
// And the empty state's own default is the one it always had, so the two
// components differ by exactly the fallback colour and nothing else.
const { container: bare } = render(<DataEmptyState />);
expect(emptyIcon(bare)!.className).toBe(EMPTY_WRAPPER_DEFAULT);
});

it('the existing surface is untouched: title, message and the retry button', () => {
let clicks = 0;
const { container } = render(
<DataErrorState
title="Couldn’t load"
message="Try again later."
retryLabel="Retry now"
onRetry={() => { clicks += 1; }}
/>,
);
expect(container.textContent).toContain('Couldn’t load');
expect(container.textContent).toContain('Try again later.');
const button = container.querySelector('button')!;
expect(button.textContent).toContain('Retry now');
button.click();
expect(clicks).toBe(1);
});
});
69 changes: 64 additions & 5 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
* so a call site keeps the last word. That is what makes this change inert for
* the two ruled surfaces — both already pass `role="status"` explicitly and
* receive the identical attribute either way — and it is what lets a call site
* rendering something that is NOT empty say so (`plugin-list`'s load-error
* panel borrows this component and declares `role="alert"`).
* rendering something that is NOT empty say so.
*
* The call site that needed that last word was `plugin-list`'s load-failure
* panel, which borrowed this component for its layout. Since objectui#7143 it
* no longer does: `DataErrorState` grew the icon props it was missing and the
* panel is drawn by the component named for what it is. The default stays a
* default for the same reason it always was — a borrow can recur, and a fixed
* attribute would announce the next one as a routine status.
*/
function DataEmptyState({
className,
Expand DownExpand Up@@ -172,6 +178,21 @@ function DataEmptyState({
// ---------------------------------------------------------------------------

interface DataErrorStateProps extends React.ComponentProps<"div"> {
/** Icon rendered above the title */
icon?: React.ReactNode
/**
* When false, the icon container is omitted entirely. Useful for
* banner-style failures that should not draw a glyph at all. Defaults to
* true.
*/
showIcon?: boolean
/**
* Override class on the icon wrapper. By default the wrapper renders as a
* small destructive-tinted rounded square (`size-10 rounded-lg
* bg-destructive/10`). Pass `""` to strip that styling and render the icon
* raw, or extend the look (e.g. larger size).
*/
iconWrapperClassName?: string
title?: string
/** Error message or description */
message?: string
Expand All@@ -181,8 +202,38 @@ interface DataErrorStateProps extends React.ComponentProps<"div"> {
retryLabel?: string
}

/**
* `icon` / `showIcon` / `iconWrapperClassName` MIRROR `DataEmptyState` above —
* same names, same types, same default semantics, including
* `iconWrapperClassName` REPLACING the wrapper's default class rather than
* merging with it, so `""` renders the icon raw (objectui#7143). A second
* spelling of the same three ideas is the defect the mirror exists to prevent;
* the only intended difference is the default class the `??` falls back to,
* which stays this component's own destructive square.
*
* They exist because `plugin-list` rendered its load FAILURE through
* `DataEmptyState` — the component named for the *empty* case — for want of
* them: this one hardcoded its glyph, so the one panel that needed to draw a
* network failure differently from a permission denial could only get that from
* the wrong component. objectui#7132 fixed the accessibility half of that
* collision (`role`) and deliberately left the structural half here.
*
* Deliberately NOT mirrored: `illustration` — an empty state's product-feel hero
* has no failure analogue — and `action`, because this component already spells
* its affordance as `onRetry` / `retryLabel`, with `children` for a call site
* that needs to render its own control.
*
* The icon wrapper carries `data-slot="data-error-state-icon"`, mirroring the
* empty state's `data-empty-state-icon`. Not a prop, and not in the ruling's
* list of three — it is here so the wrapper `iconWrapperClassName` now governs
* can be named by a test and a stylesheet, and so migrating a call site off
* `DataEmptyState` renames that identifier instead of dropping it.
*/
function DataErrorState({
className,
icon,
showIcon = true,
iconWrapperClassName,
title = "Something went wrong",
message,
onRetry,
Expand All@@ -200,9 +251,17 @@ function DataErrorState({
)}
{...props}
>
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-5 text-destructive" />
</div>
{showIcon && (
<div
data-slot="data-error-state-icon"
className={cn(
iconWrapperClassName ??
"flex size-10 items-center justify-center rounded-lg bg-destructive/10"
)}
>
{icon ?? <AlertCircle className="size-5 text-destructive" />}
</div>
)}
{title && (
<h3 className="text-sm font-medium">{title}</h3>
)}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/7143-data-error-state-migration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
---

`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s
load-failure panel is now rendered by the error state instead of the empty state
(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27).

`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for
the *empty* case — passing it a destructive icon, error copy and a retry action, while
`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in
the repo. objectui#7132 closed the accessibility half of that collision (the panel now
declares `role="alert"` over the empty state's `role="status"` default) and deliberately
left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a
props-surface question plus a visual change rather than a rename.

**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored
from `DataEmptyState` in the same file rather than spelled a second way:

- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle`
glyph the component has always drawn.
- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely.
- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than
merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??`
against its own default and this does the same, against
`flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive
square `DataErrorState` already drew.

Same names, same types, same default semantics as the empty state's; nothing existing on
`DataErrorState` changed, and a call site that passes none of the three renders exactly
what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the
ruling pins three props, and this component's retry affordance is already spelled
`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control).

One non-prop addition rides along, called out rather than folded in: the icon wrapper now
carries `data-slot="data-error-state-icon"`, mirroring the empty state's
`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no
name — untestable and unstylable — and migrating a call site off `DataEmptyState` would
DROP that identifier rather than rename it.

**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call
site passes the same custom icon through the new `icon` prop, the same
`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the
error state's spelling of `description`); its retry `<Button>` moves from `action` to
`children`, which renders at the identical position. `role="alert"`, the
`data-testid="list-error-state"` hook and `data-error-kind` are untouched. The whole
rendered delta is two attributes:

- the panel root's `data-slot` becomes `data-error-state` (was `data-empty-state`);
- the icon wrapper's becomes `data-error-state-icon` (was `data-empty-state-icon`).

Both are renames, not removals. Nothing in this repo styles or selects on either — no CSS
rule and no test read them — so a stylesheet in a host app targeting
`[data-slot="data-empty-state"]` to reach *this* panel is the only way to notice, and it
should be reading `data-error-state` now. Every class on every node, and the glyphs
themselves, are byte-identical: this is a visual no-op, deliberately, so the review the
ruling asks for has a small thing to look at rather than a redesign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,11 @@ describe('DataEmptyState — role default (#7132)', () => {
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
// The borrow this arm was written for is gone — `plugin-list`'s load-failure
// panel moved to `DataErrorState` in objectui#7143 — but the mechanism it
// measures is the reason the default is safe to have, so it is kept and
// renamed rather than retired with the call site that motivated it.
it('OVERRIDE: a call site passing role="alert" keeps it', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7143 — `DataErrorState` gains `icon` / `showIcon` /
* `iconWrapperClassName`, MIRRORED from `DataEmptyState` rather than spelled a
* second way.
*
* `DataErrorState` hardcoded its glyph, which is why `plugin-list` rendered its
* load FAILURE through the component named for the *empty* case: the panel had
* to draw a network outage differently from a permission denial, and only the
* wrong component could take an icon. objectui#7132 fixed the accessibility half
* of that collision (`role`) and left the structural half; the maintainer ruling
* of 2026-09-01 approved the migration and pinned the shape to the empty state's.
*
* "Mirrored" is a claim about SEMANTICS, not just about three identifiers, so
* every arm below runs twice — once on each component — and the pairs assert the
* same thing. The semantics that can silently diverge is
* `iconWrapperClassName`: `DataEmptyState` resolves it with `??`, so it REPLACES
* the default wrapper class rather than merging with it, and `""` is therefore a
* meaningful value that strips the styling. A `cn(default, override)` reading
* would type-check, look right, and quietly keep `bg-destructive/10` under every
* override — including `plugin-list`'s `mb-3`, which exists to remove the box.
*
* SUITE DIRECTION, MEASURED by reverting both source files to the base commit
* and re-running: every `DataErrorState` arm here is RED against `origin/main`.
* The three props do not exist there — they fall into the `...props` spread and
* land on the div as unknown attributes while the hardcoded wrapper renders
* regardless — and that wrapper carries no `data-slot` to select it by. The
* DEFAULTS arm is red for the selector alone, which is the point of keeping it:
* what it pins is that after the change a call site passing none of the three
* still gets the same square and the same glyph it always got.
*
* ⚠️ The `showIcon={false}` arm was written asserting only that the named
* wrapper is ABSENT, and that arm passed on the base — the selector it looks
* for does not exist there either, so "no wrapper" and "no such name" were the
* same reading and the arm could not fail. It now names the glyph as well.
* The same trap is why the DataEmptyState mirror below does both.
*
* GREEN in both worlds, deliberately: the three `DataEmptyState` halves — the
* control that makes "same semantics" a measurement rather than a restatement of
* the new code — and the last arm, which pins that title / message / retry were
* not disturbed by growing the component.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { DataEmptyState, DataErrorState } from '../custom/view-states';

const errorBox = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state"]')!;
const errorIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state-icon"]');
const emptyIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state-icon"]');

const ERROR_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-destructive/10';
const EMPTY_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-muted';

describe('DataErrorState — the icon props mirrored from DataEmptyState (#7143)', () => {
it('DEFAULTS: no props renders the destructive square and its own glyph, unchanged', () => {
const { container } = render(<DataErrorState title="Something went wrong" />);
const box = errorBox(container);
expect(box.getAttribute('role')).toBe('alert');
const icon = errorIcon(container);
expect(icon).not.toBeNull();
expect(icon!.className).toBe(ERROR_WRAPPER_DEFAULT);
// The component's own AlertCircle, not an absent glyph.
expect(icon!.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('Something went wrong');
});

it('`icon` replaces the hardcoded glyph — the reason this migration needed props', () => {
const { container } = render(
<DataErrorState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = errorIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
// Asserted by absence too: a `??` that fell through would render BOTH.
expect(icon.querySelector('svg')).toBeNull();
});

it('MIRROR: `icon` behaves identically on DataEmptyState', () => {
const { container } = render(
<DataEmptyState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = emptyIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
expect(icon.querySelector('svg')).toBeNull();
});

it('`showIcon={false}` omits the container entirely, without collapsing the render', () => {
const { container } = render(<DataErrorState showIcon={false} title="Denied" message="No." />);
expect(errorIcon(container)).toBeNull();
// The absent-wrapper assertion ALONE cannot fail: measured against
// `origin/main`, where the prop does not exist and the wrapper carries no
// `data-slot`, the selector returns null while the hardcoded glyph is right
// there on screen. So the glyph itself is named — no icon means no icon.
expect(errorBox(container).querySelector('svg')).toBeNull();
// Guard against the arm passing over a component that rendered nothing.
expect(container.textContent).toContain('Denied');
expect(container.textContent).toContain('No.');
});

it('MIRROR: `showIcon={false}` behaves identically on DataEmptyState', () => {
const { container } = render(<DataEmptyState showIcon={false} title="Nothing here yet" />);
expect(emptyIcon(container)).toBeNull();
expect(container.querySelector('[data-slot="data-empty-state"]')!.querySelector('svg')).toBeNull();
expect(container.textContent).toContain('Nothing here yet');
});

it('`iconWrapperClassName` REPLACES the default class, it does not merge with it', () => {
const { container } = render(<DataErrorState iconWrapperClassName="mb-3" />);
const icon = errorIcon(container)!;
// Exact value, not `toContain`: merging would also satisfy "contains mb-3",
// and merging is precisely what `plugin-list`'s call site must not get — its
// `mb-3` is there to REMOVE the box, not to nudge it.
expect(icon.className).toBe('mb-3');
expect(icon.className).not.toContain('bg-destructive/10');
});

it('`iconWrapperClassName=""` strips the styling and renders the icon raw', () => {
const { container } = render(<DataErrorState iconWrapperClassName="" />);
const icon = errorIcon(container)!;
expect(icon.className).toBe('');
expect(icon.querySelector('svg')).not.toBeNull();
});

it('MIRROR: both override semantics are identical on DataEmptyState', () => {
const { container: replaced } = render(<DataEmptyState iconWrapperClassName="mb-3" />);
expect(emptyIcon(replaced)!.className).toBe('mb-3');
expect(emptyIcon(replaced)!.className).not.toContain('bg-muted');
const { container: stripped } = render(<DataEmptyState iconWrapperClassName="" />);
expect(emptyIcon(stripped)!.className).toBe('');
// And the empty state's own default is the one it always had, so the two
// components differ by exactly the fallback colour and nothing else.
const { container: bare } = render(<DataEmptyState />);
expect(emptyIcon(bare)!.className).toBe(EMPTY_WRAPPER_DEFAULT);
});

it('the existing surface is untouched: title, message and the retry button', () => {
let clicks = 0;
const { container } = render(
<DataErrorState
title="Couldn’t load"
message="Try again later."
retryLabel="Retry now"
onRetry={() => { clicks += 1; }}
/>,
);
expect(container.textContent).toContain('Couldn’t load');
expect(container.textContent).toContain('Try again later.');
const button = container.querySelector('button')!;
expect(button.textContent).toContain('Retry now');
button.click();
expect(clicks).toBe(1);
});
});
69 changes: 64 additions & 5 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
* so a call site keeps the last word. That is what makes this change inert for
* the two ruled surfaces — both already pass `role="status"` explicitly and
* receive the identical attribute either way — and it is what lets a call site
* rendering something that is NOT empty say so (`plugin-list`'s load-error
* panel borrows this component and declares `role="alert"`).
* rendering something that is NOT empty say so.
*
* The call site that needed that last word was `plugin-list`'s load-failure
* panel, which borrowed this component for its layout. Since objectui#7143 it
* no longer does: `DataErrorState` grew the icon props it was missing and the
* panel is drawn by the component named for what it is. The default stays a
* default for the same reason it always was — a borrow can recur, and a fixed
* attribute would announce the next one as a routine status.
*/
function DataEmptyState({
className,
Expand DownExpand Up@@ -172,6 +178,21 @@ function DataEmptyState({
// ---------------------------------------------------------------------------

interface DataErrorStateProps extends React.ComponentProps<"div"> {
/** Icon rendered above the title */
icon?: React.ReactNode
/**
* When false, the icon container is omitted entirely. Useful for
* banner-style failures that should not draw a glyph at all. Defaults to
* true.
*/
showIcon?: boolean
/**
* Override class on the icon wrapper. By default the wrapper renders as a
* small destructive-tinted rounded square (`size-10 rounded-lg
* bg-destructive/10`). Pass `""` to strip that styling and render the icon
* raw, or extend the look (e.g. larger size).
*/
iconWrapperClassName?: string
title?: string
/** Error message or description */
message?: string
Expand All@@ -181,8 +202,38 @@ interface DataErrorStateProps extends React.ComponentProps<"div"> {
retryLabel?: string
}

/**
* `icon` / `showIcon` / `iconWrapperClassName` MIRROR `DataEmptyState` above —
* same names, same types, same default semantics, including
* `iconWrapperClassName` REPLACING the wrapper's default class rather than
* merging with it, so `""` renders the icon raw (objectui#7143). A second
* spelling of the same three ideas is the defect the mirror exists to prevent;
* the only intended difference is the default class the `??` falls back to,
* which stays this component's own destructive square.
*
* They exist because `plugin-list` rendered its load FAILURE through
* `DataEmptyState` — the component named for the *empty* case — for want of
* them: this one hardcoded its glyph, so the one panel that needed to draw a
* network failure differently from a permission denial could only get that from
* the wrong component. objectui#7132 fixed the accessibility half of that
* collision (`role`) and deliberately left the structural half here.
*
* Deliberately NOT mirrored: `illustration` — an empty state's product-feel hero
* has no failure analogue — and `action`, because this component already spells
* its affordance as `onRetry` / `retryLabel`, with `children` for a call site
* that needs to render its own control.
*
* The icon wrapper carries `data-slot="data-error-state-icon"`, mirroring the
* empty state's `data-empty-state-icon`. Not a prop, and not in the ruling's
* list of three — it is here so the wrapper `iconWrapperClassName` now governs
* can be named by a test and a stylesheet, and so migrating a call site off
* `DataEmptyState` renames that identifier instead of dropping it.
*/
function DataErrorState({
className,
icon,
showIcon = true,
iconWrapperClassName,
title = "Something went wrong",
message,
onRetry,
Expand All@@ -200,9 +251,17 @@ function DataErrorState({
)}
{...props}
>
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-5 text-destructive" />
</div>
{showIcon && (
<div
data-slot="data-error-state-icon"
className={cn(
iconWrapperClassName ??
"flex size-10 items-center justify-center rounded-lg bg-destructive/10"
)}
>
{icon ?? <AlertCircle className="size-5 text-destructive" />}
</div>
)}
{title && (
<h3 className="text-sm font-medium">{title}</h3>
)}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/7143-data-error-state-migration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
---

`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s
load-failure panel is now rendered by the error state instead of the empty state
(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27).

`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for
the *empty* case — passing it a destructive icon, error copy and a retry action, while
`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in
the repo. objectui#7132 closed the accessibility half of that collision (the panel now
declares `role="alert"` over the empty state's `role="status"` default) and deliberately
left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a
props-surface question plus a visual change rather than a rename.

**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored
from `DataEmptyState` in the same file rather than spelled a second way:

- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle`
glyph the component has always drawn.
- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely.
- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than
merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??`
against its own default and this does the same, against
`flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive
square `DataErrorState` already drew.

Same names, same types, same default semantics as the empty state's; nothing existing on
`DataErrorState` changed, and a call site that passes none of the three renders exactly
what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the
ruling pins three props, and this component's retry affordance is already spelled
`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control).

One non-prop addition rides along, called out rather than folded in: the icon wrapper now
carries `data-slot="data-error-state-icon"`, mirroring the empty state's
`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no
name — untestable and unstylable — and migrating a call site off `DataEmptyState` would
DROP that identifier rather than rename it.

**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call
site passes the same custom icon through the new `icon` prop, the same
`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the
error state's spelling of `description`); its retry `<Button>` moves from `action` to
`children`, which renders at the identical position. `role="alert"`, the
`data-testid="list-error-state"` hook and `data-error-kind` are untouched. The whole
rendered delta is two attributes:

- the panel root's `data-slot` becomes `data-error-state` (was `data-empty-state`);
- the icon wrapper's becomes `data-error-state-icon` (was `data-empty-state-icon`).

Both are renames, not removals. Nothing in this repo styles or selects on either — no CSS
rule and no test read them — so a stylesheet in a host app targeting
`[data-slot="data-empty-state"]` to reach *this* panel is the only way to notice, and it
should be reading `data-error-state` now. Every class on every node, and the glyphs
themselves, are byte-identical: this is a visual no-op, deliberately, so the review the
ruling asks for has a small thing to look at rather than a redesign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,11 @@ describe('DataEmptyState — role default (#7132)', () => {
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
// The borrow this arm was written for is gone — `plugin-list`'s load-failure
// panel moved to `DataErrorState` in objectui#7143 — but the mechanism it
// measures is the reason the default is safe to have, so it is kept and
// renamed rather than retired with the call site that motivated it.
it('OVERRIDE: a call site passing role="alert" keeps it', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7143 — `DataErrorState` gains `icon` / `showIcon` /
* `iconWrapperClassName`, MIRRORED from `DataEmptyState` rather than spelled a
* second way.
*
* `DataErrorState` hardcoded its glyph, which is why `plugin-list` rendered its
* load FAILURE through the component named for the *empty* case: the panel had
* to draw a network outage differently from a permission denial, and only the
* wrong component could take an icon. objectui#7132 fixed the accessibility half
* of that collision (`role`) and left the structural half; the maintainer ruling
* of 2026-09-01 approved the migration and pinned the shape to the empty state's.
*
* "Mirrored" is a claim about SEMANTICS, not just about three identifiers, so
* every arm below runs twice — once on each component — and the pairs assert the
* same thing. The semantics that can silently diverge is
* `iconWrapperClassName`: `DataEmptyState` resolves it with `??`, so it REPLACES
* the default wrapper class rather than merging with it, and `""` is therefore a
* meaningful value that strips the styling. A `cn(default, override)` reading
* would type-check, look right, and quietly keep `bg-destructive/10` under every
* override — including `plugin-list`'s `mb-3`, which exists to remove the box.
*
* SUITE DIRECTION, MEASURED by reverting both source files to the base commit
* and re-running: every `DataErrorState` arm here is RED against `origin/main`.
* The three props do not exist there — they fall into the `...props` spread and
* land on the div as unknown attributes while the hardcoded wrapper renders
* regardless — and that wrapper carries no `data-slot` to select it by. The
* DEFAULTS arm is red for the selector alone, which is the point of keeping it:
* what it pins is that after the change a call site passing none of the three
* still gets the same square and the same glyph it always got.
*
* ⚠️ The `showIcon={false}` arm was written asserting only that the named
* wrapper is ABSENT, and that arm passed on the base — the selector it looks
* for does not exist there either, so "no wrapper" and "no such name" were the
* same reading and the arm could not fail. It now names the glyph as well.
* The same trap is why the DataEmptyState mirror below does both.
*
* GREEN in both worlds, deliberately: the three `DataEmptyState` halves — the
* control that makes "same semantics" a measurement rather than a restatement of
* the new code — and the last arm, which pins that title / message / retry were
* not disturbed by growing the component.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { DataEmptyState, DataErrorState } from '../custom/view-states';

const errorBox = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state"]')!;
const errorIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state-icon"]');
const emptyIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state-icon"]');

const ERROR_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-destructive/10';
const EMPTY_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-muted';

describe('DataErrorState — the icon props mirrored from DataEmptyState (#7143)', () => {
it('DEFAULTS: no props renders the destructive square and its own glyph, unchanged', () => {
const { container } = render(<DataErrorState title="Something went wrong" />);
const box = errorBox(container);
expect(box.getAttribute('role')).toBe('alert');
const icon = errorIcon(container);
expect(icon).not.toBeNull();
expect(icon!.className).toBe(ERROR_WRAPPER_DEFAULT);
// The component's own AlertCircle, not an absent glyph.
expect(icon!.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('Something went wrong');
});

it('`icon` replaces the hardcoded glyph — the reason this migration needed props', () => {
const { container } = render(
<DataErrorState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = errorIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
// Asserted by absence too: a `??` that fell through would render BOTH.
expect(icon.querySelector('svg')).toBeNull();
});

it('MIRROR: `icon` behaves identically on DataEmptyState', () => {
const { container } = render(
<DataEmptyState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = emptyIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
expect(icon.querySelector('svg')).toBeNull();
});

it('`showIcon={false}` omits the container entirely, without collapsing the render', () => {
const { container } = render(<DataErrorState showIcon={false} title="Denied" message="No." />);
expect(errorIcon(container)).toBeNull();
// The absent-wrapper assertion ALONE cannot fail: measured against
// `origin/main`, where the prop does not exist and the wrapper carries no
// `data-slot`, the selector returns null while the hardcoded glyph is right
// there on screen. So the glyph itself is named — no icon means no icon.
expect(errorBox(container).querySelector('svg')).toBeNull();
// Guard against the arm passing over a component that rendered nothing.
expect(container.textContent).toContain('Denied');
expect(container.textContent).toContain('No.');
});

it('MIRROR: `showIcon={false}` behaves identically on DataEmptyState', () => {
const { container } = render(<DataEmptyState showIcon={false} title="Nothing here yet" />);
expect(emptyIcon(container)).toBeNull();
expect(container.querySelector('[data-slot="data-empty-state"]')!.querySelector('svg')).toBeNull();
expect(container.textContent).toContain('Nothing here yet');
});

it('`iconWrapperClassName` REPLACES the default class, it does not merge with it', () => {
const { container } = render(<DataErrorState iconWrapperClassName="mb-3" />);
const icon = errorIcon(container)!;
// Exact value, not `toContain`: merging would also satisfy "contains mb-3",
// and merging is precisely what `plugin-list`'s call site must not get — its
// `mb-3` is there to REMOVE the box, not to nudge it.
expect(icon.className).toBe('mb-3');
expect(icon.className).not.toContain('bg-destructive/10');
});

it('`iconWrapperClassName=""` strips the styling and renders the icon raw', () => {
const { container } = render(<DataErrorState iconWrapperClassName="" />);
const icon = errorIcon(container)!;
expect(icon.className).toBe('');
expect(icon.querySelector('svg')).not.toBeNull();
});

it('MIRROR: both override semantics are identical on DataEmptyState', () => {
const { container: replaced } = render(<DataEmptyState iconWrapperClassName="mb-3" />);
expect(emptyIcon(replaced)!.className).toBe('mb-3');
expect(emptyIcon(replaced)!.className).not.toContain('bg-muted');
const { container: stripped } = render(<DataEmptyState iconWrapperClassName="" />);
expect(emptyIcon(stripped)!.className).toBe('');
// And the empty state's own default is the one it always had, so the two
// components differ by exactly the fallback colour and nothing else.
const { container: bare } = render(<DataEmptyState />);
expect(emptyIcon(bare)!.className).toBe(EMPTY_WRAPPER_DEFAULT);
});

it('the existing surface is untouched: title, message and the retry button', () => {
let clicks = 0;
const { container } = render(
<DataErrorState
title="Couldn’t load"
message="Try again later."
retryLabel="Retry now"
onRetry={() => { clicks += 1; }}
/>,
);
expect(container.textContent).toContain('Couldn’t load');
expect(container.textContent).toContain('Try again later.');
const button = container.querySelector('button')!;
expect(button.textContent).toContain('Retry now');
button.click();
expect(clicks).toBe(1);
});
});
69 changes: 64 additions & 5 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
* so a call site keeps the last word. That is what makes this change inert for
* the two ruled surfaces — both already pass `role="status"` explicitly and
* receive the identical attribute either way — and it is what lets a call site
* rendering something that is NOT empty say so (`plugin-list`'s load-error
* panel borrows this component and declares `role="alert"`).
* rendering something that is NOT empty say so.
*
* The call site that needed that last word was `plugin-list`'s load-failure
* panel, which borrowed this component for its layout. Since objectui#7143 it
* no longer does: `DataErrorState` grew the icon props it was missing and the
* panel is drawn by the component named for what it is. The default stays a
* default for the same reason it always was — a borrow can recur, and a fixed
* attribute would announce the next one as a routine status.
*/
function DataEmptyState({
className,
Expand DownExpand Up@@ -172,6 +178,21 @@ function DataEmptyState({
// ---------------------------------------------------------------------------

interface DataErrorStateProps extends React.ComponentProps<"div"> {
/** Icon rendered above the title */
icon?: React.ReactNode
/**
* When false, the icon container is omitted entirely. Useful for
* banner-style failures that should not draw a glyph at all. Defaults to
* true.
*/
showIcon?: boolean
/**
* Override class on the icon wrapper. By default the wrapper renders as a
* small destructive-tinted rounded square (`size-10 rounded-lg
* bg-destructive/10`). Pass `""` to strip that styling and render the icon
* raw, or extend the look (e.g. larger size).
*/
iconWrapperClassName?: string
title?: string
/** Error message or description */
message?: string
Expand All@@ -181,8 +202,38 @@ interface DataErrorStateProps extends React.ComponentProps<"div"> {
retryLabel?: string
}

/**
* `icon` / `showIcon` / `iconWrapperClassName` MIRROR `DataEmptyState` above —
* same names, same types, same default semantics, including
* `iconWrapperClassName` REPLACING the wrapper's default class rather than
* merging with it, so `""` renders the icon raw (objectui#7143). A second
* spelling of the same three ideas is the defect the mirror exists to prevent;
* the only intended difference is the default class the `??` falls back to,
* which stays this component's own destructive square.
*
* They exist because `plugin-list` rendered its load FAILURE through
* `DataEmptyState` — the component named for the *empty* case — for want of
* them: this one hardcoded its glyph, so the one panel that needed to draw a
* network failure differently from a permission denial could only get that from
* the wrong component. objectui#7132 fixed the accessibility half of that
* collision (`role`) and deliberately left the structural half here.
*
* Deliberately NOT mirrored: `illustration` — an empty state's product-feel hero
* has no failure analogue — and `action`, because this component already spells
* its affordance as `onRetry` / `retryLabel`, with `children` for a call site
* that needs to render its own control.
*
* The icon wrapper carries `data-slot="data-error-state-icon"`, mirroring the
* empty state's `data-empty-state-icon`. Not a prop, and not in the ruling's
* list of three — it is here so the wrapper `iconWrapperClassName` now governs
* can be named by a test and a stylesheet, and so migrating a call site off
* `DataEmptyState` renames that identifier instead of dropping it.
*/
function DataErrorState({
className,
icon,
showIcon = true,
iconWrapperClassName,
title = "Something went wrong",
message,
onRetry,
Expand All@@ -200,9 +251,17 @@ function DataErrorState({
)}
{...props}
>
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-5 text-destructive" />
</div>
{showIcon && (
<div
data-slot="data-error-state-icon"
className={cn(
iconWrapperClassName ??
"flex size-10 items-center justify-center rounded-lg bg-destructive/10"
)}
>
{icon ?? <AlertCircle className="size-5 text-destructive" />}
</div>
)}
{title && (
<h3 className="text-sm font-medium">{title}</h3>
)}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/7143-data-error-state-migration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
---

`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s
load-failure panel is now rendered by the error state instead of the empty state
(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27).

`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for
the *empty* case — passing it a destructive icon, error copy and a retry action, while
`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in
the repo. objectui#7132 closed the accessibility half of that collision (the panel now
declares `role="alert"` over the empty state's `role="status"` default) and deliberately
left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a
props-surface question plus a visual change rather than a rename.

**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored
from `DataEmptyState` in the same file rather than spelled a second way:

- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle`
glyph the component has always drawn.
- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely.
- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than
merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??`
against its own default and this does the same, against
`flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive
square `DataErrorState` already drew.

Same names, same types, same default semantics as the empty state's; nothing existing on
`DataErrorState` changed, and a call site that passes none of the three renders exactly
what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the
ruling pins three props, and this component's retry affordance is already spelled
`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control).

One non-prop addition rides along, called out rather than folded in: the icon wrapper now
carries `data-slot="data-error-state-icon"`, mirroring the empty state's
`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no
name — untestable and unstylable — and migrating a call site off `DataEmptyState` would
DROP that identifier rather than rename it.

**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call
site passes the same custom icon through the new `icon` prop, the same
`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the
error state's spelling of `description`); its retry `<Button>` moves from `action` to
`children`, which renders at the identical position. `role="alert"`, the
`data-testid="list-error-state"` hook and `data-error-kind` are untouched. The whole
rendered delta is two attributes:

- the panel root's `data-slot` becomes `data-error-state` (was `data-empty-state`);
- the icon wrapper's becomes `data-error-state-icon` (was `data-empty-state-icon`).

Both are renames, not removals. Nothing in this repo styles or selects on either — no CSS
rule and no test read them — so a stylesheet in a host app targeting
`[data-slot="data-empty-state"]` to reach *this* panel is the only way to notice, and it
should be reading `data-error-state` now. Every class on every node, and the glyphs
themselves, are byte-identical: this is a visual no-op, deliberately, so the review the
ruling asks for has a small thing to look at rather than a redesign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,11 @@ describe('DataEmptyState — role default (#7132)', () => {
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
// The borrow this arm was written for is gone — `plugin-list`'s load-failure
// panel moved to `DataErrorState` in objectui#7143 — but the mechanism it
// measures is the reason the default is safe to have, so it is kept and
// renamed rather than retired with the call site that motivated it.
it('OVERRIDE: a call site passing role="alert" keeps it', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7143 — `DataErrorState` gains `icon` / `showIcon` /
* `iconWrapperClassName`, MIRRORED from `DataEmptyState` rather than spelled a
* second way.
*
* `DataErrorState` hardcoded its glyph, which is why `plugin-list` rendered its
* load FAILURE through the component named for the *empty* case: the panel had
* to draw a network outage differently from a permission denial, and only the
* wrong component could take an icon. objectui#7132 fixed the accessibility half
* of that collision (`role`) and left the structural half; the maintainer ruling
* of 2026-09-01 approved the migration and pinned the shape to the empty state's.
*
* "Mirrored" is a claim about SEMANTICS, not just about three identifiers, so
* every arm below runs twice — once on each component — and the pairs assert the
* same thing. The semantics that can silently diverge is
* `iconWrapperClassName`: `DataEmptyState` resolves it with `??`, so it REPLACES
* the default wrapper class rather than merging with it, and `""` is therefore a
* meaningful value that strips the styling. A `cn(default, override)` reading
* would type-check, look right, and quietly keep `bg-destructive/10` under every
* override — including `plugin-list`'s `mb-3`, which exists to remove the box.
*
* SUITE DIRECTION, MEASURED by reverting both source files to the base commit
* and re-running: every `DataErrorState` arm here is RED against `origin/main`.
* The three props do not exist there — they fall into the `...props` spread and
* land on the div as unknown attributes while the hardcoded wrapper renders
* regardless — and that wrapper carries no `data-slot` to select it by. The
* DEFAULTS arm is red for the selector alone, which is the point of keeping it:
* what it pins is that after the change a call site passing none of the three
* still gets the same square and the same glyph it always got.
*
* ⚠️ The `showIcon={false}` arm was written asserting only that the named
* wrapper is ABSENT, and that arm passed on the base — the selector it looks
* for does not exist there either, so "no wrapper" and "no such name" were the
* same reading and the arm could not fail. It now names the glyph as well.
* The same trap is why the DataEmptyState mirror below does both.
*
* GREEN in both worlds, deliberately: the three `DataEmptyState` halves — the
* control that makes "same semantics" a measurement rather than a restatement of
* the new code — and the last arm, which pins that title / message / retry were
* not disturbed by growing the component.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { DataEmptyState, DataErrorState } from '../custom/view-states';

const errorBox = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state"]')!;
const errorIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state-icon"]');
const emptyIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state-icon"]');

const ERROR_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-destructive/10';
const EMPTY_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-muted';

describe('DataErrorState — the icon props mirrored from DataEmptyState (#7143)', () => {
it('DEFAULTS: no props renders the destructive square and its own glyph, unchanged', () => {
const { container } = render(<DataErrorState title="Something went wrong" />);
const box = errorBox(container);
expect(box.getAttribute('role')).toBe('alert');
const icon = errorIcon(container);
expect(icon).not.toBeNull();
expect(icon!.className).toBe(ERROR_WRAPPER_DEFAULT);
// The component's own AlertCircle, not an absent glyph.
expect(icon!.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('Something went wrong');
});

it('`icon` replaces the hardcoded glyph — the reason this migration needed props', () => {
const { container } = render(
<DataErrorState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = errorIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
// Asserted by absence too: a `??` that fell through would render BOTH.
expect(icon.querySelector('svg')).toBeNull();
});

it('MIRROR: `icon` behaves identically on DataEmptyState', () => {
const { container } = render(
<DataEmptyState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = emptyIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
expect(icon.querySelector('svg')).toBeNull();
});

it('`showIcon={false}` omits the container entirely, without collapsing the render', () => {
const { container } = render(<DataErrorState showIcon={false} title="Denied" message="No." />);
expect(errorIcon(container)).toBeNull();
// The absent-wrapper assertion ALONE cannot fail: measured against
// `origin/main`, where the prop does not exist and the wrapper carries no
// `data-slot`, the selector returns null while the hardcoded glyph is right
// there on screen. So the glyph itself is named — no icon means no icon.
expect(errorBox(container).querySelector('svg')).toBeNull();
// Guard against the arm passing over a component that rendered nothing.
expect(container.textContent).toContain('Denied');
expect(container.textContent).toContain('No.');
});

it('MIRROR: `showIcon={false}` behaves identically on DataEmptyState', () => {
const { container } = render(<DataEmptyState showIcon={false} title="Nothing here yet" />);
expect(emptyIcon(container)).toBeNull();
expect(container.querySelector('[data-slot="data-empty-state"]')!.querySelector('svg')).toBeNull();
expect(container.textContent).toContain('Nothing here yet');
});

it('`iconWrapperClassName` REPLACES the default class, it does not merge with it', () => {
const { container } = render(<DataErrorState iconWrapperClassName="mb-3" />);
const icon = errorIcon(container)!;
// Exact value, not `toContain`: merging would also satisfy "contains mb-3",
// and merging is precisely what `plugin-list`'s call site must not get — its
// `mb-3` is there to REMOVE the box, not to nudge it.
expect(icon.className).toBe('mb-3');
expect(icon.className).not.toContain('bg-destructive/10');
});

it('`iconWrapperClassName=""` strips the styling and renders the icon raw', () => {
const { container } = render(<DataErrorState iconWrapperClassName="" />);
const icon = errorIcon(container)!;
expect(icon.className).toBe('');
expect(icon.querySelector('svg')).not.toBeNull();
});

it('MIRROR: both override semantics are identical on DataEmptyState', () => {
const { container: replaced } = render(<DataEmptyState iconWrapperClassName="mb-3" />);
expect(emptyIcon(replaced)!.className).toBe('mb-3');
expect(emptyIcon(replaced)!.className).not.toContain('bg-muted');
const { container: stripped } = render(<DataEmptyState iconWrapperClassName="" />);
expect(emptyIcon(stripped)!.className).toBe('');
// And the empty state's own default is the one it always had, so the two
// components differ by exactly the fallback colour and nothing else.
const { container: bare } = render(<DataEmptyState />);
expect(emptyIcon(bare)!.className).toBe(EMPTY_WRAPPER_DEFAULT);
});

it('the existing surface is untouched: title, message and the retry button', () => {
let clicks = 0;
const { container } = render(
<DataErrorState
title="Couldn’t load"
message="Try again later."
retryLabel="Retry now"
onRetry={() => { clicks += 1; }}
/>,
);
expect(container.textContent).toContain('Couldn’t load');
expect(container.textContent).toContain('Try again later.');
const button = container.querySelector('button')!;
expect(button.textContent).toContain('Retry now');
button.click();
expect(clicks).toBe(1);
});
});
69 changes: 64 additions & 5 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
* so a call site keeps the last word. That is what makes this change inert for
* the two ruled surfaces — both already pass `role="status"` explicitly and
* receive the identical attribute either way — and it is what lets a call site
* rendering something that is NOT empty say so (`plugin-list`'s load-error
* panel borrows this component and declares `role="alert"`).
* rendering something that is NOT empty say so.
*
* The call site that needed that last word was `plugin-list`'s load-failure
* panel, which borrowed this component for its layout. Since objectui#7143 it
* no longer does: `DataErrorState` grew the icon props it was missing and the
* panel is drawn by the component named for what it is. The default stays a
* default for the same reason it always was — a borrow can recur, and a fixed
* attribute would announce the next one as a routine status.
*/
function DataEmptyState({
className,
Expand DownExpand Up@@ -172,6 +178,21 @@ function DataEmptyState({
// ---------------------------------------------------------------------------

interface DataErrorStateProps extends React.ComponentProps<"div"> {
/** Icon rendered above the title */
icon?: React.ReactNode
/**
* When false, the icon container is omitted entirely. Useful for
* banner-style failures that should not draw a glyph at all. Defaults to
* true.
*/
showIcon?: boolean
/**
* Override class on the icon wrapper. By default the wrapper renders as a
* small destructive-tinted rounded square (`size-10 rounded-lg
* bg-destructive/10`). Pass `""` to strip that styling and render the icon
* raw, or extend the look (e.g. larger size).
*/
iconWrapperClassName?: string
title?: string
/** Error message or description */
message?: string
Expand All@@ -181,8 +202,38 @@ interface DataErrorStateProps extends React.ComponentProps<"div"> {
retryLabel?: string
}

/**
* `icon` / `showIcon` / `iconWrapperClassName` MIRROR `DataEmptyState` above —
* same names, same types, same default semantics, including
* `iconWrapperClassName` REPLACING the wrapper's default class rather than
* merging with it, so `""` renders the icon raw (objectui#7143). A second
* spelling of the same three ideas is the defect the mirror exists to prevent;
* the only intended difference is the default class the `??` falls back to,
* which stays this component's own destructive square.
*
* They exist because `plugin-list` rendered its load FAILURE through
* `DataEmptyState` — the component named for the *empty* case — for want of
* them: this one hardcoded its glyph, so the one panel that needed to draw a
* network failure differently from a permission denial could only get that from
* the wrong component. objectui#7132 fixed the accessibility half of that
* collision (`role`) and deliberately left the structural half here.
*
* Deliberately NOT mirrored: `illustration` — an empty state's product-feel hero
* has no failure analogue — and `action`, because this component already spells
* its affordance as `onRetry` / `retryLabel`, with `children` for a call site
* that needs to render its own control.
*
* The icon wrapper carries `data-slot="data-error-state-icon"`, mirroring the
* empty state's `data-empty-state-icon`. Not a prop, and not in the ruling's
* list of three — it is here so the wrapper `iconWrapperClassName` now governs
* can be named by a test and a stylesheet, and so migrating a call site off
* `DataEmptyState` renames that identifier instead of dropping it.
*/
function DataErrorState({
className,
icon,
showIcon = true,
iconWrapperClassName,
title = "Something went wrong",
message,
onRetry,
Expand All@@ -200,9 +251,17 @@ function DataErrorState({
)}
{...props}
>
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-5 text-destructive" />
</div>
{showIcon && (
<div
data-slot="data-error-state-icon"
className={cn(
iconWrapperClassName ??
"flex size-10 items-center justify-center rounded-lg bg-destructive/10"
)}
>
{icon ?? <AlertCircle className="size-5 text-destructive" />}
</div>
)}
{title && (
<h3 className="text-sm font-medium">{title}</h3>
)}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/7143-data-error-state-migration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
---

`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s
load-failure panel is now rendered by the error state instead of the empty state
(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27).

`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for
the *empty* case — passing it a destructive icon, error copy and a retry action, while
`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in
the repo. objectui#7132 closed the accessibility half of that collision (the panel now
declares `role="alert"` over the empty state's `role="status"` default) and deliberately
left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a
props-surface question plus a visual change rather than a rename.

**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored
from `DataEmptyState` in the same file rather than spelled a second way:

- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle`
glyph the component has always drawn.
- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely.
- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than
merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??`
against its own default and this does the same, against
`flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive
square `DataErrorState` already drew.

Same names, same types, same default semantics as the empty state's; nothing existing on
`DataErrorState` changed, and a call site that passes none of the three renders exactly
what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the
ruling pins three props, and this component's retry affordance is already spelled
`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control).

One non-prop addition rides along, called out rather than folded in: the icon wrapper now
carries `data-slot="data-error-state-icon"`, mirroring the empty state's
`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no
name — untestable and unstylable — and migrating a call site off `DataEmptyState` would
DROP that identifier rather than rename it.

**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call
site passes the same custom icon through the new `icon` prop, the same
`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the
error state's spelling of `description`); its retry `<Button>` moves from `action` to
`children`, which renders at the identical position. `role="alert"`, the
`data-testid="list-error-state"` hook and `data-error-kind` are untouched. The whole
rendered delta is two attributes:

- the panel root's `data-slot` becomes `data-error-state` (was `data-empty-state`);
- the icon wrapper's becomes `data-error-state-icon` (was `data-empty-state-icon`).

Both are renames, not removals. Nothing in this repo styles or selects on either — no CSS
rule and no test read them — so a stylesheet in a host app targeting
`[data-slot="data-empty-state"]` to reach *this* panel is the only way to notice, and it
should be reading `data-error-state` now. Every class on every node, and the glyphs
themselves, are byte-identical: this is a visual no-op, deliberately, so the review the
ruling asks for has a small thing to look at rather than a redesign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,11 @@ describe('DataEmptyState — role default (#7132)', () => {
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
// The borrow this arm was written for is gone — `plugin-list`'s load-failure
// panel moved to `DataErrorState` in objectui#7143 — but the mechanism it
// measures is the reason the default is safe to have, so it is kept and
// renamed rather than retired with the call site that motivated it.
it('OVERRIDE: a call site passing role="alert" keeps it', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7143 — `DataErrorState` gains `icon` / `showIcon` /
* `iconWrapperClassName`, MIRRORED from `DataEmptyState` rather than spelled a
* second way.
*
* `DataErrorState` hardcoded its glyph, which is why `plugin-list` rendered its
* load FAILURE through the component named for the *empty* case: the panel had
* to draw a network outage differently from a permission denial, and only the
* wrong component could take an icon. objectui#7132 fixed the accessibility half
* of that collision (`role`) and left the structural half; the maintainer ruling
* of 2026-09-01 approved the migration and pinned the shape to the empty state's.
*
* "Mirrored" is a claim about SEMANTICS, not just about three identifiers, so
* every arm below runs twice — once on each component — and the pairs assert the
* same thing. The semantics that can silently diverge is
* `iconWrapperClassName`: `DataEmptyState` resolves it with `??`, so it REPLACES
* the default wrapper class rather than merging with it, and `""` is therefore a
* meaningful value that strips the styling. A `cn(default, override)` reading
* would type-check, look right, and quietly keep `bg-destructive/10` under every
* override — including `plugin-list`'s `mb-3`, which exists to remove the box.
*
* SUITE DIRECTION, MEASURED by reverting both source files to the base commit
* and re-running: every `DataErrorState` arm here is RED against `origin/main`.
* The three props do not exist there — they fall into the `...props` spread and
* land on the div as unknown attributes while the hardcoded wrapper renders
* regardless — and that wrapper carries no `data-slot` to select it by. The
* DEFAULTS arm is red for the selector alone, which is the point of keeping it:
* what it pins is that after the change a call site passing none of the three
* still gets the same square and the same glyph it always got.
*
* ⚠️ The `showIcon={false}` arm was written asserting only that the named
* wrapper is ABSENT, and that arm passed on the base — the selector it looks
* for does not exist there either, so "no wrapper" and "no such name" were the
* same reading and the arm could not fail. It now names the glyph as well.
* The same trap is why the DataEmptyState mirror below does both.
*
* GREEN in both worlds, deliberately: the three `DataEmptyState` halves — the
* control that makes "same semantics" a measurement rather than a restatement of
* the new code — and the last arm, which pins that title / message / retry were
* not disturbed by growing the component.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { DataEmptyState, DataErrorState } from '../custom/view-states';

const errorBox = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state"]')!;
const errorIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state-icon"]');
const emptyIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state-icon"]');

const ERROR_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-destructive/10';
const EMPTY_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-muted';

describe('DataErrorState — the icon props mirrored from DataEmptyState (#7143)', () => {
it('DEFAULTS: no props renders the destructive square and its own glyph, unchanged', () => {
const { container } = render(<DataErrorState title="Something went wrong" />);
const box = errorBox(container);
expect(box.getAttribute('role')).toBe('alert');
const icon = errorIcon(container);
expect(icon).not.toBeNull();
expect(icon!.className).toBe(ERROR_WRAPPER_DEFAULT);
// The component's own AlertCircle, not an absent glyph.
expect(icon!.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('Something went wrong');
});

it('`icon` replaces the hardcoded glyph — the reason this migration needed props', () => {
const { container } = render(
<DataErrorState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = errorIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
// Asserted by absence too: a `??` that fell through would render BOTH.
expect(icon.querySelector('svg')).toBeNull();
});

it('MIRROR: `icon` behaves identically on DataEmptyState', () => {
const { container } = render(
<DataEmptyState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = emptyIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
expect(icon.querySelector('svg')).toBeNull();
});

it('`showIcon={false}` omits the container entirely, without collapsing the render', () => {
const { container } = render(<DataErrorState showIcon={false} title="Denied" message="No." />);
expect(errorIcon(container)).toBeNull();
// The absent-wrapper assertion ALONE cannot fail: measured against
// `origin/main`, where the prop does not exist and the wrapper carries no
// `data-slot`, the selector returns null while the hardcoded glyph is right
// there on screen. So the glyph itself is named — no icon means no icon.
expect(errorBox(container).querySelector('svg')).toBeNull();
// Guard against the arm passing over a component that rendered nothing.
expect(container.textContent).toContain('Denied');
expect(container.textContent).toContain('No.');
});

it('MIRROR: `showIcon={false}` behaves identically on DataEmptyState', () => {
const { container } = render(<DataEmptyState showIcon={false} title="Nothing here yet" />);
expect(emptyIcon(container)).toBeNull();
expect(container.querySelector('[data-slot="data-empty-state"]')!.querySelector('svg')).toBeNull();
expect(container.textContent).toContain('Nothing here yet');
});

it('`iconWrapperClassName` REPLACES the default class, it does not merge with it', () => {
const { container } = render(<DataErrorState iconWrapperClassName="mb-3" />);
const icon = errorIcon(container)!;
// Exact value, not `toContain`: merging would also satisfy "contains mb-3",
// and merging is precisely what `plugin-list`'s call site must not get — its
// `mb-3` is there to REMOVE the box, not to nudge it.
expect(icon.className).toBe('mb-3');
expect(icon.className).not.toContain('bg-destructive/10');
});

it('`iconWrapperClassName=""` strips the styling and renders the icon raw', () => {
const { container } = render(<DataErrorState iconWrapperClassName="" />);
const icon = errorIcon(container)!;
expect(icon.className).toBe('');
expect(icon.querySelector('svg')).not.toBeNull();
});

it('MIRROR: both override semantics are identical on DataEmptyState', () => {
const { container: replaced } = render(<DataEmptyState iconWrapperClassName="mb-3" />);
expect(emptyIcon(replaced)!.className).toBe('mb-3');
expect(emptyIcon(replaced)!.className).not.toContain('bg-muted');
const { container: stripped } = render(<DataEmptyState iconWrapperClassName="" />);
expect(emptyIcon(stripped)!.className).toBe('');
// And the empty state's own default is the one it always had, so the two
// components differ by exactly the fallback colour and nothing else.
const { container: bare } = render(<DataEmptyState />);
expect(emptyIcon(bare)!.className).toBe(EMPTY_WRAPPER_DEFAULT);
});

it('the existing surface is untouched: title, message and the retry button', () => {
let clicks = 0;
const { container } = render(
<DataErrorState
title="Couldn’t load"
message="Try again later."
retryLabel="Retry now"
onRetry={() => { clicks += 1; }}
/>,
);
expect(container.textContent).toContain('Couldn’t load');
expect(container.textContent).toContain('Try again later.');
const button = container.querySelector('button')!;
expect(button.textContent).toContain('Retry now');
button.click();
expect(clicks).toBe(1);
});
});
69 changes: 64 additions & 5 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
* so a call site keeps the last word. That is what makes this change inert for
* the two ruled surfaces — both already pass `role="status"` explicitly and
* receive the identical attribute either way — and it is what lets a call site
* rendering something that is NOT empty say so (`plugin-list`'s load-error
* panel borrows this component and declares `role="alert"`).
* rendering something that is NOT empty say so.
*
* The call site that needed that last word was `plugin-list`'s load-failure
* panel, which borrowed this component for its layout. Since objectui#7143 it
* no longer does: `DataErrorState` grew the icon props it was missing and the
* panel is drawn by the component named for what it is. The default stays a
* default for the same reason it always was — a borrow can recur, and a fixed
* attribute would announce the next one as a routine status.
*/
function DataEmptyState({
className,
Expand DownExpand Up@@ -172,6 +178,21 @@ function DataEmptyState({
// ---------------------------------------------------------------------------

interface DataErrorStateProps extends React.ComponentProps<"div"> {
/** Icon rendered above the title */
icon?: React.ReactNode
/**
* When false, the icon container is omitted entirely. Useful for
* banner-style failures that should not draw a glyph at all. Defaults to
* true.
*/
showIcon?: boolean
/**
* Override class on the icon wrapper. By default the wrapper renders as a
* small destructive-tinted rounded square (`size-10 rounded-lg
* bg-destructive/10`). Pass `""` to strip that styling and render the icon
* raw, or extend the look (e.g. larger size).
*/
iconWrapperClassName?: string
title?: string
/** Error message or description */
message?: string
Expand All@@ -181,8 +202,38 @@ interface DataErrorStateProps extends React.ComponentProps<"div"> {
retryLabel?: string
}

/**
* `icon` / `showIcon` / `iconWrapperClassName` MIRROR `DataEmptyState` above —
* same names, same types, same default semantics, including
* `iconWrapperClassName` REPLACING the wrapper's default class rather than
* merging with it, so `""` renders the icon raw (objectui#7143). A second
* spelling of the same three ideas is the defect the mirror exists to prevent;
* the only intended difference is the default class the `??` falls back to,
* which stays this component's own destructive square.
*
* They exist because `plugin-list` rendered its load FAILURE through
* `DataEmptyState` — the component named for the *empty* case — for want of
* them: this one hardcoded its glyph, so the one panel that needed to draw a
* network failure differently from a permission denial could only get that from
* the wrong component. objectui#7132 fixed the accessibility half of that
* collision (`role`) and deliberately left the structural half here.
*
* Deliberately NOT mirrored: `illustration` — an empty state's product-feel hero
* has no failure analogue — and `action`, because this component already spells
* its affordance as `onRetry` / `retryLabel`, with `children` for a call site
* that needs to render its own control.
*
* The icon wrapper carries `data-slot="data-error-state-icon"`, mirroring the
* empty state's `data-empty-state-icon`. Not a prop, and not in the ruling's
* list of three — it is here so the wrapper `iconWrapperClassName` now governs
* can be named by a test and a stylesheet, and so migrating a call site off
* `DataEmptyState` renames that identifier instead of dropping it.
*/
function DataErrorState({
className,
icon,
showIcon = true,
iconWrapperClassName,
title = "Something went wrong",
message,
onRetry,
Expand All@@ -200,9 +251,17 @@ function DataErrorState({
)}
{...props}
>
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-5 text-destructive" />
</div>
{showIcon && (
<div
data-slot="data-error-state-icon"
className={cn(
iconWrapperClassName ??
"flex size-10 items-center justify-center rounded-lg bg-destructive/10"
)}
>
{icon ?? <AlertCircle className="size-5 text-destructive" />}
</div>
)}
{title && (
<h3 className="text-sm font-medium">{title}</h3>
)}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/7143-data-error-state-migration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
---

`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s
load-failure panel is now rendered by the error state instead of the empty state
(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27).

`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for
the *empty* case — passing it a destructive icon, error copy and a retry action, while
`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in
the repo. objectui#7132 closed the accessibility half of that collision (the panel now
declares `role="alert"` over the empty state's `role="status"` default) and deliberately
left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a
props-surface question plus a visual change rather than a rename.

**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored
from `DataEmptyState` in the same file rather than spelled a second way:

- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle`
glyph the component has always drawn.
- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely.
- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than
merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??`
against its own default and this does the same, against
`flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive
square `DataErrorState` already drew.

Same names, same types, same default semantics as the empty state's; nothing existing on
`DataErrorState` changed, and a call site that passes none of the three renders exactly
what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the
ruling pins three props, and this component's retry affordance is already spelled
`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control).

One non-prop addition rides along, called out rather than folded in: the icon wrapper now
carries `data-slot="data-error-state-icon"`, mirroring the empty state's
`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no
name — untestable and unstylable — and migrating a call site off `DataEmptyState` would
DROP that identifier rather than rename it.

**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call
site passes the same custom icon through the new `icon` prop, the same
`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the
error state's spelling of `description`); its retry `<Button>` moves from `action` to
`children`, which renders at the identical position. `role="alert"`, the
`data-testid="list-error-state"` hook and `data-error-kind` are untouched. The whole
rendered delta is two attributes:

- the panel root's `data-slot` becomes `data-error-state` (was `data-empty-state`);
- the icon wrapper's becomes `data-error-state-icon` (was `data-empty-state-icon`).

Both are renames, not removals. Nothing in this repo styles or selects on either — no CSS
rule and no test read them — so a stylesheet in a host app targeting
`[data-slot="data-empty-state"]` to reach *this* panel is the only way to notice, and it
should be reading `data-error-state` now. Every class on every node, and the glyphs
themselves, are byte-identical: this is a visual no-op, deliberately, so the review the
ruling asks for has a small thing to look at rather than a redesign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,11 @@ describe('DataEmptyState — role default (#7132)', () => {
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
// The borrow this arm was written for is gone — `plugin-list`'s load-failure
// panel moved to `DataErrorState` in objectui#7143 — but the mechanism it
// measures is the reason the default is safe to have, so it is kept and
// renamed rather than retired with the call site that motivated it.
it('OVERRIDE: a call site passing role="alert" keeps it', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7143 — `DataErrorState` gains `icon` / `showIcon` /
* `iconWrapperClassName`, MIRRORED from `DataEmptyState` rather than spelled a
* second way.
*
* `DataErrorState` hardcoded its glyph, which is why `plugin-list` rendered its
* load FAILURE through the component named for the *empty* case: the panel had
* to draw a network outage differently from a permission denial, and only the
* wrong component could take an icon. objectui#7132 fixed the accessibility half
* of that collision (`role`) and left the structural half; the maintainer ruling
* of 2026-09-01 approved the migration and pinned the shape to the empty state's.
*
* "Mirrored" is a claim about SEMANTICS, not just about three identifiers, so
* every arm below runs twice — once on each component — and the pairs assert the
* same thing. The semantics that can silently diverge is
* `iconWrapperClassName`: `DataEmptyState` resolves it with `??`, so it REPLACES
* the default wrapper class rather than merging with it, and `""` is therefore a
* meaningful value that strips the styling. A `cn(default, override)` reading
* would type-check, look right, and quietly keep `bg-destructive/10` under every
* override — including `plugin-list`'s `mb-3`, which exists to remove the box.
*
* SUITE DIRECTION, MEASURED by reverting both source files to the base commit
* and re-running: every `DataErrorState` arm here is RED against `origin/main`.
* The three props do not exist there — they fall into the `...props` spread and
* land on the div as unknown attributes while the hardcoded wrapper renders
* regardless — and that wrapper carries no `data-slot` to select it by. The
* DEFAULTS arm is red for the selector alone, which is the point of keeping it:
* what it pins is that after the change a call site passing none of the three
* still gets the same square and the same glyph it always got.
*
* ⚠️ The `showIcon={false}` arm was written asserting only that the named
* wrapper is ABSENT, and that arm passed on the base — the selector it looks
* for does not exist there either, so "no wrapper" and "no such name" were the
* same reading and the arm could not fail. It now names the glyph as well.
* The same trap is why the DataEmptyState mirror below does both.
*
* GREEN in both worlds, deliberately: the three `DataEmptyState` halves — the
* control that makes "same semantics" a measurement rather than a restatement of
* the new code — and the last arm, which pins that title / message / retry were
* not disturbed by growing the component.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { DataEmptyState, DataErrorState } from '../custom/view-states';

const errorBox = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state"]')!;
const errorIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state-icon"]');
const emptyIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state-icon"]');

const ERROR_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-destructive/10';
const EMPTY_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-muted';

describe('DataErrorState — the icon props mirrored from DataEmptyState (#7143)', () => {
it('DEFAULTS: no props renders the destructive square and its own glyph, unchanged', () => {
const { container } = render(<DataErrorState title="Something went wrong" />);
const box = errorBox(container);
expect(box.getAttribute('role')).toBe('alert');
const icon = errorIcon(container);
expect(icon).not.toBeNull();
expect(icon!.className).toBe(ERROR_WRAPPER_DEFAULT);
// The component's own AlertCircle, not an absent glyph.
expect(icon!.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('Something went wrong');
});

it('`icon` replaces the hardcoded glyph — the reason this migration needed props', () => {
const { container } = render(
<DataErrorState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = errorIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
// Asserted by absence too: a `??` that fell through would render BOTH.
expect(icon.querySelector('svg')).toBeNull();
});

it('MIRROR: `icon` behaves identically on DataEmptyState', () => {
const { container } = render(
<DataEmptyState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = emptyIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
expect(icon.querySelector('svg')).toBeNull();
});

it('`showIcon={false}` omits the container entirely, without collapsing the render', () => {
const { container } = render(<DataErrorState showIcon={false} title="Denied" message="No." />);
expect(errorIcon(container)).toBeNull();
// The absent-wrapper assertion ALONE cannot fail: measured against
// `origin/main`, where the prop does not exist and the wrapper carries no
// `data-slot`, the selector returns null while the hardcoded glyph is right
// there on screen. So the glyph itself is named — no icon means no icon.
expect(errorBox(container).querySelector('svg')).toBeNull();
// Guard against the arm passing over a component that rendered nothing.
expect(container.textContent).toContain('Denied');
expect(container.textContent).toContain('No.');
});

it('MIRROR: `showIcon={false}` behaves identically on DataEmptyState', () => {
const { container } = render(<DataEmptyState showIcon={false} title="Nothing here yet" />);
expect(emptyIcon(container)).toBeNull();
expect(container.querySelector('[data-slot="data-empty-state"]')!.querySelector('svg')).toBeNull();
expect(container.textContent).toContain('Nothing here yet');
});

it('`iconWrapperClassName` REPLACES the default class, it does not merge with it', () => {
const { container } = render(<DataErrorState iconWrapperClassName="mb-3" />);
const icon = errorIcon(container)!;
// Exact value, not `toContain`: merging would also satisfy "contains mb-3",
// and merging is precisely what `plugin-list`'s call site must not get — its
// `mb-3` is there to REMOVE the box, not to nudge it.
expect(icon.className).toBe('mb-3');
expect(icon.className).not.toContain('bg-destructive/10');
});

it('`iconWrapperClassName=""` strips the styling and renders the icon raw', () => {
const { container } = render(<DataErrorState iconWrapperClassName="" />);
const icon = errorIcon(container)!;
expect(icon.className).toBe('');
expect(icon.querySelector('svg')).not.toBeNull();
});

it('MIRROR: both override semantics are identical on DataEmptyState', () => {
const { container: replaced } = render(<DataEmptyState iconWrapperClassName="mb-3" />);
expect(emptyIcon(replaced)!.className).toBe('mb-3');
expect(emptyIcon(replaced)!.className).not.toContain('bg-muted');
const { container: stripped } = render(<DataEmptyState iconWrapperClassName="" />);
expect(emptyIcon(stripped)!.className).toBe('');
// And the empty state's own default is the one it always had, so the two
// components differ by exactly the fallback colour and nothing else.
const { container: bare } = render(<DataEmptyState />);
expect(emptyIcon(bare)!.className).toBe(EMPTY_WRAPPER_DEFAULT);
});

it('the existing surface is untouched: title, message and the retry button', () => {
let clicks = 0;
const { container } = render(
<DataErrorState
title="Couldn’t load"
message="Try again later."
retryLabel="Retry now"
onRetry={() => { clicks += 1; }}
/>,
);
expect(container.textContent).toContain('Couldn’t load');
expect(container.textContent).toContain('Try again later.');
const button = container.querySelector('button')!;
expect(button.textContent).toContain('Retry now');
button.click();
expect(clicks).toBe(1);
});
});
69 changes: 64 additions & 5 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
* so a call site keeps the last word. That is what makes this change inert for
* the two ruled surfaces — both already pass `role="status"` explicitly and
* receive the identical attribute either way — and it is what lets a call site
* rendering something that is NOT empty say so (`plugin-list`'s load-error
* panel borrows this component and declares `role="alert"`).
* rendering something that is NOT empty say so.
*
* The call site that needed that last word was `plugin-list`'s load-failure
* panel, which borrowed this component for its layout. Since objectui#7143 it
* no longer does: `DataErrorState` grew the icon props it was missing and the
* panel is drawn by the component named for what it is. The default stays a
* default for the same reason it always was — a borrow can recur, and a fixed
* attribute would announce the next one as a routine status.
*/
function DataEmptyState({
className,
Expand DownExpand Up@@ -172,6 +178,21 @@ function DataEmptyState({
// ---------------------------------------------------------------------------

interface DataErrorStateProps extends React.ComponentProps<"div"> {
/** Icon rendered above the title */
icon?: React.ReactNode
/**
* When false, the icon container is omitted entirely. Useful for
* banner-style failures that should not draw a glyph at all. Defaults to
* true.
*/
showIcon?: boolean
/**
* Override class on the icon wrapper. By default the wrapper renders as a
* small destructive-tinted rounded square (`size-10 rounded-lg
* bg-destructive/10`). Pass `""` to strip that styling and render the icon
* raw, or extend the look (e.g. larger size).
*/
iconWrapperClassName?: string
title?: string
/** Error message or description */
message?: string
Expand All@@ -181,8 +202,38 @@ interface DataErrorStateProps extends React.ComponentProps<"div"> {
retryLabel?: string
}

/**
* `icon` / `showIcon` / `iconWrapperClassName` MIRROR `DataEmptyState` above —
* same names, same types, same default semantics, including
* `iconWrapperClassName` REPLACING the wrapper's default class rather than
* merging with it, so `""` renders the icon raw (objectui#7143). A second
* spelling of the same three ideas is the defect the mirror exists to prevent;
* the only intended difference is the default class the `??` falls back to,
* which stays this component's own destructive square.
*
* They exist because `plugin-list` rendered its load FAILURE through
* `DataEmptyState` — the component named for the *empty* case — for want of
* them: this one hardcoded its glyph, so the one panel that needed to draw a
* network failure differently from a permission denial could only get that from
* the wrong component. objectui#7132 fixed the accessibility half of that
* collision (`role`) and deliberately left the structural half here.
*
* Deliberately NOT mirrored: `illustration` — an empty state's product-feel hero
* has no failure analogue — and `action`, because this component already spells
* its affordance as `onRetry` / `retryLabel`, with `children` for a call site
* that needs to render its own control.
*
* The icon wrapper carries `data-slot="data-error-state-icon"`, mirroring the
* empty state's `data-empty-state-icon`. Not a prop, and not in the ruling's
* list of three — it is here so the wrapper `iconWrapperClassName` now governs
* can be named by a test and a stylesheet, and so migrating a call site off
* `DataEmptyState` renames that identifier instead of dropping it.
*/
function DataErrorState({
className,
icon,
showIcon = true,
iconWrapperClassName,
title = "Something went wrong",
message,
onRetry,
Expand All@@ -200,9 +251,17 @@ function DataErrorState({
)}
{...props}
>
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-5 text-destructive" />
</div>
{showIcon && (
<div
data-slot="data-error-state-icon"
className={cn(
iconWrapperClassName ??
"flex size-10 items-center justify-center rounded-lg bg-destructive/10"
)}
>
{icon ?? <AlertCircle className="size-5 text-destructive" />}
</div>
)}
{title && (
<h3 className="text-sm font-medium">{title}</h3>
)}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/7143-data-error-state-migration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
---

`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s
load-failure panel is now rendered by the error state instead of the empty state
(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27).

`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for
the *empty* case — passing it a destructive icon, error copy and a retry action, while
`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in
the repo. objectui#7132 closed the accessibility half of that collision (the panel now
declares `role="alert"` over the empty state's `role="status"` default) and deliberately
left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a
props-surface question plus a visual change rather than a rename.

**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored
from `DataEmptyState` in the same file rather than spelled a second way:

- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle`
glyph the component has always drawn.
- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely.
- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than
merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??`
against its own default and this does the same, against
`flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive
square `DataErrorState` already drew.

Same names, same types, same default semantics as the empty state's; nothing existing on
`DataErrorState` changed, and a call site that passes none of the three renders exactly
what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the
ruling pins three props, and this component's retry affordance is already spelled
`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control).

One non-prop addition rides along, called out rather than folded in: the icon wrapper now
carries `data-slot="data-error-state-icon"`, mirroring the empty state's
`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no
name — untestable and unstylable — and migrating a call site off `DataEmptyState` would
DROP that identifier rather than rename it.

**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call
site passes the same custom icon through the new `icon` prop, the same
`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the
error state's spelling of `description`); its retry `<Button>` moves from `action` to
`children`, which renders at the identical position. `role="alert"`, the
`data-testid="list-error-state"` hook and `data-error-kind` are untouched. The whole
rendered delta is two attributes:

- the panel root's `data-slot` becomes `data-error-state` (was `data-empty-state`);
- the icon wrapper's becomes `data-error-state-icon` (was `data-empty-state-icon`).

Both are renames, not removals. Nothing in this repo styles or selects on either — no CSS
rule and no test read them — so a stylesheet in a host app targeting
`[data-slot="data-empty-state"]` to reach *this* panel is the only way to notice, and it
should be reading `data-error-state` now. Every class on every node, and the glyphs
themselves, are byte-identical: this is a visual no-op, deliberately, so the review the
ruling asks for has a small thing to look at rather than a redesign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,11 @@ describe('DataEmptyState — role default (#7132)', () => {
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
// The borrow this arm was written for is gone — `plugin-list`'s load-failure
// panel moved to `DataErrorState` in objectui#7143 — but the mechanism it
// measures is the reason the default is safe to have, so it is kept and
// renamed rather than retired with the call site that motivated it.
it('OVERRIDE: a call site passing role="alert" keeps it', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7143 — `DataErrorState` gains `icon` / `showIcon` /
* `iconWrapperClassName`, MIRRORED from `DataEmptyState` rather than spelled a
* second way.
*
* `DataErrorState` hardcoded its glyph, which is why `plugin-list` rendered its
* load FAILURE through the component named for the *empty* case: the panel had
* to draw a network outage differently from a permission denial, and only the
* wrong component could take an icon. objectui#7132 fixed the accessibility half
* of that collision (`role`) and left the structural half; the maintainer ruling
* of 2026-09-01 approved the migration and pinned the shape to the empty state's.
*
* "Mirrored" is a claim about SEMANTICS, not just about three identifiers, so
* every arm below runs twice — once on each component — and the pairs assert the
* same thing. The semantics that can silently diverge is
* `iconWrapperClassName`: `DataEmptyState` resolves it with `??`, so it REPLACES
* the default wrapper class rather than merging with it, and `""` is therefore a
* meaningful value that strips the styling. A `cn(default, override)` reading
* would type-check, look right, and quietly keep `bg-destructive/10` under every
* override — including `plugin-list`'s `mb-3`, which exists to remove the box.
*
* SUITE DIRECTION, MEASURED by reverting both source files to the base commit
* and re-running: every `DataErrorState` arm here is RED against `origin/main`.
* The three props do not exist there — they fall into the `...props` spread and
* land on the div as unknown attributes while the hardcoded wrapper renders
* regardless — and that wrapper carries no `data-slot` to select it by. The
* DEFAULTS arm is red for the selector alone, which is the point of keeping it:
* what it pins is that after the change a call site passing none of the three
* still gets the same square and the same glyph it always got.
*
* ⚠️ The `showIcon={false}` arm was written asserting only that the named
* wrapper is ABSENT, and that arm passed on the base — the selector it looks
* for does not exist there either, so "no wrapper" and "no such name" were the
* same reading and the arm could not fail. It now names the glyph as well.
* The same trap is why the DataEmptyState mirror below does both.
*
* GREEN in both worlds, deliberately: the three `DataEmptyState` halves — the
* control that makes "same semantics" a measurement rather than a restatement of
* the new code — and the last arm, which pins that title / message / retry were
* not disturbed by growing the component.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { DataEmptyState, DataErrorState } from '../custom/view-states';

const errorBox = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state"]')!;
const errorIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state-icon"]');
const emptyIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state-icon"]');

const ERROR_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-destructive/10';
const EMPTY_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-muted';

describe('DataErrorState — the icon props mirrored from DataEmptyState (#7143)', () => {
it('DEFAULTS: no props renders the destructive square and its own glyph, unchanged', () => {
const { container } = render(<DataErrorState title="Something went wrong" />);
const box = errorBox(container);
expect(box.getAttribute('role')).toBe('alert');
const icon = errorIcon(container);
expect(icon).not.toBeNull();
expect(icon!.className).toBe(ERROR_WRAPPER_DEFAULT);
// The component's own AlertCircle, not an absent glyph.
expect(icon!.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('Something went wrong');
});

it('`icon` replaces the hardcoded glyph — the reason this migration needed props', () => {
const { container } = render(
<DataErrorState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = errorIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
// Asserted by absence too: a `??` that fell through would render BOTH.
expect(icon.querySelector('svg')).toBeNull();
});

it('MIRROR: `icon` behaves identically on DataEmptyState', () => {
const { container } = render(
<DataEmptyState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = emptyIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
expect(icon.querySelector('svg')).toBeNull();
});

it('`showIcon={false}` omits the container entirely, without collapsing the render', () => {
const { container } = render(<DataErrorState showIcon={false} title="Denied" message="No." />);
expect(errorIcon(container)).toBeNull();
// The absent-wrapper assertion ALONE cannot fail: measured against
// `origin/main`, where the prop does not exist and the wrapper carries no
// `data-slot`, the selector returns null while the hardcoded glyph is right
// there on screen. So the glyph itself is named — no icon means no icon.
expect(errorBox(container).querySelector('svg')).toBeNull();
// Guard against the arm passing over a component that rendered nothing.
expect(container.textContent).toContain('Denied');
expect(container.textContent).toContain('No.');
});

it('MIRROR: `showIcon={false}` behaves identically on DataEmptyState', () => {
const { container } = render(<DataEmptyState showIcon={false} title="Nothing here yet" />);
expect(emptyIcon(container)).toBeNull();
expect(container.querySelector('[data-slot="data-empty-state"]')!.querySelector('svg')).toBeNull();
expect(container.textContent).toContain('Nothing here yet');
});

it('`iconWrapperClassName` REPLACES the default class, it does not merge with it', () => {
const { container } = render(<DataErrorState iconWrapperClassName="mb-3" />);
const icon = errorIcon(container)!;
// Exact value, not `toContain`: merging would also satisfy "contains mb-3",
// and merging is precisely what `plugin-list`'s call site must not get — its
// `mb-3` is there to REMOVE the box, not to nudge it.
expect(icon.className).toBe('mb-3');
expect(icon.className).not.toContain('bg-destructive/10');
});

it('`iconWrapperClassName=""` strips the styling and renders the icon raw', () => {
const { container } = render(<DataErrorState iconWrapperClassName="" />);
const icon = errorIcon(container)!;
expect(icon.className).toBe('');
expect(icon.querySelector('svg')).not.toBeNull();
});

it('MIRROR: both override semantics are identical on DataEmptyState', () => {
const { container: replaced } = render(<DataEmptyState iconWrapperClassName="mb-3" />);
expect(emptyIcon(replaced)!.className).toBe('mb-3');
expect(emptyIcon(replaced)!.className).not.toContain('bg-muted');
const { container: stripped } = render(<DataEmptyState iconWrapperClassName="" />);
expect(emptyIcon(stripped)!.className).toBe('');
// And the empty state's own default is the one it always had, so the two
// components differ by exactly the fallback colour and nothing else.
const { container: bare } = render(<DataEmptyState />);
expect(emptyIcon(bare)!.className).toBe(EMPTY_WRAPPER_DEFAULT);
});

it('the existing surface is untouched: title, message and the retry button', () => {
let clicks = 0;
const { container } = render(
<DataErrorState
title="Couldn’t load"
message="Try again later."
retryLabel="Retry now"
onRetry={() => { clicks += 1; }}
/>,
);
expect(container.textContent).toContain('Couldn’t load');
expect(container.textContent).toContain('Try again later.');
const button = container.querySelector('button')!;
expect(button.textContent).toContain('Retry now');
button.click();
expect(clicks).toBe(1);
});
});
69 changes: 64 additions & 5 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
* so a call site keeps the last word. That is what makes this change inert for
* the two ruled surfaces — both already pass `role="status"` explicitly and
* receive the identical attribute either way — and it is what lets a call site
* rendering something that is NOT empty say so (`plugin-list`'s load-error
* panel borrows this component and declares `role="alert"`).
* rendering something that is NOT empty say so.
*
* The call site that needed that last word was `plugin-list`'s load-failure
* panel, which borrowed this component for its layout. Since objectui#7143 it
* no longer does: `DataErrorState` grew the icon props it was missing and the
* panel is drawn by the component named for what it is. The default stays a
* default for the same reason it always was — a borrow can recur, and a fixed
* attribute would announce the next one as a routine status.
*/
function DataEmptyState({
className,
Expand DownExpand Up@@ -172,6 +178,21 @@ function DataEmptyState({
// ---------------------------------------------------------------------------

interface DataErrorStateProps extends React.ComponentProps<"div"> {
/** Icon rendered above the title */
icon?: React.ReactNode
/**
* When false, the icon container is omitted entirely. Useful for
* banner-style failures that should not draw a glyph at all. Defaults to
* true.
*/
showIcon?: boolean
/**
* Override class on the icon wrapper. By default the wrapper renders as a
* small destructive-tinted rounded square (`size-10 rounded-lg
* bg-destructive/10`). Pass `""` to strip that styling and render the icon
* raw, or extend the look (e.g. larger size).
*/
iconWrapperClassName?: string
title?: string
/** Error message or description */
message?: string
Expand All@@ -181,8 +202,38 @@ interface DataErrorStateProps extends React.ComponentProps<"div"> {
retryLabel?: string
}

/**
* `icon` / `showIcon` / `iconWrapperClassName` MIRROR `DataEmptyState` above —
* same names, same types, same default semantics, including
* `iconWrapperClassName` REPLACING the wrapper's default class rather than
* merging with it, so `""` renders the icon raw (objectui#7143). A second
* spelling of the same three ideas is the defect the mirror exists to prevent;
* the only intended difference is the default class the `??` falls back to,
* which stays this component's own destructive square.
*
* They exist because `plugin-list` rendered its load FAILURE through
* `DataEmptyState` — the component named for the *empty* case — for want of
* them: this one hardcoded its glyph, so the one panel that needed to draw a
* network failure differently from a permission denial could only get that from
* the wrong component. objectui#7132 fixed the accessibility half of that
* collision (`role`) and deliberately left the structural half here.
*
* Deliberately NOT mirrored: `illustration` — an empty state's product-feel hero
* has no failure analogue — and `action`, because this component already spells
* its affordance as `onRetry` / `retryLabel`, with `children` for a call site
* that needs to render its own control.
*
* The icon wrapper carries `data-slot="data-error-state-icon"`, mirroring the
* empty state's `data-empty-state-icon`. Not a prop, and not in the ruling's
* list of three — it is here so the wrapper `iconWrapperClassName` now governs
* can be named by a test and a stylesheet, and so migrating a call site off
* `DataEmptyState` renames that identifier instead of dropping it.
*/
function DataErrorState({
className,
icon,
showIcon = true,
iconWrapperClassName,
title = "Something went wrong",
message,
onRetry,
Expand All@@ -200,9 +251,17 @@ function DataErrorState({
)}
{...props}
>
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-5 text-destructive" />
</div>
{showIcon && (
<div
data-slot="data-error-state-icon"
className={cn(
iconWrapperClassName ??
"flex size-10 items-center justify-center rounded-lg bg-destructive/10"
)}
>
{icon ?? <AlertCircle className="size-5 text-destructive" />}
</div>
)}
{title && (
<h3 className="text-sm font-medium">{title}</h3>
)}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/7143-data-error-state-migration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
---

`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s
load-failure panel is now rendered by the error state instead of the empty state
(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27).

`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for
the *empty* case — passing it a destructive icon, error copy and a retry action, while
`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in
the repo. objectui#7132 closed the accessibility half of that collision (the panel now
declares `role="alert"` over the empty state's `role="status"` default) and deliberately
left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a
props-surface question plus a visual change rather than a rename.

**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored
from `DataEmptyState` in the same file rather than spelled a second way:

- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle`
glyph the component has always drawn.
- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely.
- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than
merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??`
against its own default and this does the same, against
`flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive
square `DataErrorState` already drew.

Same names, same types, same default semantics as the empty state's; nothing existing on
`DataErrorState` changed, and a call site that passes none of the three renders exactly
what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the
ruling pins three props, and this component's retry affordance is already spelled
`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control).

One non-prop addition rides along, called out rather than folded in: the icon wrapper now
carries `data-slot="data-error-state-icon"`, mirroring the empty state's
`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no
name — untestable and unstylable — and migrating a call site off `DataEmptyState` would
DROP that identifier rather than rename it.

**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call
site passes the same custom icon through the new `icon` prop, the same
`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the
error state's spelling of `description`); its retry `<Button>` moves from `action` to
`children`, which renders at the identical position. `role="alert"`, the
`data-testid="list-error-state"` hook and `data-error-kind` are untouched. The whole
rendered delta is two attributes:

- the panel root's `data-slot` becomes `data-error-state` (was `data-empty-state`);
- the icon wrapper's becomes `data-error-state-icon` (was `data-empty-state-icon`).

Both are renames, not removals. Nothing in this repo styles or selects on either — no CSS
rule and no test read them — so a stylesheet in a host app targeting
`[data-slot="data-empty-state"]` to reach *this* panel is the only way to notice, and it
should be reading `data-error-state` now. Every class on every node, and the glyphs
themselves, are byte-identical: this is a visual no-op, deliberately, so the review the
ruling asks for has a small thing to look at rather than a redesign.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,11 @@ describe('DataEmptyState — role default (#7132)', () => {
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
// The borrow this arm was written for is gone — `plugin-list`'s load-failure
// panel moved to `DataErrorState` in objectui#7143 — but the mechanism it
// measures is the reason the default is safe to have, so it is kept and
// renamed rather than retired with the call site that motivated it.
it('OVERRIDE: a call site passing role="alert" keeps it', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7143 — `DataErrorState` gains `icon` / `showIcon` /
* `iconWrapperClassName`, MIRRORED from `DataEmptyState` rather than spelled a
* second way.
*
* `DataErrorState` hardcoded its glyph, which is why `plugin-list` rendered its
* load FAILURE through the component named for the *empty* case: the panel had
* to draw a network outage differently from a permission denial, and only the
* wrong component could take an icon. objectui#7132 fixed the accessibility half
* of that collision (`role`) and left the structural half; the maintainer ruling
* of 2026-09-01 approved the migration and pinned the shape to the empty state's.
*
* "Mirrored" is a claim about SEMANTICS, not just about three identifiers, so
* every arm below runs twice — once on each component — and the pairs assert the
* same thing. The semantics that can silently diverge is
* `iconWrapperClassName`: `DataEmptyState` resolves it with `??`, so it REPLACES
* the default wrapper class rather than merging with it, and `""` is therefore a
* meaningful value that strips the styling. A `cn(default, override)` reading
* would type-check, look right, and quietly keep `bg-destructive/10` under every
* override — including `plugin-list`'s `mb-3`, which exists to remove the box.
*
* SUITE DIRECTION, MEASURED by reverting both source files to the base commit
* and re-running: every `DataErrorState` arm here is RED against `origin/main`.
* The three props do not exist there — they fall into the `...props` spread and
* land on the div as unknown attributes while the hardcoded wrapper renders
* regardless — and that wrapper carries no `data-slot` to select it by. The
* DEFAULTS arm is red for the selector alone, which is the point of keeping it:
* what it pins is that after the change a call site passing none of the three
* still gets the same square and the same glyph it always got.
*
* ⚠️ The `showIcon={false}` arm was written asserting only that the named
* wrapper is ABSENT, and that arm passed on the base — the selector it looks
* for does not exist there either, so "no wrapper" and "no such name" were the
* same reading and the arm could not fail. It now names the glyph as well.
* The same trap is why the DataEmptyState mirror below does both.
*
* GREEN in both worlds, deliberately: the three `DataEmptyState` halves — the
* control that makes "same semantics" a measurement rather than a restatement of
* the new code — and the last arm, which pins that title / message / retry were
* not disturbed by growing the component.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { DataEmptyState, DataErrorState } from '../custom/view-states';

const errorBox = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state"]')!;
const errorIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-error-state-icon"]');
const emptyIcon = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state-icon"]');

const ERROR_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-destructive/10';
const EMPTY_WRAPPER_DEFAULT = 'flex size-10 items-center justify-center rounded-lg bg-muted';

describe('DataErrorState — the icon props mirrored from DataEmptyState (#7143)', () => {
it('DEFAULTS: no props renders the destructive square and its own glyph, unchanged', () => {
const { container } = render(<DataErrorState title="Something went wrong" />);
const box = errorBox(container);
expect(box.getAttribute('role')).toBe('alert');
const icon = errorIcon(container);
expect(icon).not.toBeNull();
expect(icon!.className).toBe(ERROR_WRAPPER_DEFAULT);
// The component's own AlertCircle, not an absent glyph.
expect(icon!.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('Something went wrong');
});

it('`icon` replaces the hardcoded glyph — the reason this migration needed props', () => {
const { container } = render(
<DataErrorState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = errorIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
// Asserted by absence too: a `??` that fell through would render BOTH.
expect(icon.querySelector('svg')).toBeNull();
});

it('MIRROR: `icon` behaves identically on DataEmptyState', () => {
const { container } = render(
<DataEmptyState icon={<span data-testid="custom-glyph">!</span>} />,
);
const icon = emptyIcon(container)!;
expect(icon.querySelector('[data-testid="custom-glyph"]')).not.toBeNull();
expect(icon.querySelector('svg')).toBeNull();
});

it('`showIcon={false}` omits the container entirely, without collapsing the render', () => {
const { container } = render(<DataErrorState showIcon={false} title="Denied" message="No." />);
expect(errorIcon(container)).toBeNull();
// The absent-wrapper assertion ALONE cannot fail: measured against
// `origin/main`, where the prop does not exist and the wrapper carries no
// `data-slot`, the selector returns null while the hardcoded glyph is right
// there on screen. So the glyph itself is named — no icon means no icon.
expect(errorBox(container).querySelector('svg')).toBeNull();
// Guard against the arm passing over a component that rendered nothing.
expect(container.textContent).toContain('Denied');
expect(container.textContent).toContain('No.');
});

it('MIRROR: `showIcon={false}` behaves identically on DataEmptyState', () => {
const { container } = render(<DataEmptyState showIcon={false} title="Nothing here yet" />);
expect(emptyIcon(container)).toBeNull();
expect(container.querySelector('[data-slot="data-empty-state"]')!.querySelector('svg')).toBeNull();
expect(container.textContent).toContain('Nothing here yet');
});

it('`iconWrapperClassName` REPLACES the default class, it does not merge with it', () => {
const { container } = render(<DataErrorState iconWrapperClassName="mb-3" />);
const icon = errorIcon(container)!;
// Exact value, not `toContain`: merging would also satisfy "contains mb-3",
// and merging is precisely what `plugin-list`'s call site must not get — its
// `mb-3` is there to REMOVE the box, not to nudge it.
expect(icon.className).toBe('mb-3');
expect(icon.className).not.toContain('bg-destructive/10');
});

it('`iconWrapperClassName=""` strips the styling and renders the icon raw', () => {
const { container } = render(<DataErrorState iconWrapperClassName="" />);
const icon = errorIcon(container)!;
expect(icon.className).toBe('');
expect(icon.querySelector('svg')).not.toBeNull();
});

it('MIRROR: both override semantics are identical on DataEmptyState', () => {
const { container: replaced } = render(<DataEmptyState iconWrapperClassName="mb-3" />);
expect(emptyIcon(replaced)!.className).toBe('mb-3');
expect(emptyIcon(replaced)!.className).not.toContain('bg-muted');
const { container: stripped } = render(<DataEmptyState iconWrapperClassName="" />);
expect(emptyIcon(stripped)!.className).toBe('');
// And the empty state's own default is the one it always had, so the two
// components differ by exactly the fallback colour and nothing else.
const { container: bare } = render(<DataEmptyState />);
expect(emptyIcon(bare)!.className).toBe(EMPTY_WRAPPER_DEFAULT);
});

it('the existing surface is untouched: title, message and the retry button', () => {
let clicks = 0;
const { container } = render(
<DataErrorState
title="Couldn’t load"
message="Try again later."
retryLabel="Retry now"
onRetry={() => { clicks += 1; }}
/>,
);
expect(container.textContent).toContain('Couldn’t load');
expect(container.textContent).toContain('Try again later.');
const button = container.querySelector('button')!;
expect(button.textContent).toContain('Retry now');
button.click();
expect(clicks).toBe(1);
});
});
69 changes: 64 additions & 5 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,8 +105,14 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
* so a call site keeps the last word. That is what makes this change inert for
* the two ruled surfaces — both already pass `role="status"` explicitly and
* receive the identical attribute either way — and it is what lets a call site
* rendering something that is NOT empty say so (`plugin-list`'s load-error
* panel borrows this component and declares `role="alert"`).
* rendering something that is NOT empty say so.
*
* The call site that needed that last word was `plugin-list`'s load-failure
* panel, which borrowed this component for its layout. Since objectui#7143 it
* no longer does: `DataErrorState` grew the icon props it was missing and the
* panel is drawn by the component named for what it is. The default stays a
* default for the same reason it always was — a borrow can recur, and a fixed
* attribute would announce the next one as a routine status.
*/
function DataEmptyState({
className,
Expand DownExpand Up@@ -172,6 +178,21 @@ function DataEmptyState({
// ---------------------------------------------------------------------------

interface DataErrorStateProps extends React.ComponentProps<"div"> {
/** Icon rendered above the title */
icon?: React.ReactNode
/**
* When false, the icon container is omitted entirely. Useful for
* banner-style failures that should not draw a glyph at all. Defaults to
* true.
*/
showIcon?: boolean
/**
* Override class on the icon wrapper. By default the wrapper renders as a
* small destructive-tinted rounded square (`size-10 rounded-lg
* bg-destructive/10`). Pass `""` to strip that styling and render the icon
* raw, or extend the look (e.g. larger size).
*/
iconWrapperClassName?: string
title?: string
/** Error message or description */
message?: string
Expand All@@ -181,8 +202,38 @@ interface DataErrorStateProps extends React.ComponentProps<"div"> {
retryLabel?: string
}

/**
* `icon` / `showIcon` / `iconWrapperClassName` MIRROR `DataEmptyState` above —
* same names, same types, same default semantics, including
* `iconWrapperClassName` REPLACING the wrapper's default class rather than
* merging with it, so `""` renders the icon raw (objectui#7143). A second
* spelling of the same three ideas is the defect the mirror exists to prevent;
* the only intended difference is the default class the `??` falls back to,
* which stays this component's own destructive square.
*
* They exist because `plugin-list` rendered its load FAILURE through
* `DataEmptyState` — the component named for the *empty* case — for want of
* them: this one hardcoded its glyph, so the one panel that needed to draw a
* network failure differently from a permission denial could only get that from
* the wrong component. objectui#7132 fixed the accessibility half of that
* collision (`role`) and deliberately left the structural half here.
*
* Deliberately NOT mirrored: `illustration` — an empty state's product-feel hero
* has no failure analogue — and `action`, because this component already spells
* its affordance as `onRetry` / `retryLabel`, with `children` for a call site
* that needs to render its own control.
*
* The icon wrapper carries `data-slot="data-error-state-icon"`, mirroring the
* empty state's `data-empty-state-icon`. Not a prop, and not in the ruling's
* list of three — it is here so the wrapper `iconWrapperClassName` now governs
* can be named by a test and a stylesheet, and so migrating a call site off
* `DataEmptyState` renames that identifier instead of dropping it.
*/
function DataErrorState({
className,
icon,
showIcon = true,
iconWrapperClassName,
title = "Something went wrong",
message,
onRetry,
Expand All@@ -200,9 +251,17 @@ function DataErrorState({
)}
{...props}
>
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertCircle className="size-5 text-destructive" />
</div>
{showIcon && (
<div
data-slot="data-error-state-icon"
className={cn(
iconWrapperClassName ??
"flex size-10 items-center justify-center rounded-lg bg-destructive/10"
)}
>
{icon ?? <AlertCircle className="size-5 text-destructive" />}
</div>
)}
{title && (
<h3 className="text-sm font-medium">{title}</h3>
)}
Expand Down
Loading
Loading