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
50 changes: 50 additions & 0 deletions .changeset/7132-empty-state-role-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': patch
---

`DataEmptyState` now declares `role="status"` by default, so an empty result is
distinguishable from a failed one on every surface that renders it
(objectui#7132).

This is the convergence half of the two rulings that landed as objectui#7063 and
objectui#7064, both resting on objectstack#13848: uniform behaviour belongs to
the platform, and per-surface compensation is the per-app tax being ruled
against. Those two fixed their own surfaces deliberately and locally; this card
measured whether the shared primitive should carry the property. It did not.

**Measured, not assumed.** All the surfaces were rendered and their empty boxes
read directly:

| surface | `role` before |
|---|---|
| `DataEmptyState` bare default | *none* |
| `plugin-list` empty list | *none* |
| `plugin-list` load-error panel | *none* |
| `plugin-detail` activity timelines | *none* |
| `ui:empty` schema renderer | *none* |
| `plugin-dashboard` `WidgetEmptyState` (#7063) | `status`, typed at the call site |
| `plugin-kanban` empty board | `status`, typed at the call site |

The sibling states in the same file had always declared themselves —
`DataLoadingState` is `role="status"`, `DataErrorState` is `role="alert"` — and
the empty state alone declared nothing. So the surfaces were not legitimately
differing: the ones that wanted the property had each hand-typed the same line,
and the ones that had not yet done so were silently missing it. That is one
platform default, copied by hand, at package level.

**It is a default, not a fixed attribute** — `role` is spread from props, so a
call site keeps the last word. That is what makes this inert for the two ruled
surfaces: both already pass `role="status"` explicitly and receive the identical
attribute with or without it. Neither surface's behaviour changes.

**One real defect fell out of the measurement.** `plugin-list` renders its load
FAILURE through `DataEmptyState`, borrowing it for layout — so a 403 saying "You
don't have access" and a young object saying "Nothing here yet" were the same
node shape, with no role on either. That panel now declares `role="alert"`,
which both fixes the pre-existing indistinguishability and stops the new default
from announcing an outage as a routine status.

Metric/KPI widgets are untouched: their carve-out (`rows.length === 0 &&
!isMetric`) gates whether an empty state is rendered *at all*, upstream of this
component, so a KPI still reads `0` rather than "no data".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* 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#7132 — the empty state must declare what it is.
*
* `DataLoadingState` has always been `role="status"` and `DataErrorState`
* `role="alert"`; `DataEmptyState` alone declared nothing, so "this list is
* young" and "this list failed to load" were the same node shape. Both the
* hotcrm#1212 (#7063) and hotcrm#1247 (#7064) rulings name *distinguishable
* from a load failure* as the first property an empty state owes, and four
* separate call sites had each hand-typed `role="status"` to get it.
*
* SUITE DIRECTION, predicted before running: the DEFAULT arm is red against
* `origin/main` and green after. The OVERRIDE arms and the two SIBLING arms are
* green in both worlds — they are the negative controls proving the default is
* a default (a call site keeps the last word, which is why the two already-ruled
* surfaces are inert under this change) and that the contrast it is measured
* against is real.
*/

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

const emptyBox = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state"]');

describe('DataEmptyState — role default (#7132)', () => {
it('DEFAULT: declares role="status" with no call-site prop', () => {
const { container } = render(<DataEmptyState />);
const box = emptyBox(container);
expect(box).not.toBeNull();
expect(box!.getAttribute('role')).toBe('status');
});

it('DEFAULT survives the props a real call site passes alongside it', () => {
const { container } = render(
<DataEmptyState title="Nothing here yet" description="Create your first record." />,
);
expect(emptyBox(container)!.getAttribute('role')).toBe('status');
// The default must not have displaced the rest of the render.
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});

it('OVERRIDE: the already-ruled surfaces pass role="status" explicitly and are unchanged', () => {
// plugin-dashboard's WidgetEmptyState (#7063) and plugin-kanban both spell
// this out. They must receive the identical attribute with or without the
// default, which is what makes #7132 inert for them.
const { container } = render(<DataEmptyState role="status" aria-live="polite" />);
const box = emptyBox(container)!;
expect(box.getAttribute('role')).toBe('status');
expect(box.getAttribute('aria-live')).toBe('polite');
});

it('SIBLING CONTRAST: the error state is an alert and the loading state a status', () => {
const { container: err } = render(<DataErrorState />);
expect(err.querySelector('[data-slot="data-error-state"]')!.getAttribute('role')).toBe('alert');
const { container: load } = render(<DataLoadingState />);
expect(load.querySelector('[data-slot="data-loading-state"]')!.getAttribute('role')).toBe('status');
});
});
26 changes: 26 additions & 0 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,31 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
action?: React.ReactNode
}

/**
* `role` defaults to `"status"` (objectui#7132).
*
* The sibling states in this file each declare what they are — `DataLoadingState`
* is `role="status"`, `DataErrorState` is `role="alert"` — and the empty state
* alone declared nothing, so an empty box and a failed box were the same node
* shape to a screen reader and to any structural test. That is the exact
* property both the hotcrm#1212 (objectui#7063) and hotcrm#1247 (objectui#7064)
* rulings named first: an empty state must be *distinguishable from a load
* failure at a glance*.
*
* With no default, every surface that wanted the property had to type it at its
* own call site, and four independently did — `plugin-kanban`'s empty board,
* `plugin-dashboard`'s `WidgetEmptyState`, and `plugin-charts`' `ObjectChart` —
* while `plugin-list`, `plugin-detail`'s two timelines and the `ui:empty`
* renderer silently did not. Four hand-copies of one line is the per-app tax
* objectstack#13848 rules against, paid at the package level.
*
* It is a DEFAULT, not a fixed attribute: `role` is spread from `props` below,
* 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"`).
*/
function DataEmptyState({
className,
icon,
Expand All@@ -102,6 +127,7 @@ function DataEmptyState({

return (
<div
role="status"
data-slot="data-empty-state"
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 text-center",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3582,6 +3582,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
empty state on slow networks. */}
{loadError && data.length === 0 ? (
<DataEmptyState
// This panel is NOT an empty state — it is the load FAILURE, and it
// borrows `DataEmptyState` only for its layout. Since objectui#7132
// that component defaults to `role="status"`, which would announce a
// 403 or an outage as a routine status update, so this call site
// declares what it actually is. (Measured: before #7132 neither this
// panel nor the empty state below carried any role, so "you don't
// have access" and "nothing here yet" were the same node shape.)
role="alert"
data-testid="list-error-state"
data-error-kind={loadErrorKind}
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
/**
* 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#7132 — a list that is EMPTY and a list that FAILED TO LOAD must not
* be the same node shape.
*
* Measured on `origin/main` by rendering both branches: each one is a
* `data-slot="data-empty-state"` div with **no `role` at all**. A 403 telling
* the user "You don't have access" and a young object telling them "Nothing
* here yet" were structurally indistinguishable — the precise failure the
* #7063 / #7064 rulings put first, and the reason `ListView` was the
* "unmeasured" row on #7132's own table.
*
* Two things are pinned here because they are separable and either can regress
* alone: the empty branch takes the platform default (#7132), and the error
* branch — which borrows `DataEmptyState` purely for its layout — overrides it
* back to `alert` so the default cannot mislabel a failure as a status.
*
* SUITE DIRECTION, predicted before running: the EMPTY arm is red against
* `origin/main`; the ERROR arm is green in both worlds (its `role="alert"` is
* typed at the call site, not inherited), and is here as the negative control
* that keeps a suite collapsed to "no tests" from reading as the fix.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'work_order',
fields: ['name'],
};

function renderWith(find: () => Promise<unknown>) {
const ds = {
find: vi.fn().mockImplementation(find),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function panel(container: HTMLElement, testId: string): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull();
});
return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement;
}

describe('ListView — an empty list is not a failed list (#7132)', () => {
it('EMPTY: the empty state is announced as role="status"', async () => {
const { container } = renderWith(() => Promise.resolve([]));
const box = await panel(container, 'empty-state');
expect(box.getAttribute('role')).toBe('status');
// Guard against the arm passing over a collapsed render: the empty copy
// must actually be present in the box being measured.
expect(box.textContent).toMatch(/nothing here yet/i);
});

it('ERROR: the load-failure panel is announced as role="alert"', async () => {
const { container } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const box = await panel(container, 'list-error-state');
expect(box.getAttribute('role')).toBe('alert');
expect(box.getAttribute('data-error-kind')).toBe('forbidden');
});

it('the two branches carry DIFFERENT roles, by exact value', async () => {
const { container: emptyC } = renderWith(() => Promise.resolve([]));
const emptyRole = (await panel(emptyC, 'empty-state')).getAttribute('role');
const { container: errC } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const errorRole = (await panel(errC, 'list-error-state')).getAttribute('role');
// Asserted by exact value, not by inequality: on `origin/main` the roles
// were `null` and `null`, but a partial fix leaving the empty branch at
// `null` would still satisfy `null !== 'alert'` and pass a mere-difference
// assertion. Both values must be named for this arm to be able to fail.
expect(emptyRole).toBe('status');
expect(errorRole).toBe('alert');
});
});
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
50 changes: 50 additions & 0 deletions .changeset/7132-empty-state-role-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': patch
---

`DataEmptyState` now declares `role="status"` by default, so an empty result is
distinguishable from a failed one on every surface that renders it
(objectui#7132).

This is the convergence half of the two rulings that landed as objectui#7063 and
objectui#7064, both resting on objectstack#13848: uniform behaviour belongs to
the platform, and per-surface compensation is the per-app tax being ruled
against. Those two fixed their own surfaces deliberately and locally; this card
measured whether the shared primitive should carry the property. It did not.

**Measured, not assumed.** All the surfaces were rendered and their empty boxes
read directly:

| surface | `role` before |
|---|---|
| `DataEmptyState` bare default | *none* |
| `plugin-list` empty list | *none* |
| `plugin-list` load-error panel | *none* |
| `plugin-detail` activity timelines | *none* |
| `ui:empty` schema renderer | *none* |
| `plugin-dashboard` `WidgetEmptyState` (#7063) | `status`, typed at the call site |
| `plugin-kanban` empty board | `status`, typed at the call site |

The sibling states in the same file had always declared themselves —
`DataLoadingState` is `role="status"`, `DataErrorState` is `role="alert"` — and
the empty state alone declared nothing. So the surfaces were not legitimately
differing: the ones that wanted the property had each hand-typed the same line,
and the ones that had not yet done so were silently missing it. That is one
platform default, copied by hand, at package level.

**It is a default, not a fixed attribute** — `role` is spread from props, so a
call site keeps the last word. That is what makes this inert for the two ruled
surfaces: both already pass `role="status"` explicitly and receive the identical
attribute with or without it. Neither surface's behaviour changes.

**One real defect fell out of the measurement.** `plugin-list` renders its load
FAILURE through `DataEmptyState`, borrowing it for layout — so a 403 saying "You
don't have access" and a young object saying "Nothing here yet" were the same
node shape, with no role on either. That panel now declares `role="alert"`,
which both fixes the pre-existing indistinguishability and stops the new default
from announcing an outage as a routine status.

Metric/KPI widgets are untouched: their carve-out (`rows.length === 0 &&
!isMetric`) gates whether an empty state is rendered *at all*, upstream of this
component, so a KPI still reads `0` rather than "no data".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* 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#7132 — the empty state must declare what it is.
*
* `DataLoadingState` has always been `role="status"` and `DataErrorState`
* `role="alert"`; `DataEmptyState` alone declared nothing, so "this list is
* young" and "this list failed to load" were the same node shape. Both the
* hotcrm#1212 (#7063) and hotcrm#1247 (#7064) rulings name *distinguishable
* from a load failure* as the first property an empty state owes, and four
* separate call sites had each hand-typed `role="status"` to get it.
*
* SUITE DIRECTION, predicted before running: the DEFAULT arm is red against
* `origin/main` and green after. The OVERRIDE arms and the two SIBLING arms are
* green in both worlds — they are the negative controls proving the default is
* a default (a call site keeps the last word, which is why the two already-ruled
* surfaces are inert under this change) and that the contrast it is measured
* against is real.
*/

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

const emptyBox = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state"]');

describe('DataEmptyState — role default (#7132)', () => {
it('DEFAULT: declares role="status" with no call-site prop', () => {
const { container } = render(<DataEmptyState />);
const box = emptyBox(container);
expect(box).not.toBeNull();
expect(box!.getAttribute('role')).toBe('status');
});

it('DEFAULT survives the props a real call site passes alongside it', () => {
const { container } = render(
<DataEmptyState title="Nothing here yet" description="Create your first record." />,
);
expect(emptyBox(container)!.getAttribute('role')).toBe('status');
// The default must not have displaced the rest of the render.
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});

it('OVERRIDE: the already-ruled surfaces pass role="status" explicitly and are unchanged', () => {
// plugin-dashboard's WidgetEmptyState (#7063) and plugin-kanban both spell
// this out. They must receive the identical attribute with or without the
// default, which is what makes #7132 inert for them.
const { container } = render(<DataEmptyState role="status" aria-live="polite" />);
const box = emptyBox(container)!;
expect(box.getAttribute('role')).toBe('status');
expect(box.getAttribute('aria-live')).toBe('polite');
});

it('SIBLING CONTRAST: the error state is an alert and the loading state a status', () => {
const { container: err } = render(<DataErrorState />);
expect(err.querySelector('[data-slot="data-error-state"]')!.getAttribute('role')).toBe('alert');
const { container: load } = render(<DataLoadingState />);
expect(load.querySelector('[data-slot="data-loading-state"]')!.getAttribute('role')).toBe('status');
});
});
26 changes: 26 additions & 0 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,31 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
action?: React.ReactNode
}

/**
* `role` defaults to `"status"` (objectui#7132).
*
* The sibling states in this file each declare what they are — `DataLoadingState`
* is `role="status"`, `DataErrorState` is `role="alert"` — and the empty state
* alone declared nothing, so an empty box and a failed box were the same node
* shape to a screen reader and to any structural test. That is the exact
* property both the hotcrm#1212 (objectui#7063) and hotcrm#1247 (objectui#7064)
* rulings named first: an empty state must be *distinguishable from a load
* failure at a glance*.
*
* With no default, every surface that wanted the property had to type it at its
* own call site, and four independently did — `plugin-kanban`'s empty board,
* `plugin-dashboard`'s `WidgetEmptyState`, and `plugin-charts`' `ObjectChart` —
* while `plugin-list`, `plugin-detail`'s two timelines and the `ui:empty`
* renderer silently did not. Four hand-copies of one line is the per-app tax
* objectstack#13848 rules against, paid at the package level.
*
* It is a DEFAULT, not a fixed attribute: `role` is spread from `props` below,
* 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"`).
*/
function DataEmptyState({
className,
icon,
Expand All@@ -102,6 +127,7 @@ function DataEmptyState({

return (
<div
role="status"
data-slot="data-empty-state"
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 text-center",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3582,6 +3582,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
empty state on slow networks. */}
{loadError && data.length === 0 ? (
<DataEmptyState
// This panel is NOT an empty state — it is the load FAILURE, and it
// borrows `DataEmptyState` only for its layout. Since objectui#7132
// that component defaults to `role="status"`, which would announce a
// 403 or an outage as a routine status update, so this call site
// declares what it actually is. (Measured: before #7132 neither this
// panel nor the empty state below carried any role, so "you don't
// have access" and "nothing here yet" were the same node shape.)
role="alert"
data-testid="list-error-state"
data-error-kind={loadErrorKind}
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
/**
* 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#7132 — a list that is EMPTY and a list that FAILED TO LOAD must not
* be the same node shape.
*
* Measured on `origin/main` by rendering both branches: each one is a
* `data-slot="data-empty-state"` div with **no `role` at all**. A 403 telling
* the user "You don't have access" and a young object telling them "Nothing
* here yet" were structurally indistinguishable — the precise failure the
* #7063 / #7064 rulings put first, and the reason `ListView` was the
* "unmeasured" row on #7132's own table.
*
* Two things are pinned here because they are separable and either can regress
* alone: the empty branch takes the platform default (#7132), and the error
* branch — which borrows `DataEmptyState` purely for its layout — overrides it
* back to `alert` so the default cannot mislabel a failure as a status.
*
* SUITE DIRECTION, predicted before running: the EMPTY arm is red against
* `origin/main`; the ERROR arm is green in both worlds (its `role="alert"` is
* typed at the call site, not inherited), and is here as the negative control
* that keeps a suite collapsed to "no tests" from reading as the fix.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'work_order',
fields: ['name'],
};

function renderWith(find: () => Promise<unknown>) {
const ds = {
find: vi.fn().mockImplementation(find),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function panel(container: HTMLElement, testId: string): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull();
});
return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement;
}

describe('ListView — an empty list is not a failed list (#7132)', () => {
it('EMPTY: the empty state is announced as role="status"', async () => {
const { container } = renderWith(() => Promise.resolve([]));
const box = await panel(container, 'empty-state');
expect(box.getAttribute('role')).toBe('status');
// Guard against the arm passing over a collapsed render: the empty copy
// must actually be present in the box being measured.
expect(box.textContent).toMatch(/nothing here yet/i);
});

it('ERROR: the load-failure panel is announced as role="alert"', async () => {
const { container } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const box = await panel(container, 'list-error-state');
expect(box.getAttribute('role')).toBe('alert');
expect(box.getAttribute('data-error-kind')).toBe('forbidden');
});

it('the two branches carry DIFFERENT roles, by exact value', async () => {
const { container: emptyC } = renderWith(() => Promise.resolve([]));
const emptyRole = (await panel(emptyC, 'empty-state')).getAttribute('role');
const { container: errC } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const errorRole = (await panel(errC, 'list-error-state')).getAttribute('role');
// Asserted by exact value, not by inequality: on `origin/main` the roles
// were `null` and `null`, but a partial fix leaving the empty branch at
// `null` would still satisfy `null !== 'alert'` and pass a mere-difference
// assertion. Both values must be named for this arm to be able to fail.
expect(emptyRole).toBe('status');
expect(errorRole).toBe('alert');
});
});
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
50 changes: 50 additions & 0 deletions .changeset/7132-empty-state-role-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': patch
---

`DataEmptyState` now declares `role="status"` by default, so an empty result is
distinguishable from a failed one on every surface that renders it
(objectui#7132).

This is the convergence half of the two rulings that landed as objectui#7063 and
objectui#7064, both resting on objectstack#13848: uniform behaviour belongs to
the platform, and per-surface compensation is the per-app tax being ruled
against. Those two fixed their own surfaces deliberately and locally; this card
measured whether the shared primitive should carry the property. It did not.

**Measured, not assumed.** All the surfaces were rendered and their empty boxes
read directly:

| surface | `role` before |
|---|---|
| `DataEmptyState` bare default | *none* |
| `plugin-list` empty list | *none* |
| `plugin-list` load-error panel | *none* |
| `plugin-detail` activity timelines | *none* |
| `ui:empty` schema renderer | *none* |
| `plugin-dashboard` `WidgetEmptyState` (#7063) | `status`, typed at the call site |
| `plugin-kanban` empty board | `status`, typed at the call site |

The sibling states in the same file had always declared themselves —
`DataLoadingState` is `role="status"`, `DataErrorState` is `role="alert"` — and
the empty state alone declared nothing. So the surfaces were not legitimately
differing: the ones that wanted the property had each hand-typed the same line,
and the ones that had not yet done so were silently missing it. That is one
platform default, copied by hand, at package level.

**It is a default, not a fixed attribute** — `role` is spread from props, so a
call site keeps the last word. That is what makes this inert for the two ruled
surfaces: both already pass `role="status"` explicitly and receive the identical
attribute with or without it. Neither surface's behaviour changes.

**One real defect fell out of the measurement.** `plugin-list` renders its load
FAILURE through `DataEmptyState`, borrowing it for layout — so a 403 saying "You
don't have access" and a young object saying "Nothing here yet" were the same
node shape, with no role on either. That panel now declares `role="alert"`,
which both fixes the pre-existing indistinguishability and stops the new default
from announcing an outage as a routine status.

Metric/KPI widgets are untouched: their carve-out (`rows.length === 0 &&
!isMetric`) gates whether an empty state is rendered *at all*, upstream of this
component, so a KPI still reads `0` rather than "no data".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* 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#7132 — the empty state must declare what it is.
*
* `DataLoadingState` has always been `role="status"` and `DataErrorState`
* `role="alert"`; `DataEmptyState` alone declared nothing, so "this list is
* young" and "this list failed to load" were the same node shape. Both the
* hotcrm#1212 (#7063) and hotcrm#1247 (#7064) rulings name *distinguishable
* from a load failure* as the first property an empty state owes, and four
* separate call sites had each hand-typed `role="status"` to get it.
*
* SUITE DIRECTION, predicted before running: the DEFAULT arm is red against
* `origin/main` and green after. The OVERRIDE arms and the two SIBLING arms are
* green in both worlds — they are the negative controls proving the default is
* a default (a call site keeps the last word, which is why the two already-ruled
* surfaces are inert under this change) and that the contrast it is measured
* against is real.
*/

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

const emptyBox = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state"]');

describe('DataEmptyState — role default (#7132)', () => {
it('DEFAULT: declares role="status" with no call-site prop', () => {
const { container } = render(<DataEmptyState />);
const box = emptyBox(container);
expect(box).not.toBeNull();
expect(box!.getAttribute('role')).toBe('status');
});

it('DEFAULT survives the props a real call site passes alongside it', () => {
const { container } = render(
<DataEmptyState title="Nothing here yet" description="Create your first record." />,
);
expect(emptyBox(container)!.getAttribute('role')).toBe('status');
// The default must not have displaced the rest of the render.
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});

it('OVERRIDE: the already-ruled surfaces pass role="status" explicitly and are unchanged', () => {
// plugin-dashboard's WidgetEmptyState (#7063) and plugin-kanban both spell
// this out. They must receive the identical attribute with or without the
// default, which is what makes #7132 inert for them.
const { container } = render(<DataEmptyState role="status" aria-live="polite" />);
const box = emptyBox(container)!;
expect(box.getAttribute('role')).toBe('status');
expect(box.getAttribute('aria-live')).toBe('polite');
});

it('SIBLING CONTRAST: the error state is an alert and the loading state a status', () => {
const { container: err } = render(<DataErrorState />);
expect(err.querySelector('[data-slot="data-error-state"]')!.getAttribute('role')).toBe('alert');
const { container: load } = render(<DataLoadingState />);
expect(load.querySelector('[data-slot="data-loading-state"]')!.getAttribute('role')).toBe('status');
});
});
26 changes: 26 additions & 0 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,31 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
action?: React.ReactNode
}

/**
* `role` defaults to `"status"` (objectui#7132).
*
* The sibling states in this file each declare what they are — `DataLoadingState`
* is `role="status"`, `DataErrorState` is `role="alert"` — and the empty state
* alone declared nothing, so an empty box and a failed box were the same node
* shape to a screen reader and to any structural test. That is the exact
* property both the hotcrm#1212 (objectui#7063) and hotcrm#1247 (objectui#7064)
* rulings named first: an empty state must be *distinguishable from a load
* failure at a glance*.
*
* With no default, every surface that wanted the property had to type it at its
* own call site, and four independently did — `plugin-kanban`'s empty board,
* `plugin-dashboard`'s `WidgetEmptyState`, and `plugin-charts`' `ObjectChart` —
* while `plugin-list`, `plugin-detail`'s two timelines and the `ui:empty`
* renderer silently did not. Four hand-copies of one line is the per-app tax
* objectstack#13848 rules against, paid at the package level.
*
* It is a DEFAULT, not a fixed attribute: `role` is spread from `props` below,
* 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"`).
*/
function DataEmptyState({
className,
icon,
Expand All@@ -102,6 +127,7 @@ function DataEmptyState({

return (
<div
role="status"
data-slot="data-empty-state"
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 text-center",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3582,6 +3582,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
empty state on slow networks. */}
{loadError && data.length === 0 ? (
<DataEmptyState
// This panel is NOT an empty state — it is the load FAILURE, and it
// borrows `DataEmptyState` only for its layout. Since objectui#7132
// that component defaults to `role="status"`, which would announce a
// 403 or an outage as a routine status update, so this call site
// declares what it actually is. (Measured: before #7132 neither this
// panel nor the empty state below carried any role, so "you don't
// have access" and "nothing here yet" were the same node shape.)
role="alert"
data-testid="list-error-state"
data-error-kind={loadErrorKind}
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
/**
* 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#7132 — a list that is EMPTY and a list that FAILED TO LOAD must not
* be the same node shape.
*
* Measured on `origin/main` by rendering both branches: each one is a
* `data-slot="data-empty-state"` div with **no `role` at all**. A 403 telling
* the user "You don't have access" and a young object telling them "Nothing
* here yet" were structurally indistinguishable — the precise failure the
* #7063 / #7064 rulings put first, and the reason `ListView` was the
* "unmeasured" row on #7132's own table.
*
* Two things are pinned here because they are separable and either can regress
* alone: the empty branch takes the platform default (#7132), and the error
* branch — which borrows `DataEmptyState` purely for its layout — overrides it
* back to `alert` so the default cannot mislabel a failure as a status.
*
* SUITE DIRECTION, predicted before running: the EMPTY arm is red against
* `origin/main`; the ERROR arm is green in both worlds (its `role="alert"` is
* typed at the call site, not inherited), and is here as the negative control
* that keeps a suite collapsed to "no tests" from reading as the fix.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'work_order',
fields: ['name'],
};

function renderWith(find: () => Promise<unknown>) {
const ds = {
find: vi.fn().mockImplementation(find),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function panel(container: HTMLElement, testId: string): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull();
});
return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement;
}

describe('ListView — an empty list is not a failed list (#7132)', () => {
it('EMPTY: the empty state is announced as role="status"', async () => {
const { container } = renderWith(() => Promise.resolve([]));
const box = await panel(container, 'empty-state');
expect(box.getAttribute('role')).toBe('status');
// Guard against the arm passing over a collapsed render: the empty copy
// must actually be present in the box being measured.
expect(box.textContent).toMatch(/nothing here yet/i);
});

it('ERROR: the load-failure panel is announced as role="alert"', async () => {
const { container } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const box = await panel(container, 'list-error-state');
expect(box.getAttribute('role')).toBe('alert');
expect(box.getAttribute('data-error-kind')).toBe('forbidden');
});

it('the two branches carry DIFFERENT roles, by exact value', async () => {
const { container: emptyC } = renderWith(() => Promise.resolve([]));
const emptyRole = (await panel(emptyC, 'empty-state')).getAttribute('role');
const { container: errC } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const errorRole = (await panel(errC, 'list-error-state')).getAttribute('role');
// Asserted by exact value, not by inequality: on `origin/main` the roles
// were `null` and `null`, but a partial fix leaving the empty branch at
// `null` would still satisfy `null !== 'alert'` and pass a mere-difference
// assertion. Both values must be named for this arm to be able to fail.
expect(emptyRole).toBe('status');
expect(errorRole).toBe('alert');
});
});
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
50 changes: 50 additions & 0 deletions .changeset/7132-empty-state-role-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': patch
---

`DataEmptyState` now declares `role="status"` by default, so an empty result is
distinguishable from a failed one on every surface that renders it
(objectui#7132).

This is the convergence half of the two rulings that landed as objectui#7063 and
objectui#7064, both resting on objectstack#13848: uniform behaviour belongs to
the platform, and per-surface compensation is the per-app tax being ruled
against. Those two fixed their own surfaces deliberately and locally; this card
measured whether the shared primitive should carry the property. It did not.

**Measured, not assumed.** All the surfaces were rendered and their empty boxes
read directly:

| surface | `role` before |
|---|---|
| `DataEmptyState` bare default | *none* |
| `plugin-list` empty list | *none* |
| `plugin-list` load-error panel | *none* |
| `plugin-detail` activity timelines | *none* |
| `ui:empty` schema renderer | *none* |
| `plugin-dashboard` `WidgetEmptyState` (#7063) | `status`, typed at the call site |
| `plugin-kanban` empty board | `status`, typed at the call site |

The sibling states in the same file had always declared themselves —
`DataLoadingState` is `role="status"`, `DataErrorState` is `role="alert"` — and
the empty state alone declared nothing. So the surfaces were not legitimately
differing: the ones that wanted the property had each hand-typed the same line,
and the ones that had not yet done so were silently missing it. That is one
platform default, copied by hand, at package level.

**It is a default, not a fixed attribute** — `role` is spread from props, so a
call site keeps the last word. That is what makes this inert for the two ruled
surfaces: both already pass `role="status"` explicitly and receive the identical
attribute with or without it. Neither surface's behaviour changes.

**One real defect fell out of the measurement.** `plugin-list` renders its load
FAILURE through `DataEmptyState`, borrowing it for layout — so a 403 saying "You
don't have access" and a young object saying "Nothing here yet" were the same
node shape, with no role on either. That panel now declares `role="alert"`,
which both fixes the pre-existing indistinguishability and stops the new default
from announcing an outage as a routine status.

Metric/KPI widgets are untouched: their carve-out (`rows.length === 0 &&
!isMetric`) gates whether an empty state is rendered *at all*, upstream of this
component, so a KPI still reads `0` rather than "no data".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* 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#7132 — the empty state must declare what it is.
*
* `DataLoadingState` has always been `role="status"` and `DataErrorState`
* `role="alert"`; `DataEmptyState` alone declared nothing, so "this list is
* young" and "this list failed to load" were the same node shape. Both the
* hotcrm#1212 (#7063) and hotcrm#1247 (#7064) rulings name *distinguishable
* from a load failure* as the first property an empty state owes, and four
* separate call sites had each hand-typed `role="status"` to get it.
*
* SUITE DIRECTION, predicted before running: the DEFAULT arm is red against
* `origin/main` and green after. The OVERRIDE arms and the two SIBLING arms are
* green in both worlds — they are the negative controls proving the default is
* a default (a call site keeps the last word, which is why the two already-ruled
* surfaces are inert under this change) and that the contrast it is measured
* against is real.
*/

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

const emptyBox = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state"]');

describe('DataEmptyState — role default (#7132)', () => {
it('DEFAULT: declares role="status" with no call-site prop', () => {
const { container } = render(<DataEmptyState />);
const box = emptyBox(container);
expect(box).not.toBeNull();
expect(box!.getAttribute('role')).toBe('status');
});

it('DEFAULT survives the props a real call site passes alongside it', () => {
const { container } = render(
<DataEmptyState title="Nothing here yet" description="Create your first record." />,
);
expect(emptyBox(container)!.getAttribute('role')).toBe('status');
// The default must not have displaced the rest of the render.
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});

it('OVERRIDE: the already-ruled surfaces pass role="status" explicitly and are unchanged', () => {
// plugin-dashboard's WidgetEmptyState (#7063) and plugin-kanban both spell
// this out. They must receive the identical attribute with or without the
// default, which is what makes #7132 inert for them.
const { container } = render(<DataEmptyState role="status" aria-live="polite" />);
const box = emptyBox(container)!;
expect(box.getAttribute('role')).toBe('status');
expect(box.getAttribute('aria-live')).toBe('polite');
});

it('SIBLING CONTRAST: the error state is an alert and the loading state a status', () => {
const { container: err } = render(<DataErrorState />);
expect(err.querySelector('[data-slot="data-error-state"]')!.getAttribute('role')).toBe('alert');
const { container: load } = render(<DataLoadingState />);
expect(load.querySelector('[data-slot="data-loading-state"]')!.getAttribute('role')).toBe('status');
});
});
26 changes: 26 additions & 0 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,31 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
action?: React.ReactNode
}

/**
* `role` defaults to `"status"` (objectui#7132).
*
* The sibling states in this file each declare what they are — `DataLoadingState`
* is `role="status"`, `DataErrorState` is `role="alert"` — and the empty state
* alone declared nothing, so an empty box and a failed box were the same node
* shape to a screen reader and to any structural test. That is the exact
* property both the hotcrm#1212 (objectui#7063) and hotcrm#1247 (objectui#7064)
* rulings named first: an empty state must be *distinguishable from a load
* failure at a glance*.
*
* With no default, every surface that wanted the property had to type it at its
* own call site, and four independently did — `plugin-kanban`'s empty board,
* `plugin-dashboard`'s `WidgetEmptyState`, and `plugin-charts`' `ObjectChart` —
* while `plugin-list`, `plugin-detail`'s two timelines and the `ui:empty`
* renderer silently did not. Four hand-copies of one line is the per-app tax
* objectstack#13848 rules against, paid at the package level.
*
* It is a DEFAULT, not a fixed attribute: `role` is spread from `props` below,
* 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"`).
*/
function DataEmptyState({
className,
icon,
Expand All@@ -102,6 +127,7 @@ function DataEmptyState({

return (
<div
role="status"
data-slot="data-empty-state"
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 text-center",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3582,6 +3582,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
empty state on slow networks. */}
{loadError && data.length === 0 ? (
<DataEmptyState
// This panel is NOT an empty state — it is the load FAILURE, and it
// borrows `DataEmptyState` only for its layout. Since objectui#7132
// that component defaults to `role="status"`, which would announce a
// 403 or an outage as a routine status update, so this call site
// declares what it actually is. (Measured: before #7132 neither this
// panel nor the empty state below carried any role, so "you don't
// have access" and "nothing here yet" were the same node shape.)
role="alert"
data-testid="list-error-state"
data-error-kind={loadErrorKind}
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
/**
* 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#7132 — a list that is EMPTY and a list that FAILED TO LOAD must not
* be the same node shape.
*
* Measured on `origin/main` by rendering both branches: each one is a
* `data-slot="data-empty-state"` div with **no `role` at all**. A 403 telling
* the user "You don't have access" and a young object telling them "Nothing
* here yet" were structurally indistinguishable — the precise failure the
* #7063 / #7064 rulings put first, and the reason `ListView` was the
* "unmeasured" row on #7132's own table.
*
* Two things are pinned here because they are separable and either can regress
* alone: the empty branch takes the platform default (#7132), and the error
* branch — which borrows `DataEmptyState` purely for its layout — overrides it
* back to `alert` so the default cannot mislabel a failure as a status.
*
* SUITE DIRECTION, predicted before running: the EMPTY arm is red against
* `origin/main`; the ERROR arm is green in both worlds (its `role="alert"` is
* typed at the call site, not inherited), and is here as the negative control
* that keeps a suite collapsed to "no tests" from reading as the fix.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'work_order',
fields: ['name'],
};

function renderWith(find: () => Promise<unknown>) {
const ds = {
find: vi.fn().mockImplementation(find),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function panel(container: HTMLElement, testId: string): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull();
});
return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement;
}

describe('ListView — an empty list is not a failed list (#7132)', () => {
it('EMPTY: the empty state is announced as role="status"', async () => {
const { container } = renderWith(() => Promise.resolve([]));
const box = await panel(container, 'empty-state');
expect(box.getAttribute('role')).toBe('status');
// Guard against the arm passing over a collapsed render: the empty copy
// must actually be present in the box being measured.
expect(box.textContent).toMatch(/nothing here yet/i);
});

it('ERROR: the load-failure panel is announced as role="alert"', async () => {
const { container } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const box = await panel(container, 'list-error-state');
expect(box.getAttribute('role')).toBe('alert');
expect(box.getAttribute('data-error-kind')).toBe('forbidden');
});

it('the two branches carry DIFFERENT roles, by exact value', async () => {
const { container: emptyC } = renderWith(() => Promise.resolve([]));
const emptyRole = (await panel(emptyC, 'empty-state')).getAttribute('role');
const { container: errC } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const errorRole = (await panel(errC, 'list-error-state')).getAttribute('role');
// Asserted by exact value, not by inequality: on `origin/main` the roles
// were `null` and `null`, but a partial fix leaving the empty branch at
// `null` would still satisfy `null !== 'alert'` and pass a mere-difference
// assertion. Both values must be named for this arm to be able to fail.
expect(emptyRole).toBe('status');
expect(errorRole).toBe('alert');
});
});
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
50 changes: 50 additions & 0 deletions .changeset/7132-empty-state-role-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': patch
---

`DataEmptyState` now declares `role="status"` by default, so an empty result is
distinguishable from a failed one on every surface that renders it
(objectui#7132).

This is the convergence half of the two rulings that landed as objectui#7063 and
objectui#7064, both resting on objectstack#13848: uniform behaviour belongs to
the platform, and per-surface compensation is the per-app tax being ruled
against. Those two fixed their own surfaces deliberately and locally; this card
measured whether the shared primitive should carry the property. It did not.

**Measured, not assumed.** All the surfaces were rendered and their empty boxes
read directly:

| surface | `role` before |
|---|---|
| `DataEmptyState` bare default | *none* |
| `plugin-list` empty list | *none* |
| `plugin-list` load-error panel | *none* |
| `plugin-detail` activity timelines | *none* |
| `ui:empty` schema renderer | *none* |
| `plugin-dashboard` `WidgetEmptyState` (#7063) | `status`, typed at the call site |
| `plugin-kanban` empty board | `status`, typed at the call site |

The sibling states in the same file had always declared themselves —
`DataLoadingState` is `role="status"`, `DataErrorState` is `role="alert"` — and
the empty state alone declared nothing. So the surfaces were not legitimately
differing: the ones that wanted the property had each hand-typed the same line,
and the ones that had not yet done so were silently missing it. That is one
platform default, copied by hand, at package level.

**It is a default, not a fixed attribute** — `role` is spread from props, so a
call site keeps the last word. That is what makes this inert for the two ruled
surfaces: both already pass `role="status"` explicitly and receive the identical
attribute with or without it. Neither surface's behaviour changes.

**One real defect fell out of the measurement.** `plugin-list` renders its load
FAILURE through `DataEmptyState`, borrowing it for layout — so a 403 saying "You
don't have access" and a young object saying "Nothing here yet" were the same
node shape, with no role on either. That panel now declares `role="alert"`,
which both fixes the pre-existing indistinguishability and stops the new default
from announcing an outage as a routine status.

Metric/KPI widgets are untouched: their carve-out (`rows.length === 0 &&
!isMetric`) gates whether an empty state is rendered *at all*, upstream of this
component, so a KPI still reads `0` rather than "no data".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* 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#7132 — the empty state must declare what it is.
*
* `DataLoadingState` has always been `role="status"` and `DataErrorState`
* `role="alert"`; `DataEmptyState` alone declared nothing, so "this list is
* young" and "this list failed to load" were the same node shape. Both the
* hotcrm#1212 (#7063) and hotcrm#1247 (#7064) rulings name *distinguishable
* from a load failure* as the first property an empty state owes, and four
* separate call sites had each hand-typed `role="status"` to get it.
*
* SUITE DIRECTION, predicted before running: the DEFAULT arm is red against
* `origin/main` and green after. The OVERRIDE arms and the two SIBLING arms are
* green in both worlds — they are the negative controls proving the default is
* a default (a call site keeps the last word, which is why the two already-ruled
* surfaces are inert under this change) and that the contrast it is measured
* against is real.
*/

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

const emptyBox = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state"]');

describe('DataEmptyState — role default (#7132)', () => {
it('DEFAULT: declares role="status" with no call-site prop', () => {
const { container } = render(<DataEmptyState />);
const box = emptyBox(container);
expect(box).not.toBeNull();
expect(box!.getAttribute('role')).toBe('status');
});

it('DEFAULT survives the props a real call site passes alongside it', () => {
const { container } = render(
<DataEmptyState title="Nothing here yet" description="Create your first record." />,
);
expect(emptyBox(container)!.getAttribute('role')).toBe('status');
// The default must not have displaced the rest of the render.
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});

it('OVERRIDE: the already-ruled surfaces pass role="status" explicitly and are unchanged', () => {
// plugin-dashboard's WidgetEmptyState (#7063) and plugin-kanban both spell
// this out. They must receive the identical attribute with or without the
// default, which is what makes #7132 inert for them.
const { container } = render(<DataEmptyState role="status" aria-live="polite" />);
const box = emptyBox(container)!;
expect(box.getAttribute('role')).toBe('status');
expect(box.getAttribute('aria-live')).toBe('polite');
});

it('SIBLING CONTRAST: the error state is an alert and the loading state a status', () => {
const { container: err } = render(<DataErrorState />);
expect(err.querySelector('[data-slot="data-error-state"]')!.getAttribute('role')).toBe('alert');
const { container: load } = render(<DataLoadingState />);
expect(load.querySelector('[data-slot="data-loading-state"]')!.getAttribute('role')).toBe('status');
});
});
26 changes: 26 additions & 0 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,31 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
action?: React.ReactNode
}

/**
* `role` defaults to `"status"` (objectui#7132).
*
* The sibling states in this file each declare what they are — `DataLoadingState`
* is `role="status"`, `DataErrorState` is `role="alert"` — and the empty state
* alone declared nothing, so an empty box and a failed box were the same node
* shape to a screen reader and to any structural test. That is the exact
* property both the hotcrm#1212 (objectui#7063) and hotcrm#1247 (objectui#7064)
* rulings named first: an empty state must be *distinguishable from a load
* failure at a glance*.
*
* With no default, every surface that wanted the property had to type it at its
* own call site, and four independently did — `plugin-kanban`'s empty board,
* `plugin-dashboard`'s `WidgetEmptyState`, and `plugin-charts`' `ObjectChart` —
* while `plugin-list`, `plugin-detail`'s two timelines and the `ui:empty`
* renderer silently did not. Four hand-copies of one line is the per-app tax
* objectstack#13848 rules against, paid at the package level.
*
* It is a DEFAULT, not a fixed attribute: `role` is spread from `props` below,
* 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"`).
*/
function DataEmptyState({
className,
icon,
Expand All@@ -102,6 +127,7 @@ function DataEmptyState({

return (
<div
role="status"
data-slot="data-empty-state"
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 text-center",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3582,6 +3582,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
empty state on slow networks. */}
{loadError && data.length === 0 ? (
<DataEmptyState
// This panel is NOT an empty state — it is the load FAILURE, and it
// borrows `DataEmptyState` only for its layout. Since objectui#7132
// that component defaults to `role="status"`, which would announce a
// 403 or an outage as a routine status update, so this call site
// declares what it actually is. (Measured: before #7132 neither this
// panel nor the empty state below carried any role, so "you don't
// have access" and "nothing here yet" were the same node shape.)
role="alert"
data-testid="list-error-state"
data-error-kind={loadErrorKind}
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
/**
* 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#7132 — a list that is EMPTY and a list that FAILED TO LOAD must not
* be the same node shape.
*
* Measured on `origin/main` by rendering both branches: each one is a
* `data-slot="data-empty-state"` div with **no `role` at all**. A 403 telling
* the user "You don't have access" and a young object telling them "Nothing
* here yet" were structurally indistinguishable — the precise failure the
* #7063 / #7064 rulings put first, and the reason `ListView` was the
* "unmeasured" row on #7132's own table.
*
* Two things are pinned here because they are separable and either can regress
* alone: the empty branch takes the platform default (#7132), and the error
* branch — which borrows `DataEmptyState` purely for its layout — overrides it
* back to `alert` so the default cannot mislabel a failure as a status.
*
* SUITE DIRECTION, predicted before running: the EMPTY arm is red against
* `origin/main`; the ERROR arm is green in both worlds (its `role="alert"` is
* typed at the call site, not inherited), and is here as the negative control
* that keeps a suite collapsed to "no tests" from reading as the fix.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'work_order',
fields: ['name'],
};

function renderWith(find: () => Promise<unknown>) {
const ds = {
find: vi.fn().mockImplementation(find),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function panel(container: HTMLElement, testId: string): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull();
});
return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement;
}

describe('ListView — an empty list is not a failed list (#7132)', () => {
it('EMPTY: the empty state is announced as role="status"', async () => {
const { container } = renderWith(() => Promise.resolve([]));
const box = await panel(container, 'empty-state');
expect(box.getAttribute('role')).toBe('status');
// Guard against the arm passing over a collapsed render: the empty copy
// must actually be present in the box being measured.
expect(box.textContent).toMatch(/nothing here yet/i);
});

it('ERROR: the load-failure panel is announced as role="alert"', async () => {
const { container } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const box = await panel(container, 'list-error-state');
expect(box.getAttribute('role')).toBe('alert');
expect(box.getAttribute('data-error-kind')).toBe('forbidden');
});

it('the two branches carry DIFFERENT roles, by exact value', async () => {
const { container: emptyC } = renderWith(() => Promise.resolve([]));
const emptyRole = (await panel(emptyC, 'empty-state')).getAttribute('role');
const { container: errC } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const errorRole = (await panel(errC, 'list-error-state')).getAttribute('role');
// Asserted by exact value, not by inequality: on `origin/main` the roles
// were `null` and `null`, but a partial fix leaving the empty branch at
// `null` would still satisfy `null !== 'alert'` and pass a mere-difference
// assertion. Both values must be named for this arm to be able to fail.
expect(emptyRole).toBe('status');
expect(errorRole).toBe('alert');
});
});
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
50 changes: 50 additions & 0 deletions .changeset/7132-empty-state-role-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': patch
---

`DataEmptyState` now declares `role="status"` by default, so an empty result is
distinguishable from a failed one on every surface that renders it
(objectui#7132).

This is the convergence half of the two rulings that landed as objectui#7063 and
objectui#7064, both resting on objectstack#13848: uniform behaviour belongs to
the platform, and per-surface compensation is the per-app tax being ruled
against. Those two fixed their own surfaces deliberately and locally; this card
measured whether the shared primitive should carry the property. It did not.

**Measured, not assumed.** All the surfaces were rendered and their empty boxes
read directly:

| surface | `role` before |
|---|---|
| `DataEmptyState` bare default | *none* |
| `plugin-list` empty list | *none* |
| `plugin-list` load-error panel | *none* |
| `plugin-detail` activity timelines | *none* |
| `ui:empty` schema renderer | *none* |
| `plugin-dashboard` `WidgetEmptyState` (#7063) | `status`, typed at the call site |
| `plugin-kanban` empty board | `status`, typed at the call site |

The sibling states in the same file had always declared themselves —
`DataLoadingState` is `role="status"`, `DataErrorState` is `role="alert"` — and
the empty state alone declared nothing. So the surfaces were not legitimately
differing: the ones that wanted the property had each hand-typed the same line,
and the ones that had not yet done so were silently missing it. That is one
platform default, copied by hand, at package level.

**It is a default, not a fixed attribute** — `role` is spread from props, so a
call site keeps the last word. That is what makes this inert for the two ruled
surfaces: both already pass `role="status"` explicitly and receive the identical
attribute with or without it. Neither surface's behaviour changes.

**One real defect fell out of the measurement.** `plugin-list` renders its load
FAILURE through `DataEmptyState`, borrowing it for layout — so a 403 saying "You
don't have access" and a young object saying "Nothing here yet" were the same
node shape, with no role on either. That panel now declares `role="alert"`,
which both fixes the pre-existing indistinguishability and stops the new default
from announcing an outage as a routine status.

Metric/KPI widgets are untouched: their carve-out (`rows.length === 0 &&
!isMetric`) gates whether an empty state is rendered *at all*, upstream of this
component, so a KPI still reads `0` rather than "no data".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* 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#7132 — the empty state must declare what it is.
*
* `DataLoadingState` has always been `role="status"` and `DataErrorState`
* `role="alert"`; `DataEmptyState` alone declared nothing, so "this list is
* young" and "this list failed to load" were the same node shape. Both the
* hotcrm#1212 (#7063) and hotcrm#1247 (#7064) rulings name *distinguishable
* from a load failure* as the first property an empty state owes, and four
* separate call sites had each hand-typed `role="status"` to get it.
*
* SUITE DIRECTION, predicted before running: the DEFAULT arm is red against
* `origin/main` and green after. The OVERRIDE arms and the two SIBLING arms are
* green in both worlds — they are the negative controls proving the default is
* a default (a call site keeps the last word, which is why the two already-ruled
* surfaces are inert under this change) and that the contrast it is measured
* against is real.
*/

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

const emptyBox = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state"]');

describe('DataEmptyState — role default (#7132)', () => {
it('DEFAULT: declares role="status" with no call-site prop', () => {
const { container } = render(<DataEmptyState />);
const box = emptyBox(container);
expect(box).not.toBeNull();
expect(box!.getAttribute('role')).toBe('status');
});

it('DEFAULT survives the props a real call site passes alongside it', () => {
const { container } = render(
<DataEmptyState title="Nothing here yet" description="Create your first record." />,
);
expect(emptyBox(container)!.getAttribute('role')).toBe('status');
// The default must not have displaced the rest of the render.
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});

it('OVERRIDE: the already-ruled surfaces pass role="status" explicitly and are unchanged', () => {
// plugin-dashboard's WidgetEmptyState (#7063) and plugin-kanban both spell
// this out. They must receive the identical attribute with or without the
// default, which is what makes #7132 inert for them.
const { container } = render(<DataEmptyState role="status" aria-live="polite" />);
const box = emptyBox(container)!;
expect(box.getAttribute('role')).toBe('status');
expect(box.getAttribute('aria-live')).toBe('polite');
});

it('SIBLING CONTRAST: the error state is an alert and the loading state a status', () => {
const { container: err } = render(<DataErrorState />);
expect(err.querySelector('[data-slot="data-error-state"]')!.getAttribute('role')).toBe('alert');
const { container: load } = render(<DataLoadingState />);
expect(load.querySelector('[data-slot="data-loading-state"]')!.getAttribute('role')).toBe('status');
});
});
26 changes: 26 additions & 0 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,31 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
action?: React.ReactNode
}

/**
* `role` defaults to `"status"` (objectui#7132).
*
* The sibling states in this file each declare what they are — `DataLoadingState`
* is `role="status"`, `DataErrorState` is `role="alert"` — and the empty state
* alone declared nothing, so an empty box and a failed box were the same node
* shape to a screen reader and to any structural test. That is the exact
* property both the hotcrm#1212 (objectui#7063) and hotcrm#1247 (objectui#7064)
* rulings named first: an empty state must be *distinguishable from a load
* failure at a glance*.
*
* With no default, every surface that wanted the property had to type it at its
* own call site, and four independently did — `plugin-kanban`'s empty board,
* `plugin-dashboard`'s `WidgetEmptyState`, and `plugin-charts`' `ObjectChart` —
* while `plugin-list`, `plugin-detail`'s two timelines and the `ui:empty`
* renderer silently did not. Four hand-copies of one line is the per-app tax
* objectstack#13848 rules against, paid at the package level.
*
* It is a DEFAULT, not a fixed attribute: `role` is spread from `props` below,
* 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"`).
*/
function DataEmptyState({
className,
icon,
Expand All@@ -102,6 +127,7 @@ function DataEmptyState({

return (
<div
role="status"
data-slot="data-empty-state"
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 text-center",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3582,6 +3582,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
empty state on slow networks. */}
{loadError && data.length === 0 ? (
<DataEmptyState
// This panel is NOT an empty state — it is the load FAILURE, and it
// borrows `DataEmptyState` only for its layout. Since objectui#7132
// that component defaults to `role="status"`, which would announce a
// 403 or an outage as a routine status update, so this call site
// declares what it actually is. (Measured: before #7132 neither this
// panel nor the empty state below carried any role, so "you don't
// have access" and "nothing here yet" were the same node shape.)
role="alert"
data-testid="list-error-state"
data-error-kind={loadErrorKind}
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
/**
* 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#7132 — a list that is EMPTY and a list that FAILED TO LOAD must not
* be the same node shape.
*
* Measured on `origin/main` by rendering both branches: each one is a
* `data-slot="data-empty-state"` div with **no `role` at all**. A 403 telling
* the user "You don't have access" and a young object telling them "Nothing
* here yet" were structurally indistinguishable — the precise failure the
* #7063 / #7064 rulings put first, and the reason `ListView` was the
* "unmeasured" row on #7132's own table.
*
* Two things are pinned here because they are separable and either can regress
* alone: the empty branch takes the platform default (#7132), and the error
* branch — which borrows `DataEmptyState` purely for its layout — overrides it
* back to `alert` so the default cannot mislabel a failure as a status.
*
* SUITE DIRECTION, predicted before running: the EMPTY arm is red against
* `origin/main`; the ERROR arm is green in both worlds (its `role="alert"` is
* typed at the call site, not inherited), and is here as the negative control
* that keeps a suite collapsed to "no tests" from reading as the fix.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'work_order',
fields: ['name'],
};

function renderWith(find: () => Promise<unknown>) {
const ds = {
find: vi.fn().mockImplementation(find),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function panel(container: HTMLElement, testId: string): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull();
});
return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement;
}

describe('ListView — an empty list is not a failed list (#7132)', () => {
it('EMPTY: the empty state is announced as role="status"', async () => {
const { container } = renderWith(() => Promise.resolve([]));
const box = await panel(container, 'empty-state');
expect(box.getAttribute('role')).toBe('status');
// Guard against the arm passing over a collapsed render: the empty copy
// must actually be present in the box being measured.
expect(box.textContent).toMatch(/nothing here yet/i);
});

it('ERROR: the load-failure panel is announced as role="alert"', async () => {
const { container } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const box = await panel(container, 'list-error-state');
expect(box.getAttribute('role')).toBe('alert');
expect(box.getAttribute('data-error-kind')).toBe('forbidden');
});

it('the two branches carry DIFFERENT roles, by exact value', async () => {
const { container: emptyC } = renderWith(() => Promise.resolve([]));
const emptyRole = (await panel(emptyC, 'empty-state')).getAttribute('role');
const { container: errC } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const errorRole = (await panel(errC, 'list-error-state')).getAttribute('role');
// Asserted by exact value, not by inequality: on `origin/main` the roles
// were `null` and `null`, but a partial fix leaving the empty branch at
// `null` would still satisfy `null !== 'alert'` and pass a mere-difference
// assertion. Both values must be named for this arm to be able to fail.
expect(emptyRole).toBe('status');
expect(errorRole).toBe('alert');
});
});
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
50 changes: 50 additions & 0 deletions .changeset/7132-empty-state-role-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': patch
---

`DataEmptyState` now declares `role="status"` by default, so an empty result is
distinguishable from a failed one on every surface that renders it
(objectui#7132).

This is the convergence half of the two rulings that landed as objectui#7063 and
objectui#7064, both resting on objectstack#13848: uniform behaviour belongs to
the platform, and per-surface compensation is the per-app tax being ruled
against. Those two fixed their own surfaces deliberately and locally; this card
measured whether the shared primitive should carry the property. It did not.

**Measured, not assumed.** All the surfaces were rendered and their empty boxes
read directly:

| surface | `role` before |
|---|---|
| `DataEmptyState` bare default | *none* |
| `plugin-list` empty list | *none* |
| `plugin-list` load-error panel | *none* |
| `plugin-detail` activity timelines | *none* |
| `ui:empty` schema renderer | *none* |
| `plugin-dashboard` `WidgetEmptyState` (#7063) | `status`, typed at the call site |
| `plugin-kanban` empty board | `status`, typed at the call site |

The sibling states in the same file had always declared themselves —
`DataLoadingState` is `role="status"`, `DataErrorState` is `role="alert"` — and
the empty state alone declared nothing. So the surfaces were not legitimately
differing: the ones that wanted the property had each hand-typed the same line,
and the ones that had not yet done so were silently missing it. That is one
platform default, copied by hand, at package level.

**It is a default, not a fixed attribute** — `role` is spread from props, so a
call site keeps the last word. That is what makes this inert for the two ruled
surfaces: both already pass `role="status"` explicitly and receive the identical
attribute with or without it. Neither surface's behaviour changes.

**One real defect fell out of the measurement.** `plugin-list` renders its load
FAILURE through `DataEmptyState`, borrowing it for layout — so a 403 saying "You
don't have access" and a young object saying "Nothing here yet" were the same
node shape, with no role on either. That panel now declares `role="alert"`,
which both fixes the pre-existing indistinguishability and stops the new default
from announcing an outage as a routine status.

Metric/KPI widgets are untouched: their carve-out (`rows.length === 0 &&
!isMetric`) gates whether an empty state is rendered *at all*, upstream of this
component, so a KPI still reads `0` rather than "no data".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* 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#7132 — the empty state must declare what it is.
*
* `DataLoadingState` has always been `role="status"` and `DataErrorState`
* `role="alert"`; `DataEmptyState` alone declared nothing, so "this list is
* young" and "this list failed to load" were the same node shape. Both the
* hotcrm#1212 (#7063) and hotcrm#1247 (#7064) rulings name *distinguishable
* from a load failure* as the first property an empty state owes, and four
* separate call sites had each hand-typed `role="status"` to get it.
*
* SUITE DIRECTION, predicted before running: the DEFAULT arm is red against
* `origin/main` and green after. The OVERRIDE arms and the two SIBLING arms are
* green in both worlds — they are the negative controls proving the default is
* a default (a call site keeps the last word, which is why the two already-ruled
* surfaces are inert under this change) and that the contrast it is measured
* against is real.
*/

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

const emptyBox = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state"]');

describe('DataEmptyState — role default (#7132)', () => {
it('DEFAULT: declares role="status" with no call-site prop', () => {
const { container } = render(<DataEmptyState />);
const box = emptyBox(container);
expect(box).not.toBeNull();
expect(box!.getAttribute('role')).toBe('status');
});

it('DEFAULT survives the props a real call site passes alongside it', () => {
const { container } = render(
<DataEmptyState title="Nothing here yet" description="Create your first record." />,
);
expect(emptyBox(container)!.getAttribute('role')).toBe('status');
// The default must not have displaced the rest of the render.
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});

it('OVERRIDE: the already-ruled surfaces pass role="status" explicitly and are unchanged', () => {
// plugin-dashboard's WidgetEmptyState (#7063) and plugin-kanban both spell
// this out. They must receive the identical attribute with or without the
// default, which is what makes #7132 inert for them.
const { container } = render(<DataEmptyState role="status" aria-live="polite" />);
const box = emptyBox(container)!;
expect(box.getAttribute('role')).toBe('status');
expect(box.getAttribute('aria-live')).toBe('polite');
});

it('SIBLING CONTRAST: the error state is an alert and the loading state a status', () => {
const { container: err } = render(<DataErrorState />);
expect(err.querySelector('[data-slot="data-error-state"]')!.getAttribute('role')).toBe('alert');
const { container: load } = render(<DataLoadingState />);
expect(load.querySelector('[data-slot="data-loading-state"]')!.getAttribute('role')).toBe('status');
});
});
26 changes: 26 additions & 0 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,31 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
action?: React.ReactNode
}

/**
* `role` defaults to `"status"` (objectui#7132).
*
* The sibling states in this file each declare what they are — `DataLoadingState`
* is `role="status"`, `DataErrorState` is `role="alert"` — and the empty state
* alone declared nothing, so an empty box and a failed box were the same node
* shape to a screen reader and to any structural test. That is the exact
* property both the hotcrm#1212 (objectui#7063) and hotcrm#1247 (objectui#7064)
* rulings named first: an empty state must be *distinguishable from a load
* failure at a glance*.
*
* With no default, every surface that wanted the property had to type it at its
* own call site, and four independently did — `plugin-kanban`'s empty board,
* `plugin-dashboard`'s `WidgetEmptyState`, and `plugin-charts`' `ObjectChart` —
* while `plugin-list`, `plugin-detail`'s two timelines and the `ui:empty`
* renderer silently did not. Four hand-copies of one line is the per-app tax
* objectstack#13848 rules against, paid at the package level.
*
* It is a DEFAULT, not a fixed attribute: `role` is spread from `props` below,
* 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"`).
*/
function DataEmptyState({
className,
icon,
Expand All@@ -102,6 +127,7 @@ function DataEmptyState({

return (
<div
role="status"
data-slot="data-empty-state"
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 text-center",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3582,6 +3582,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
empty state on slow networks. */}
{loadError && data.length === 0 ? (
<DataEmptyState
// This panel is NOT an empty state — it is the load FAILURE, and it
// borrows `DataEmptyState` only for its layout. Since objectui#7132
// that component defaults to `role="status"`, which would announce a
// 403 or an outage as a routine status update, so this call site
// declares what it actually is. (Measured: before #7132 neither this
// panel nor the empty state below carried any role, so "you don't
// have access" and "nothing here yet" were the same node shape.)
role="alert"
data-testid="list-error-state"
data-error-kind={loadErrorKind}
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
/**
* 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#7132 — a list that is EMPTY and a list that FAILED TO LOAD must not
* be the same node shape.
*
* Measured on `origin/main` by rendering both branches: each one is a
* `data-slot="data-empty-state"` div with **no `role` at all**. A 403 telling
* the user "You don't have access" and a young object telling them "Nothing
* here yet" were structurally indistinguishable — the precise failure the
* #7063 / #7064 rulings put first, and the reason `ListView` was the
* "unmeasured" row on #7132's own table.
*
* Two things are pinned here because they are separable and either can regress
* alone: the empty branch takes the platform default (#7132), and the error
* branch — which borrows `DataEmptyState` purely for its layout — overrides it
* back to `alert` so the default cannot mislabel a failure as a status.
*
* SUITE DIRECTION, predicted before running: the EMPTY arm is red against
* `origin/main`; the ERROR arm is green in both worlds (its `role="alert"` is
* typed at the call site, not inherited), and is here as the negative control
* that keeps a suite collapsed to "no tests" from reading as the fix.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'work_order',
fields: ['name'],
};

function renderWith(find: () => Promise<unknown>) {
const ds = {
find: vi.fn().mockImplementation(find),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function panel(container: HTMLElement, testId: string): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull();
});
return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement;
}

describe('ListView — an empty list is not a failed list (#7132)', () => {
it('EMPTY: the empty state is announced as role="status"', async () => {
const { container } = renderWith(() => Promise.resolve([]));
const box = await panel(container, 'empty-state');
expect(box.getAttribute('role')).toBe('status');
// Guard against the arm passing over a collapsed render: the empty copy
// must actually be present in the box being measured.
expect(box.textContent).toMatch(/nothing here yet/i);
});

it('ERROR: the load-failure panel is announced as role="alert"', async () => {
const { container } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const box = await panel(container, 'list-error-state');
expect(box.getAttribute('role')).toBe('alert');
expect(box.getAttribute('data-error-kind')).toBe('forbidden');
});

it('the two branches carry DIFFERENT roles, by exact value', async () => {
const { container: emptyC } = renderWith(() => Promise.resolve([]));
const emptyRole = (await panel(emptyC, 'empty-state')).getAttribute('role');
const { container: errC } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const errorRole = (await panel(errC, 'list-error-state')).getAttribute('role');
// Asserted by exact value, not by inequality: on `origin/main` the roles
// were `null` and `null`, but a partial fix leaving the empty branch at
// `null` would still satisfy `null !== 'alert'` and pass a mere-difference
// assertion. Both values must be named for this arm to be able to fail.
expect(emptyRole).toBe('status');
expect(errorRole).toBe('alert');
});
});
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
50 changes: 50 additions & 0 deletions .changeset/7132-empty-state-role-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': patch
---

`DataEmptyState` now declares `role="status"` by default, so an empty result is
distinguishable from a failed one on every surface that renders it
(objectui#7132).

This is the convergence half of the two rulings that landed as objectui#7063 and
objectui#7064, both resting on objectstack#13848: uniform behaviour belongs to
the platform, and per-surface compensation is the per-app tax being ruled
against. Those two fixed their own surfaces deliberately and locally; this card
measured whether the shared primitive should carry the property. It did not.

**Measured, not assumed.** All the surfaces were rendered and their empty boxes
read directly:

| surface | `role` before |
|---|---|
| `DataEmptyState` bare default | *none* |
| `plugin-list` empty list | *none* |
| `plugin-list` load-error panel | *none* |
| `plugin-detail` activity timelines | *none* |
| `ui:empty` schema renderer | *none* |
| `plugin-dashboard` `WidgetEmptyState` (#7063) | `status`, typed at the call site |
| `plugin-kanban` empty board | `status`, typed at the call site |

The sibling states in the same file had always declared themselves —
`DataLoadingState` is `role="status"`, `DataErrorState` is `role="alert"` — and
the empty state alone declared nothing. So the surfaces were not legitimately
differing: the ones that wanted the property had each hand-typed the same line,
and the ones that had not yet done so were silently missing it. That is one
platform default, copied by hand, at package level.

**It is a default, not a fixed attribute** — `role` is spread from props, so a
call site keeps the last word. That is what makes this inert for the two ruled
surfaces: both already pass `role="status"` explicitly and receive the identical
attribute with or without it. Neither surface's behaviour changes.

**One real defect fell out of the measurement.** `plugin-list` renders its load
FAILURE through `DataEmptyState`, borrowing it for layout — so a 403 saying "You
don't have access" and a young object saying "Nothing here yet" were the same
node shape, with no role on either. That panel now declares `role="alert"`,
which both fixes the pre-existing indistinguishability and stops the new default
from announcing an outage as a routine status.

Metric/KPI widgets are untouched: their carve-out (`rows.length === 0 &&
!isMetric`) gates whether an empty state is rendered *at all*, upstream of this
component, so a KPI still reads `0` rather than "no data".
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
/**
* 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#7132 — the empty state must declare what it is.
*
* `DataLoadingState` has always been `role="status"` and `DataErrorState`
* `role="alert"`; `DataEmptyState` alone declared nothing, so "this list is
* young" and "this list failed to load" were the same node shape. Both the
* hotcrm#1212 (#7063) and hotcrm#1247 (#7064) rulings name *distinguishable
* from a load failure* as the first property an empty state owes, and four
* separate call sites had each hand-typed `role="status"` to get it.
*
* SUITE DIRECTION, predicted before running: the DEFAULT arm is red against
* `origin/main` and green after. The OVERRIDE arms and the two SIBLING arms are
* green in both worlds — they are the negative controls proving the default is
* a default (a call site keeps the last word, which is why the two already-ruled
* surfaces are inert under this change) and that the contrast it is measured
* against is real.
*/

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

const emptyBox = (c: HTMLElement) => c.querySelector('[data-slot="data-empty-state"]');

describe('DataEmptyState — role default (#7132)', () => {
it('DEFAULT: declares role="status" with no call-site prop', () => {
const { container } = render(<DataEmptyState />);
const box = emptyBox(container);
expect(box).not.toBeNull();
expect(box!.getAttribute('role')).toBe('status');
});

it('DEFAULT survives the props a real call site passes alongside it', () => {
const { container } = render(
<DataEmptyState title="Nothing here yet" description="Create your first record." />,
);
expect(emptyBox(container)!.getAttribute('role')).toBe('status');
// The default must not have displaced the rest of the render.
expect(container.textContent).toContain('Nothing here yet');
});

it('OVERRIDE: a call site passing role="alert" keeps it (the load-error borrow)', () => {
const { container } = render(<DataEmptyState role="alert" title="You don’t have access" />);
expect(emptyBox(container)!.getAttribute('role')).toBe('alert');
});

it('OVERRIDE: the already-ruled surfaces pass role="status" explicitly and are unchanged', () => {
// plugin-dashboard's WidgetEmptyState (#7063) and plugin-kanban both spell
// this out. They must receive the identical attribute with or without the
// default, which is what makes #7132 inert for them.
const { container } = render(<DataEmptyState role="status" aria-live="polite" />);
const box = emptyBox(container)!;
expect(box.getAttribute('role')).toBe('status');
expect(box.getAttribute('aria-live')).toBe('polite');
});

it('SIBLING CONTRAST: the error state is an alert and the loading state a status', () => {
const { container: err } = render(<DataErrorState />);
expect(err.querySelector('[data-slot="data-error-state"]')!.getAttribute('role')).toBe('alert');
const { container: load } = render(<DataLoadingState />);
expect(load.querySelector('[data-slot="data-loading-state"]')!.getAttribute('role')).toBe('status');
});
});
26 changes: 26 additions & 0 deletions packages/components/src/custom/view-states.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,31 @@ interface DataEmptyStateProps extends React.ComponentProps<"div"> {
action?: React.ReactNode
}

/**
* `role` defaults to `"status"` (objectui#7132).
*
* The sibling states in this file each declare what they are — `DataLoadingState`
* is `role="status"`, `DataErrorState` is `role="alert"` — and the empty state
* alone declared nothing, so an empty box and a failed box were the same node
* shape to a screen reader and to any structural test. That is the exact
* property both the hotcrm#1212 (objectui#7063) and hotcrm#1247 (objectui#7064)
* rulings named first: an empty state must be *distinguishable from a load
* failure at a glance*.
*
* With no default, every surface that wanted the property had to type it at its
* own call site, and four independently did — `plugin-kanban`'s empty board,
* `plugin-dashboard`'s `WidgetEmptyState`, and `plugin-charts`' `ObjectChart` —
* while `plugin-list`, `plugin-detail`'s two timelines and the `ui:empty`
* renderer silently did not. Four hand-copies of one line is the per-app tax
* objectstack#13848 rules against, paid at the package level.
*
* It is a DEFAULT, not a fixed attribute: `role` is spread from `props` below,
* 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"`).
*/
function DataEmptyState({
className,
icon,
Expand All@@ -102,6 +127,7 @@ function DataEmptyState({

return (
<div
role="status"
data-slot="data-empty-state"
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 text-center",
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3582,6 +3582,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
empty state on slow networks. */}
{loadError && data.length === 0 ? (
<DataEmptyState
// This panel is NOT an empty state — it is the load FAILURE, and it
// borrows `DataEmptyState` only for its layout. Since objectui#7132
// that component defaults to `role="status"`, which would announce a
// 403 or an outage as a routine status update, so this call site
// declares what it actually is. (Measured: before #7132 neither this
// panel nor the empty state below carried any role, so "you don't
// have access" and "nothing here yet" were the same node shape.)
role="alert"
data-testid="list-error-state"
data-error-kind={loadErrorKind}
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
/**
* 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#7132 — a list that is EMPTY and a list that FAILED TO LOAD must not
* be the same node shape.
*
* Measured on `origin/main` by rendering both branches: each one is a
* `data-slot="data-empty-state"` div with **no `role` at all**. A 403 telling
* the user "You don't have access" and a young object telling them "Nothing
* here yet" were structurally indistinguishable — the precise failure the
* #7063 / #7064 rulings put first, and the reason `ListView` was the
* "unmeasured" row on #7132's own table.
*
* Two things are pinned here because they are separable and either can regress
* alone: the empty branch takes the platform default (#7132), and the error
* branch — which borrows `DataEmptyState` purely for its layout — overrides it
* back to `alert` so the default cannot mislabel a failure as a status.
*
* SUITE DIRECTION, predicted before running: the EMPTY arm is red against
* `origin/main`; the ERROR arm is green in both worlds (its `role="alert"` is
* typed at the call site, not inherited), and is here as the negative control
* that keeps a suite collapsed to "no tests" from reading as the fix.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'work_order',
fields: ['name'],
};

function renderWith(find: () => Promise<unknown>) {
const ds = {
find: vi.fn().mockImplementation(find),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function panel(container: HTMLElement, testId: string): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull();
});
return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement;
}

describe('ListView — an empty list is not a failed list (#7132)', () => {
it('EMPTY: the empty state is announced as role="status"', async () => {
const { container } = renderWith(() => Promise.resolve([]));
const box = await panel(container, 'empty-state');
expect(box.getAttribute('role')).toBe('status');
// Guard against the arm passing over a collapsed render: the empty copy
// must actually be present in the box being measured.
expect(box.textContent).toMatch(/nothing here yet/i);
});

it('ERROR: the load-failure panel is announced as role="alert"', async () => {
const { container } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const box = await panel(container, 'list-error-state');
expect(box.getAttribute('role')).toBe('alert');
expect(box.getAttribute('data-error-kind')).toBe('forbidden');
});

it('the two branches carry DIFFERENT roles, by exact value', async () => {
const { container: emptyC } = renderWith(() => Promise.resolve([]));
const emptyRole = (await panel(emptyC, 'empty-state')).getAttribute('role');
const { container: errC } = renderWith(() =>
Promise.reject(Object.assign(new Error('Forbidden'), { httpStatus: 403 })),
);
const errorRole = (await panel(errC, 'list-error-state')).getAttribute('role');
// Asserted by exact value, not by inequality: on `origin/main` the roles
// were `null` and `null`, but a partial fix leaving the empty branch at
// `null` would still satisfy `null !== 'alert'` and pass a mere-difference
// assertion. Both values must be named for this arm to be able to fail.
expect(emptyRole).toBe('status');
expect(errorRole).toBe('alert');
});
});
Loading