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
20 changes: 20 additions & 0 deletions .changeset/metric-props-dom-passthrough-4426.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/plugin-dashboard': minor
---

`MetricWidgetProps` / `MetricCardProps` declare the DOM pass-through their spread has always accepted

Both KPI components end their prop list with a `...domProps` spread onto the Shadcn `Card`, and objectui#4357 (PR #4428) kept that spread deliberately — it is their only accessibility pass-through, and removing it would delete the only way a host can put an `id`, a `role` or an `aria-label` on a KPI card. Neither props interface declared any of it. So the type refused what the runtime accepted: a JS consumer, and every SDUI author going through `SchemaRenderer` (untyped at that boundary), got the pass-through, while a TypeScript consumer importing the component directly got `error TS2322` on `id` / `role` / `aria-label` and needed a cast.

`MetricWidgetProps` now extends `React.HTMLAttributes<HTMLDivElement>`, and `MetricCardProps` extends the same minus `title`. That is the repo's measured convention for an exported props interface that spreads onto a host element (`PageHeaderComponentProps`, `ChatbotProps`, `ChatbotEnhancedProps`, `TypingIndicatorProps`, `RefreshIndicatorProps`, `FieldProps`, and shadcn's `BadgeProps`), and the `Omit` carve-out is `ComboboxProps`'s spelling for a name the component's own contract owns.

Graded `minor` rather than `patch` per the objectui#4403 precedent: two exported interfaces widen. The widening is purely additive for existing callers — every prop that compiled before still compiles, and nothing narrows — so no source change is required to upgrade.

Semantics worth knowing, because both are contract statements rather than incidental:

- **`MetricCard.title` stays the heading.** HTML's `title` is a tooltip; this card's `title` is its heading, in the `I18nLabel` vocabulary, destructured out and rendered into `CardTitle`. No `title` attribute has ever reached this element, so the inherited DOM `title` is omitted rather than declared and silently dropped — the "declared but not delivered" failure this repo treats as first-class (objectui#3290, objectui#3222). `MetricWidget` has no such collision (its heading is `label`) and extends the DOM attributes whole.
- **`MetricWidget.onClick` stays zero-arg**, narrower than the inherited `MouseEventHandler`, because the same handler is wired to Enter/Space where there is no mouse event to hand over. A zero-arg function is assignable to the inherited signature, so callers already passing `(e) => …` keep compiling.

Not declared, deliberately: the schema-shaped keys `SchemaRenderer` injects (`schema` / `bind` / `events` / `props` / `ariaLabel` / `ariaDescribedBy` / `dataSource`). None is an HTML attribute name, all seven are destructured out before the spread, and declaring them would re-assert as public contract exactly what PR #4428 stripped from the DOM. They stay in `SchemaHostProps`, intersected in at each component's own signature — accepted so the renderer can inject them, never part of the documented authoring surface.

Zero runtime change: no component body was touched, and PR #4428's pins pass untouched.
20 changes: 19 additions & 1 deletion packages/plugin-dashboard/src/MetricCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,25 @@ import type { I18nLabel } from '@object-ui/types';
import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react';
import type { SchemaHostProps } from './schemaHostProps';

export interface MetricCardProps {
/**
* DOM PASS-THROUGH (objectui#4426) — see `MetricWidget.tsx`'s interface header
* for the full argument; this is the same widening on the same spread, onto the
* same Shadcn `Card` (`div`).
*
* `title` is the one key `Omit`-ed, and the omission is the accurate contract
* rather than a workaround. HTML's `title` is a tooltip string; this card's
* `title` is its HEADING, in the `I18nLabel` vocabulary — an incompatible type,
* and one the component destructures out and renders into `CardTitle`, so no
* `title` attribute has ever reached this element. Declaring the inherited DOM
* `title` here would be the "declared but not delivered" failure this repo
* treats as first-class (objectui#3290, objectui#3222): it would type-check,
* read as a supported tooltip, and silently do nothing. `MetricWidget` has no
* such collision — its heading is `label` — so it extends the DOM attributes
* whole. The repo's spelling for this carve-out is `ComboboxProps`
* (`extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onChange">`),
* omitted there for the same reason: the component's own contract owns the name.
*/
export interface MetricCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
/**
* Card heading, in @objectstack/spec's `I18nLabel` vocabulary — a plain
* string or an inline per-locale map. See `MetricWidget.label` for why the
Expand Down
59 changes: 56 additions & 3 deletions packages/plugin-dashboard/src/MetricWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,44 @@ function formatMetricValue(
*/
export type { MetricColorVariant };

export interface MetricWidgetProps {
/**
* DOM PASS-THROUGH (objectui#4426). `MetricWidget` ends its prop list with a
* `...domProps` spread onto the Shadcn `Card` — a `div` — so `id`, `role`,
* `aria-*`, `data-*`, `tabIndex`, `title` and the DOM event handlers reach the
* element. objectui#4357 (PR #4428) deliberately KEPT that spread: it is the
* component's only accessibility passthrough, and removing it would delete the
* only way a host can put an `id` or an `aria-label` on a KPI card.
*
* Until now the type refused what the runtime accepted. A TypeScript consumer
* importing the component directly could not write `<MetricWidget id="revenue"
* role="region" aria-label="Revenue KPI" … />` without a cast, while a JS
* consumer and every SDUI author going through `SchemaRenderer` (untyped at
* that boundary) got the passthrough for free. This `extends` is that
* declaration — the mirror image of #4357, closing the same question from the
* other side: renderer metadata was reaching the DOM because the spread is
* open; DOM attributes were unreachable because the type was closed.
*
* Measured convention, not an invented spelling: an exported `*Props` interface
* that spreads onto a host element extends `React.HTMLAttributes<HTMLDivElement>`
* here — `PageHeaderComponentProps` (whose own doc says "it extends
* `HTMLAttributes` so every DOM prop rides along"), `ChatbotProps`,
* `ChatbotEnhancedProps`, `TypingIndicatorProps`, `RefreshIndicatorProps`,
* `FieldProps`, and shadcn's own `BadgeProps`.
*
* What this does NOT declare, deliberately: the schema-shaped keys
* `SchemaRenderer` injects (`schema` / `bind` / `events` / `props` /
* `ariaLabel` / `ariaDescribedBy` / `dataSource`). None of them is an HTML
* attribute name, all seven are destructured out and never reach the DOM, and
* declaring them here would re-assert as a public contract exactly what #4357
* stripped. They stay in `SchemaHostProps`, intersected in at the component's
* own signature — the renderer's private door, not the consumer's front door.
* The narrower `FieldWidgetDomProps` shape (objectui#3221) is the other spelling
* in this repo, but it exists to be bound key-by-key to a runtime whitelist
* (`toDomProps`) in both directions; this component has no such whitelist, and
* inventing a half of one here would declare a set the spread does not enforce.
* Whether plugin widgets should get that whitelist is objectui#4425.
*/
export interface MetricWidgetProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* The KPI's heading, in @objectstack/spec's `I18nLabel` vocabulary — a plain
* string or an inline per-locale map (`{ en: 'Revenue', 'zh-CN': '收入' }`).
Expand Down Expand Up @@ -181,11 +218,27 @@ export interface MetricWidgetProps {
format?: string;
/** ISO currency code (e.g. `'USD'`); enables currency formatting on numeric values. */
currency?: string;
/** Static prefix appended in front of the formatted value (e.g. `'$'`, `'¥'`). */
/**
* Static prefix appended in front of the formatted value (e.g. `'$'`, `'¥'`).
*
* Same name, same `string` type as the inherited RDFa `prefix` attribute, and
* this declaration wins: the component reads it and destructures it out, so
* it never reaches the DOM. That is the accurate contract — it was already
* true before this interface declared its passthrough.
*/
prefix?: string;
/** Static suffix appended after the formatted value (e.g. `' /mo'`). */
suffix?: string;
/** When set, the entire card becomes clickable and emits this handler. */
/**
* When set, the entire card becomes clickable and emits this handler.
*
* Deliberately NARROWER than the inherited `MouseEventHandler<HTMLDivElement>`:
* the KPI card treats a click as "the tile was activated" and wires the same
* handler to Enter/Space, where there is no mouse event to hand over. A
* zero-arg function is assignable to the inherited signature, so consumers
* already passing `(e) => …` keep compiling; this only stops the component
* from promising an event object its keyboard path cannot produce.
*/
onClick?: () => void;
/**
* Layout variant. `'card'` (default) is the bordered KPI card; `'bare'` drops
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* 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#4426 — the RUNTIME half of the KPI cards' DOM pass-through contract.
*
* Both components end their prop list with a `...domProps` spread onto the
* Shadcn `Card`, and objectui#4357 (PR #4428) kept that spread deliberately: it
* is their only accessibility pass-through. But neither props interface extended
* `React.HTMLAttributes`, so what the runtime accepted the TYPE refused:
*
* ```
* probe.tsx(11,5): error TS2322: Type '{ label: string; value: number; id: string;
* role: string; "aria-label": string; … }' is not assignable to type
* 'IntrinsicAttributes & MetricWidgetProps & SchemaHostProps'.
* Property 'id' does not exist on type '…'.
* ```
*
* A JS consumer, and every SDUI author going through `SchemaRenderer` (untyped
* at that boundary), got the pass-through; a TypeScript consumer importing the
* component directly needed a cast.
*
* ## What is asserted WHERE, and why the split is not arbitrary
*
* The defect was type-only — `id` / `role` / `aria-label` reached the card the
* whole time — so the fix's own direction cannot be observed by anything vitest
* runs. The compile-time assertions therefore live in `../domPassthroughPins.ts`,
* a SOURCE module the package's `tsc --noEmit` actually compiles. They are not
* here, and deliberately so: this package's tests are compiled by nothing
* (`tsconfig.json` excludes `**\/*.test.tsx`, the package is the sole remaining
* `TEST_DEBT` entry in `scripts/check-type-check-coverage.mjs`, and vitest erases
* types), which is objectui#3181 — assertions in an uncompiled test file read as
* coverage and are decoration. A `@ts-expect-error` written here would be
* especially dishonest: nothing would ever check that the error it expects still
* happens.
*
* What IS real here, and only here: that those attributes actually land on the
* element, and that `title` does NOT. Case (c) is the one that would catch the
* tempting bad fix for the `title` collision — declaring the inherited DOM
* `title` instead of omitting it would type-check, read as a supported tooltip,
* and silently do nothing (objectui#3290 / objectui#3222's first-class failure).
*
* DIRECTIONS, written before the run: all three cases are GREEN on both sides of
* #4426 at RUNTIME — the spread already forwarded these attributes, and this
* change adds no runtime behaviour at all. They are a regression floor for the
* spread `MetricWidget.domProps.test.tsx` case (e) protects from the other side,
* not evidence of the type fix. The type fix's before/after direction is the
* pins file, and is recorded in the PR.
*/

import * as React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { MetricWidget } from '../MetricWidget';
import { MetricCard } from '../MetricCard';

afterEach(cleanup);

function wrap(ui: React.ReactElement) {
return render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false, resources: {} }}>
{ui}
</I18nProvider>,
);
}

describe('MetricWidget / MetricCard — the declared DOM pass-through reaches the element (#4426)', () => {
it('(a) MetricWidget carries a direct consumer\'s DOM identity and ARIA', () => {
const { container } = wrap(
<MetricWidget
label="Total Revenue"
value={1930000}
id="revenue"
role="region"
aria-label="Revenue KPI"
aria-describedby="revenue-desc"
tabIndex={0}
data-testid="revenue-kpi"
className="kpi-tile"
/>,
);

const el = container.firstElementChild as HTMLElement;
expect(el.getAttribute('id')).toBe('revenue');
expect(el.getAttribute('role')).toBe('region');
expect(el.getAttribute('aria-label')).toBe('Revenue KPI');
expect(el.getAttribute('aria-describedby')).toBe('revenue-desc');
expect(el.getAttribute('tabindex')).toBe('0');
expect(el.getAttribute('data-testid')).toBe('revenue-kpi');
expect(el.className).toContain('kpi-tile');
// Zero runtime change: the KPI still renders exactly as before.
expect(container.textContent).toContain('Total Revenue');
expect(container.textContent).toContain('1,930,000');
});

it('(b) MetricCard does the same through its own props', () => {
const { container } = wrap(
<MetricCard
title="Total Revenue"
value="1,930,000"
id="revenue-card"
role="region"
aria-label="Revenue KPI"
aria-describedby="revenue-desc"
tabIndex={0}
data-testid="revenue-card"
/>,
);

const el = container.firstElementChild as HTMLElement;
expect(el.getAttribute('id')).toBe('revenue-card');
expect(el.getAttribute('role')).toBe('region');
expect(el.getAttribute('aria-label')).toBe('Revenue KPI');
expect(el.getAttribute('aria-describedby')).toBe('revenue-desc');
expect(el.getAttribute('tabindex')).toBe('0');
expect(el.getAttribute('data-testid')).toBe('revenue-card');
expect(container.textContent).toContain('Total Revenue');
});

it('(c) MetricCard\'s `title` is the heading and never becomes a DOM tooltip', () => {
// The reason `MetricCardProps` OMITS the inherited `title` rather than
// declaring it. If a later edit "aligns" the interface by declaring the DOM
// `title`, this stays green — but the type would then promise a tooltip the
// component drops on the floor, which is what the pins file forbids and what
// this case documents from the runtime side.
const { container } = wrap(<MetricCard title="Total Revenue" value={1} id="c" />);

const el = container.firstElementChild as HTMLElement;
expect(el.getAttribute('title')).toBeNull();
expect(container.textContent).toContain('Total Revenue');
});
});
Loading
Loading