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
33 changes: 33 additions & 0 deletions .changeset/7008-field-edit-widget-host-plumbing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-kanban': patch
---

`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008).

objectui#7009 made the factory forward its declared DOM pass-through block. The rest of
`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole
"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type
error and the widget never received it — the "declared but not delivered" class this
package treats as first-class.

`error` was the live one. `InlineFieldInput` has passed `error` into this factory since
PR #7109 and the factory dropped it, so an inline-edit control that had failed validation
never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was
told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it
computes the validation state and could not hand it over — and now passes `error`, so its
controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays
with the host, per the objectui#3222 contract.

The keys travel through a new sibling executor, `toHostProps` (exported alongside
`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a
`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop.
Three compile-time assertions make the two executors partition the contract, so a future
declared key cannot go undelivered silently.

`dataSource` precedence is stated rather than left to emerge: a host's explicit
`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField`
already implements; the factory is a conduit and resolves nothing. A host that passes no
`dataSource` keeps reading the context exactly as before, so no in-repo host changes
behaviour.
45 changes: 45 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js';
// The package's own executor of the DOM pass-through declaration, reused here
// rather than re-listed — see the note on this component's return statement.
import { toDomProps } from './widgets/toDomProps.js';
// The package's own executor of the NON-DOM half of the same declaration
// (objectui#7008) — a separate function because those keys are not DOM-legal.
import { toHostProps } from './widgets/toHostProps.js';

// The SAME dedicated widgets the form renders — reused for in-place editing
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
Expand DownExpand Up@@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user']);
* each widget's own whitelist carries these onto the real focusable control, so
* nothing here needs to know which element that is. A host that passes nothing
* is unaffected.
*
* The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the
* sibling executor `toHostProps`. The DOM fix left the other half of the
* contract undelivered: `error`, `onUploadingChange` and the "Host plumbing"
* block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
* `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as
* supported, and never reached the widget. `error` was the live one:
* `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory
* dropped it, so a control that had failed validation never reported
* `aria-invalid`. Forwarding is not a widening — every one of those keys is
* already declared on `FieldWidgetComponentProps`, the same argument #7009
* landed on in this file.
*
* ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that
* whitelist is closed for exactly this reason — a `dataSource` adapter routed
* there becomes `dataSource="[object Object]"` on an `<input>`, the leak the
* helper exists to prevent. `toHostProps`' direction-3 assertion makes the two
* sets provably disjoint, so the order of the two spreads below is not a
* question anyone has to answer again.
*
* ## `dataSource` precedence: the explicit prop WINS
*
* Delivering `dataSource` can change behaviour where before it could not
* arrive, because the relational widgets fall back to `SchemaRendererContext`
* (which the grid already provides). The precedence is therefore STATED rather
* than left to emerge: **a host's explicit `dataSource` prop wins over the
* context**. That is not a new decision — `LookupField` already resolves
* `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ??
* contextDataSource` and documents that order on the line that does it. This
* factory is a CONDUIT and resolves nothing: adding a resolution here would
* give `dataSource` a second author, the `field || schema` shape objectui#3233
* removed. A host that passes no `dataSource` keeps reading the context exactly
* as before, so no in-repo host changes behaviour. The full per-key precedence
* table lives on `toHostProps`, next to the list it governs.
*/
export function FieldEditWidget(
props: FieldWidgetComponentProps<any>,
Expand DownExpand Up@@ -324,9 +361,17 @@ export function FieldEditWidget(
// The semantic props stay explicit and come AFTER the spread. They are not in
// the whitelist, so there is no collision to resolve; ordering them this way
// states that this component OWNS them and a host cannot displace them.
//
// `toHostProps` is the same reuse argument applied to the other half of the
// declaration (objectui#7008): the declared NON-DOM keys — `error` and the
// "Host plumbing" block — travel as COMPONENT props, never through the DOM
// whitelist, which is closed against exactly them. The two executors are
// asserted disjoint at compile time, so neither spread can shadow the other,
// and `compact` below still wins because the factory owns it.
return (
<Widget
{...toDomProps(props)}
{...toHostProps(props)}
field={field}
value={value}
onChange={onChange}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
/**
* 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.
*/

/**
* `FieldEditWidget` DELIVERS the NON-DOM half of the contract it DECLARES
* (objectui#7008) — the other half of objectui#6909 / #7009.
*
* ## The defect this pins closed
*
* #7009 made the factory forward `toDomProps(props)`, so the declared DOM block
* finally arrived. `FieldWidgetComponentProps` also declares `error`,
* `onUploadingChange`, and a whole "Host plumbing" block (`dataSource`,
* `dependentValues`, `dependsOn`, `dependsOnLabels`, `emptyHint`,
* `onSelectRecord`, `onCreateNew`) — and nothing carried any of it. A host
* passed them with no type error and the widget never received them.
*
* `error` was the LIVE one, and measurably so on `main` at `71d83a6b1`:
* `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already
* passes `error={error}` into this factory, which dropped it — so a control
* that had failed validation never reported `aria-invalid`. A sighted user saw
* the red hint; a screen-reader user was told nothing. That is the class
* objectui#3222 / #3290 exist to close, and the one #7002 closed for
* `NumberField` one layer down.
*
* ## What binds it, and what this file adds
*
* The fix hands the widget `toHostProps(props)` — a SIBLING executor, not more
* entries in `DOM_PASS_THROUGH_KEYS`, because none of these keys is DOM-legal
* and that whitelist is closed against exactly them. Three compile-time
* assertions in `toHostProps.ts` make the two executors PARTITION the contract,
* so a future declared key cannot go undelivered without a red build.
*
* A type cannot see the two things this file pins: that the keys ARRIVE at
* runtime, and that arriving actually changes what assistive tech is told.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { SchemaRendererContext } from '@object-ui/react';

import { FieldEditWidget } from '../FieldEditWidget';
import type { FieldWidgetComponentProps } from '../widgets/types';

afterEach(() => cleanup());

/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
} as never;

/** `text` resolves to `TextField` — used only where the widget is irrelevant. */
const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never;

describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => {
it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => {
const onUploadingChange = vi.fn();
const onSelectRecord = vi.fn();
const onCreateNew = vi.fn();
const dataSource = { find: vi.fn() };

// `zzcanary` is the control, carried over from the #7009 pin: NOT declared
// on `FieldWidgetComponentProps` (passing it is a compile error, hence the
// cast), but an SDUI node or a field config can carry exactly such a key at
// runtime. Without it, "everything forwards now" would be
// indistinguishable from having reopened the bare `{...props}` spread.
const props = {
field: TEXT_FIELD,
value: '',
onChange: () => {},
readonly: false,
// the two declared controlled-input keys the factory neither owns nor
// routes to the DOM
error: 'Required',
onUploadingChange,
// the declared "Host plumbing" block, minus `compact` (factory-owned)
dataSource,
dependentValues: { account: 'a1' },
dependsOn: 'account',
dependsOnLabels: { account: 'Account' },
emptyHint: 'Pick an account first',
onSelectRecord,
onCreateNew,
zzcanary: 'CANARY-STR',
} as unknown as FieldWidgetComponentProps<string>;

// Called as a plain function rather than rendered: it uses no hooks and its
// return value IS the widget element, so this reads the handoff itself.
const element = FieldEditWidget(props);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

// Exact set, not a subset — a subset check cannot see the control key, and
// an extra key appearing here is the leak this guards.
expect(Object.keys(forwarded).sort()).toEqual(
[
// rendered by the factory itself
'field',
'value',
'onChange',
'readonly',
// the declared NON-DOM keys, via `toHostProps`
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
].sort(),
);

// Identity, not just presence: a conduit hands over the host's own object.
expect(forwarded.dataSource).toBe(dataSource);
expect(forwarded.onSelectRecord).toBe(onSelectRecord);
expect(forwarded.onCreateNew).toBe(onCreateNew);
expect(forwarded.onUploadingChange).toBe(onUploadingChange);
expect(forwarded.error).toBe('Required');

// CONTROL: the undeclared authored key is still dropped.
expect(forwarded).not.toHaveProperty('zzcanary');
});

it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => {
// The #7009 pin asserts an exact boundary set for a host that passes only
// DOM keys. Forwarding the non-DOM block as nine always-present
// `undefined`s would have broken that pin AND made the boundary unreadable
// — "what the host supplied" is the claim, so absence must survive.
const element = FieldEditWidget({
field: TEXT_FIELD,
value: '',
onChange: () => {},
} as FieldWidgetComponentProps<string>);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

for (const key of [
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
]) {
expect(forwarded).not.toHaveProperty(key);
}
// CONTROL: the factory's own props are still there, so the assertion above
// is not passing because the handoff is empty.
expect(forwarded).toHaveProperty('field');
expect(forwarded).toHaveProperty('value');
});

it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => {
const { getByTestId, rerender } = render(
<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} error="Required" />,
);
// `SelectField` puts the DOM pass-through and `aria-invalid` on
// `SelectTrigger` — the focusable `button role="combobox"` the user and
// their screen reader actually meet (objectui#3306) — not on Radix `Root`,
// which renders no element.
const trigger = getByTestId('select-trigger-stage');
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

// CONTROL: the same widget, same host, no `error`. `SelectField` computes
// `!!error`, so a valid field SAYS "false" rather than staying mute — which
// makes this a real two-state reading and not "the attribute exists".
rerender(<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} />);
expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false');
});

it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => {
// The one delivered key that can CHANGE behaviour rather than only add it:
// the relational widgets fall back to `SchemaRendererContext` (which the
// grid already provides), so delivering the prop creates a precedence
// question. `LookupField` already resolves "explicit prop > field-level >
// wrapper field > SchemaRendererContext > none"; the factory is a conduit
// and adds no second authority. This pins that the delivered prop is what
// the widget ends up querying.
const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never;
const makeSource = () => ({
find: vi.fn().mockResolvedValue([]),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }),
});

const fromProp = makeSource();
const fromContext = makeSource();

render(
<SchemaRendererContext.Provider value={{ dataSource: fromContext } as never}>
<FieldEditWidget
field={LOOKUP_FIELD}
value={undefined}
onChange={() => {}}
dataSource={fromProp}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts'));
expect(fromContext.getObjectSchema).not.toHaveBeenCalled();

cleanup();

// CONTROL: drop the prop and the SAME context source IS queried. Without
// this, "the context was not called" would be indistinguishable from a
// context that was never wired up in this test at all.
const contextOnly = makeSource();
render(
<SchemaRendererContext.Provider value={{ dataSource: contextOnly } as never}>
<FieldEditWidget field={LOOKUP_FIELD} value={undefined} onChange={() => {}} />
</SchemaRendererContext.Provider>,
);
await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts'));
});
});
9 changes: 9 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js';
export { toDomProps } from './widgets/toDomProps.js';
export type { DomProps } from './widgets/toDomProps.js';

// The sibling executor for the NON-DOM half of the same declaration
// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as
// COMPONENT props because none of them is DOM-legal. Exported alongside
// `toDomProps` because a host factory authored outside this repo needs the
// pair — reaching for only the first one is how `FieldEditWidget` came to
// deliver half the contract it declares.
export { toHostProps } from './widgets/toHostProps.js';
export type { HostProps } from './widgets/toHostProps.js';

// The native date/time control value adapters (objectui#3127). `DateTimeField`
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
// an ISO instant back, which is also the wire form the platform's `datetime`
Expand Down
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/toDomProps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [
'disabled',
] as const;

type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
/**
* The keys this helper forwards.
*
* Exported so the SIBLING executor — `toHostProps`, which carries the declared
* NON-DOM keys (objectui#7008) — can subtract this set from the contract and
* assert that the two together cover every declared key exactly once. Without
* that subtraction there is no way to state "these keys are handled elsewhere"
* as a compile-time fact rather than as a comment.
*/
export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];

/**
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7008-field-edit-widget-host-plumbing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-kanban': patch
---

`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008).

objectui#7009 made the factory forward its declared DOM pass-through block. The rest of
`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole
"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type
error and the widget never received it — the "declared but not delivered" class this
package treats as first-class.

`error` was the live one. `InlineFieldInput` has passed `error` into this factory since
PR #7109 and the factory dropped it, so an inline-edit control that had failed validation
never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was
told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it
computes the validation state and could not hand it over — and now passes `error`, so its
controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays
with the host, per the objectui#3222 contract.

The keys travel through a new sibling executor, `toHostProps` (exported alongside
`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a
`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop.
Three compile-time assertions make the two executors partition the contract, so a future
declared key cannot go undelivered silently.

`dataSource` precedence is stated rather than left to emerge: a host's explicit
`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField`
already implements; the factory is a conduit and resolves nothing. A host that passes no
`dataSource` keeps reading the context exactly as before, so no in-repo host changes
behaviour.
45 changes: 45 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js';
// The package's own executor of the DOM pass-through declaration, reused here
// rather than re-listed — see the note on this component's return statement.
import { toDomProps } from './widgets/toDomProps.js';
// The package's own executor of the NON-DOM half of the same declaration
// (objectui#7008) — a separate function because those keys are not DOM-legal.
import { toHostProps } from './widgets/toHostProps.js';

// The SAME dedicated widgets the form renders — reused for in-place editing
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
Expand DownExpand Up@@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user']);
* each widget's own whitelist carries these onto the real focusable control, so
* nothing here needs to know which element that is. A host that passes nothing
* is unaffected.
*
* The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the
* sibling executor `toHostProps`. The DOM fix left the other half of the
* contract undelivered: `error`, `onUploadingChange` and the "Host plumbing"
* block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
* `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as
* supported, and never reached the widget. `error` was the live one:
* `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory
* dropped it, so a control that had failed validation never reported
* `aria-invalid`. Forwarding is not a widening — every one of those keys is
* already declared on `FieldWidgetComponentProps`, the same argument #7009
* landed on in this file.
*
* ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that
* whitelist is closed for exactly this reason — a `dataSource` adapter routed
* there becomes `dataSource="[object Object]"` on an `<input>`, the leak the
* helper exists to prevent. `toHostProps`' direction-3 assertion makes the two
* sets provably disjoint, so the order of the two spreads below is not a
* question anyone has to answer again.
*
* ## `dataSource` precedence: the explicit prop WINS
*
* Delivering `dataSource` can change behaviour where before it could not
* arrive, because the relational widgets fall back to `SchemaRendererContext`
* (which the grid already provides). The precedence is therefore STATED rather
* than left to emerge: **a host's explicit `dataSource` prop wins over the
* context**. That is not a new decision — `LookupField` already resolves
* `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ??
* contextDataSource` and documents that order on the line that does it. This
* factory is a CONDUIT and resolves nothing: adding a resolution here would
* give `dataSource` a second author, the `field || schema` shape objectui#3233
* removed. A host that passes no `dataSource` keeps reading the context exactly
* as before, so no in-repo host changes behaviour. The full per-key precedence
* table lives on `toHostProps`, next to the list it governs.
*/
export function FieldEditWidget(
props: FieldWidgetComponentProps<any>,
Expand DownExpand Up@@ -324,9 +361,17 @@ export function FieldEditWidget(
// The semantic props stay explicit and come AFTER the spread. They are not in
// the whitelist, so there is no collision to resolve; ordering them this way
// states that this component OWNS them and a host cannot displace them.
//
// `toHostProps` is the same reuse argument applied to the other half of the
// declaration (objectui#7008): the declared NON-DOM keys — `error` and the
// "Host plumbing" block — travel as COMPONENT props, never through the DOM
// whitelist, which is closed against exactly them. The two executors are
// asserted disjoint at compile time, so neither spread can shadow the other,
// and `compact` below still wins because the factory owns it.
return (
<Widget
{...toDomProps(props)}
{...toHostProps(props)}
field={field}
value={value}
onChange={onChange}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
/**
* 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.
*/

/**
* `FieldEditWidget` DELIVERS the NON-DOM half of the contract it DECLARES
* (objectui#7008) — the other half of objectui#6909 / #7009.
*
* ## The defect this pins closed
*
* #7009 made the factory forward `toDomProps(props)`, so the declared DOM block
* finally arrived. `FieldWidgetComponentProps` also declares `error`,
* `onUploadingChange`, and a whole "Host plumbing" block (`dataSource`,
* `dependentValues`, `dependsOn`, `dependsOnLabels`, `emptyHint`,
* `onSelectRecord`, `onCreateNew`) — and nothing carried any of it. A host
* passed them with no type error and the widget never received them.
*
* `error` was the LIVE one, and measurably so on `main` at `71d83a6b1`:
* `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already
* passes `error={error}` into this factory, which dropped it — so a control
* that had failed validation never reported `aria-invalid`. A sighted user saw
* the red hint; a screen-reader user was told nothing. That is the class
* objectui#3222 / #3290 exist to close, and the one #7002 closed for
* `NumberField` one layer down.
*
* ## What binds it, and what this file adds
*
* The fix hands the widget `toHostProps(props)` — a SIBLING executor, not more
* entries in `DOM_PASS_THROUGH_KEYS`, because none of these keys is DOM-legal
* and that whitelist is closed against exactly them. Three compile-time
* assertions in `toHostProps.ts` make the two executors PARTITION the contract,
* so a future declared key cannot go undelivered without a red build.
*
* A type cannot see the two things this file pins: that the keys ARRIVE at
* runtime, and that arriving actually changes what assistive tech is told.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { SchemaRendererContext } from '@object-ui/react';

import { FieldEditWidget } from '../FieldEditWidget';
import type { FieldWidgetComponentProps } from '../widgets/types';

afterEach(() => cleanup());

/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
} as never;

/** `text` resolves to `TextField` — used only where the widget is irrelevant. */
const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never;

describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => {
it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => {
const onUploadingChange = vi.fn();
const onSelectRecord = vi.fn();
const onCreateNew = vi.fn();
const dataSource = { find: vi.fn() };

// `zzcanary` is the control, carried over from the #7009 pin: NOT declared
// on `FieldWidgetComponentProps` (passing it is a compile error, hence the
// cast), but an SDUI node or a field config can carry exactly such a key at
// runtime. Without it, "everything forwards now" would be
// indistinguishable from having reopened the bare `{...props}` spread.
const props = {
field: TEXT_FIELD,
value: '',
onChange: () => {},
readonly: false,
// the two declared controlled-input keys the factory neither owns nor
// routes to the DOM
error: 'Required',
onUploadingChange,
// the declared "Host plumbing" block, minus `compact` (factory-owned)
dataSource,
dependentValues: { account: 'a1' },
dependsOn: 'account',
dependsOnLabels: { account: 'Account' },
emptyHint: 'Pick an account first',
onSelectRecord,
onCreateNew,
zzcanary: 'CANARY-STR',
} as unknown as FieldWidgetComponentProps<string>;

// Called as a plain function rather than rendered: it uses no hooks and its
// return value IS the widget element, so this reads the handoff itself.
const element = FieldEditWidget(props);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

// Exact set, not a subset — a subset check cannot see the control key, and
// an extra key appearing here is the leak this guards.
expect(Object.keys(forwarded).sort()).toEqual(
[
// rendered by the factory itself
'field',
'value',
'onChange',
'readonly',
// the declared NON-DOM keys, via `toHostProps`
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
].sort(),
);

// Identity, not just presence: a conduit hands over the host's own object.
expect(forwarded.dataSource).toBe(dataSource);
expect(forwarded.onSelectRecord).toBe(onSelectRecord);
expect(forwarded.onCreateNew).toBe(onCreateNew);
expect(forwarded.onUploadingChange).toBe(onUploadingChange);
expect(forwarded.error).toBe('Required');

// CONTROL: the undeclared authored key is still dropped.
expect(forwarded).not.toHaveProperty('zzcanary');
});

it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => {
// The #7009 pin asserts an exact boundary set for a host that passes only
// DOM keys. Forwarding the non-DOM block as nine always-present
// `undefined`s would have broken that pin AND made the boundary unreadable
// — "what the host supplied" is the claim, so absence must survive.
const element = FieldEditWidget({
field: TEXT_FIELD,
value: '',
onChange: () => {},
} as FieldWidgetComponentProps<string>);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

for (const key of [
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
]) {
expect(forwarded).not.toHaveProperty(key);
}
// CONTROL: the factory's own props are still there, so the assertion above
// is not passing because the handoff is empty.
expect(forwarded).toHaveProperty('field');
expect(forwarded).toHaveProperty('value');
});

it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => {
const { getByTestId, rerender } = render(
<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} error="Required" />,
);
// `SelectField` puts the DOM pass-through and `aria-invalid` on
// `SelectTrigger` — the focusable `button role="combobox"` the user and
// their screen reader actually meet (objectui#3306) — not on Radix `Root`,
// which renders no element.
const trigger = getByTestId('select-trigger-stage');
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

// CONTROL: the same widget, same host, no `error`. `SelectField` computes
// `!!error`, so a valid field SAYS "false" rather than staying mute — which
// makes this a real two-state reading and not "the attribute exists".
rerender(<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} />);
expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false');
});

it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => {
// The one delivered key that can CHANGE behaviour rather than only add it:
// the relational widgets fall back to `SchemaRendererContext` (which the
// grid already provides), so delivering the prop creates a precedence
// question. `LookupField` already resolves "explicit prop > field-level >
// wrapper field > SchemaRendererContext > none"; the factory is a conduit
// and adds no second authority. This pins that the delivered prop is what
// the widget ends up querying.
const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never;
const makeSource = () => ({
find: vi.fn().mockResolvedValue([]),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }),
});

const fromProp = makeSource();
const fromContext = makeSource();

render(
<SchemaRendererContext.Provider value={{ dataSource: fromContext } as never}>
<FieldEditWidget
field={LOOKUP_FIELD}
value={undefined}
onChange={() => {}}
dataSource={fromProp}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts'));
expect(fromContext.getObjectSchema).not.toHaveBeenCalled();

cleanup();

// CONTROL: drop the prop and the SAME context source IS queried. Without
// this, "the context was not called" would be indistinguishable from a
// context that was never wired up in this test at all.
const contextOnly = makeSource();
render(
<SchemaRendererContext.Provider value={{ dataSource: contextOnly } as never}>
<FieldEditWidget field={LOOKUP_FIELD} value={undefined} onChange={() => {}} />
</SchemaRendererContext.Provider>,
);
await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts'));
});
});
9 changes: 9 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js';
export { toDomProps } from './widgets/toDomProps.js';
export type { DomProps } from './widgets/toDomProps.js';

// The sibling executor for the NON-DOM half of the same declaration
// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as
// COMPONENT props because none of them is DOM-legal. Exported alongside
// `toDomProps` because a host factory authored outside this repo needs the
// pair — reaching for only the first one is how `FieldEditWidget` came to
// deliver half the contract it declares.
export { toHostProps } from './widgets/toHostProps.js';
export type { HostProps } from './widgets/toHostProps.js';

// The native date/time control value adapters (objectui#3127). `DateTimeField`
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
// an ISO instant back, which is also the wire form the platform's `datetime`
Expand Down
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/toDomProps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [
'disabled',
] as const;

type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
/**
* The keys this helper forwards.
*
* Exported so the SIBLING executor — `toHostProps`, which carries the declared
* NON-DOM keys (objectui#7008) — can subtract this set from the contract and
* assert that the two together cover every declared key exactly once. Without
* that subtraction there is no way to state "these keys are handled elsewhere"
* as a compile-time fact rather than as a comment.
*/
export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];

/**
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7008-field-edit-widget-host-plumbing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-kanban': patch
---

`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008).

objectui#7009 made the factory forward its declared DOM pass-through block. The rest of
`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole
"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type
error and the widget never received it — the "declared but not delivered" class this
package treats as first-class.

`error` was the live one. `InlineFieldInput` has passed `error` into this factory since
PR #7109 and the factory dropped it, so an inline-edit control that had failed validation
never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was
told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it
computes the validation state and could not hand it over — and now passes `error`, so its
controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays
with the host, per the objectui#3222 contract.

The keys travel through a new sibling executor, `toHostProps` (exported alongside
`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a
`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop.
Three compile-time assertions make the two executors partition the contract, so a future
declared key cannot go undelivered silently.

`dataSource` precedence is stated rather than left to emerge: a host's explicit
`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField`
already implements; the factory is a conduit and resolves nothing. A host that passes no
`dataSource` keeps reading the context exactly as before, so no in-repo host changes
behaviour.
45 changes: 45 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js';
// The package's own executor of the DOM pass-through declaration, reused here
// rather than re-listed — see the note on this component's return statement.
import { toDomProps } from './widgets/toDomProps.js';
// The package's own executor of the NON-DOM half of the same declaration
// (objectui#7008) — a separate function because those keys are not DOM-legal.
import { toHostProps } from './widgets/toHostProps.js';

// The SAME dedicated widgets the form renders — reused for in-place editing
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
Expand DownExpand Up@@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user']);
* each widget's own whitelist carries these onto the real focusable control, so
* nothing here needs to know which element that is. A host that passes nothing
* is unaffected.
*
* The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the
* sibling executor `toHostProps`. The DOM fix left the other half of the
* contract undelivered: `error`, `onUploadingChange` and the "Host plumbing"
* block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
* `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as
* supported, and never reached the widget. `error` was the live one:
* `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory
* dropped it, so a control that had failed validation never reported
* `aria-invalid`. Forwarding is not a widening — every one of those keys is
* already declared on `FieldWidgetComponentProps`, the same argument #7009
* landed on in this file.
*
* ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that
* whitelist is closed for exactly this reason — a `dataSource` adapter routed
* there becomes `dataSource="[object Object]"` on an `<input>`, the leak the
* helper exists to prevent. `toHostProps`' direction-3 assertion makes the two
* sets provably disjoint, so the order of the two spreads below is not a
* question anyone has to answer again.
*
* ## `dataSource` precedence: the explicit prop WINS
*
* Delivering `dataSource` can change behaviour where before it could not
* arrive, because the relational widgets fall back to `SchemaRendererContext`
* (which the grid already provides). The precedence is therefore STATED rather
* than left to emerge: **a host's explicit `dataSource` prop wins over the
* context**. That is not a new decision — `LookupField` already resolves
* `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ??
* contextDataSource` and documents that order on the line that does it. This
* factory is a CONDUIT and resolves nothing: adding a resolution here would
* give `dataSource` a second author, the `field || schema` shape objectui#3233
* removed. A host that passes no `dataSource` keeps reading the context exactly
* as before, so no in-repo host changes behaviour. The full per-key precedence
* table lives on `toHostProps`, next to the list it governs.
*/
export function FieldEditWidget(
props: FieldWidgetComponentProps<any>,
Expand DownExpand Up@@ -324,9 +361,17 @@ export function FieldEditWidget(
// The semantic props stay explicit and come AFTER the spread. They are not in
// the whitelist, so there is no collision to resolve; ordering them this way
// states that this component OWNS them and a host cannot displace them.
//
// `toHostProps` is the same reuse argument applied to the other half of the
// declaration (objectui#7008): the declared NON-DOM keys — `error` and the
// "Host plumbing" block — travel as COMPONENT props, never through the DOM
// whitelist, which is closed against exactly them. The two executors are
// asserted disjoint at compile time, so neither spread can shadow the other,
// and `compact` below still wins because the factory owns it.
return (
<Widget
{...toDomProps(props)}
{...toHostProps(props)}
field={field}
value={value}
onChange={onChange}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
/**
* 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.
*/

/**
* `FieldEditWidget` DELIVERS the NON-DOM half of the contract it DECLARES
* (objectui#7008) — the other half of objectui#6909 / #7009.
*
* ## The defect this pins closed
*
* #7009 made the factory forward `toDomProps(props)`, so the declared DOM block
* finally arrived. `FieldWidgetComponentProps` also declares `error`,
* `onUploadingChange`, and a whole "Host plumbing" block (`dataSource`,
* `dependentValues`, `dependsOn`, `dependsOnLabels`, `emptyHint`,
* `onSelectRecord`, `onCreateNew`) — and nothing carried any of it. A host
* passed them with no type error and the widget never received them.
*
* `error` was the LIVE one, and measurably so on `main` at `71d83a6b1`:
* `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already
* passes `error={error}` into this factory, which dropped it — so a control
* that had failed validation never reported `aria-invalid`. A sighted user saw
* the red hint; a screen-reader user was told nothing. That is the class
* objectui#3222 / #3290 exist to close, and the one #7002 closed for
* `NumberField` one layer down.
*
* ## What binds it, and what this file adds
*
* The fix hands the widget `toHostProps(props)` — a SIBLING executor, not more
* entries in `DOM_PASS_THROUGH_KEYS`, because none of these keys is DOM-legal
* and that whitelist is closed against exactly them. Three compile-time
* assertions in `toHostProps.ts` make the two executors PARTITION the contract,
* so a future declared key cannot go undelivered without a red build.
*
* A type cannot see the two things this file pins: that the keys ARRIVE at
* runtime, and that arriving actually changes what assistive tech is told.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { SchemaRendererContext } from '@object-ui/react';

import { FieldEditWidget } from '../FieldEditWidget';
import type { FieldWidgetComponentProps } from '../widgets/types';

afterEach(() => cleanup());

/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
} as never;

/** `text` resolves to `TextField` — used only where the widget is irrelevant. */
const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never;

describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => {
it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => {
const onUploadingChange = vi.fn();
const onSelectRecord = vi.fn();
const onCreateNew = vi.fn();
const dataSource = { find: vi.fn() };

// `zzcanary` is the control, carried over from the #7009 pin: NOT declared
// on `FieldWidgetComponentProps` (passing it is a compile error, hence the
// cast), but an SDUI node or a field config can carry exactly such a key at
// runtime. Without it, "everything forwards now" would be
// indistinguishable from having reopened the bare `{...props}` spread.
const props = {
field: TEXT_FIELD,
value: '',
onChange: () => {},
readonly: false,
// the two declared controlled-input keys the factory neither owns nor
// routes to the DOM
error: 'Required',
onUploadingChange,
// the declared "Host plumbing" block, minus `compact` (factory-owned)
dataSource,
dependentValues: { account: 'a1' },
dependsOn: 'account',
dependsOnLabels: { account: 'Account' },
emptyHint: 'Pick an account first',
onSelectRecord,
onCreateNew,
zzcanary: 'CANARY-STR',
} as unknown as FieldWidgetComponentProps<string>;

// Called as a plain function rather than rendered: it uses no hooks and its
// return value IS the widget element, so this reads the handoff itself.
const element = FieldEditWidget(props);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

// Exact set, not a subset — a subset check cannot see the control key, and
// an extra key appearing here is the leak this guards.
expect(Object.keys(forwarded).sort()).toEqual(
[
// rendered by the factory itself
'field',
'value',
'onChange',
'readonly',
// the declared NON-DOM keys, via `toHostProps`
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
].sort(),
);

// Identity, not just presence: a conduit hands over the host's own object.
expect(forwarded.dataSource).toBe(dataSource);
expect(forwarded.onSelectRecord).toBe(onSelectRecord);
expect(forwarded.onCreateNew).toBe(onCreateNew);
expect(forwarded.onUploadingChange).toBe(onUploadingChange);
expect(forwarded.error).toBe('Required');

// CONTROL: the undeclared authored key is still dropped.
expect(forwarded).not.toHaveProperty('zzcanary');
});

it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => {
// The #7009 pin asserts an exact boundary set for a host that passes only
// DOM keys. Forwarding the non-DOM block as nine always-present
// `undefined`s would have broken that pin AND made the boundary unreadable
// — "what the host supplied" is the claim, so absence must survive.
const element = FieldEditWidget({
field: TEXT_FIELD,
value: '',
onChange: () => {},
} as FieldWidgetComponentProps<string>);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

for (const key of [
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
]) {
expect(forwarded).not.toHaveProperty(key);
}
// CONTROL: the factory's own props are still there, so the assertion above
// is not passing because the handoff is empty.
expect(forwarded).toHaveProperty('field');
expect(forwarded).toHaveProperty('value');
});

it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => {
const { getByTestId, rerender } = render(
<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} error="Required" />,
);
// `SelectField` puts the DOM pass-through and `aria-invalid` on
// `SelectTrigger` — the focusable `button role="combobox"` the user and
// their screen reader actually meet (objectui#3306) — not on Radix `Root`,
// which renders no element.
const trigger = getByTestId('select-trigger-stage');
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

// CONTROL: the same widget, same host, no `error`. `SelectField` computes
// `!!error`, so a valid field SAYS "false" rather than staying mute — which
// makes this a real two-state reading and not "the attribute exists".
rerender(<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} />);
expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false');
});

it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => {
// The one delivered key that can CHANGE behaviour rather than only add it:
// the relational widgets fall back to `SchemaRendererContext` (which the
// grid already provides), so delivering the prop creates a precedence
// question. `LookupField` already resolves "explicit prop > field-level >
// wrapper field > SchemaRendererContext > none"; the factory is a conduit
// and adds no second authority. This pins that the delivered prop is what
// the widget ends up querying.
const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never;
const makeSource = () => ({
find: vi.fn().mockResolvedValue([]),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }),
});

const fromProp = makeSource();
const fromContext = makeSource();

render(
<SchemaRendererContext.Provider value={{ dataSource: fromContext } as never}>
<FieldEditWidget
field={LOOKUP_FIELD}
value={undefined}
onChange={() => {}}
dataSource={fromProp}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts'));
expect(fromContext.getObjectSchema).not.toHaveBeenCalled();

cleanup();

// CONTROL: drop the prop and the SAME context source IS queried. Without
// this, "the context was not called" would be indistinguishable from a
// context that was never wired up in this test at all.
const contextOnly = makeSource();
render(
<SchemaRendererContext.Provider value={{ dataSource: contextOnly } as never}>
<FieldEditWidget field={LOOKUP_FIELD} value={undefined} onChange={() => {}} />
</SchemaRendererContext.Provider>,
);
await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts'));
});
});
9 changes: 9 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js';
export { toDomProps } from './widgets/toDomProps.js';
export type { DomProps } from './widgets/toDomProps.js';

// The sibling executor for the NON-DOM half of the same declaration
// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as
// COMPONENT props because none of them is DOM-legal. Exported alongside
// `toDomProps` because a host factory authored outside this repo needs the
// pair — reaching for only the first one is how `FieldEditWidget` came to
// deliver half the contract it declares.
export { toHostProps } from './widgets/toHostProps.js';
export type { HostProps } from './widgets/toHostProps.js';

// The native date/time control value adapters (objectui#3127). `DateTimeField`
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
// an ISO instant back, which is also the wire form the platform's `datetime`
Expand Down
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/toDomProps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [
'disabled',
] as const;

type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
/**
* The keys this helper forwards.
*
* Exported so the SIBLING executor — `toHostProps`, which carries the declared
* NON-DOM keys (objectui#7008) — can subtract this set from the contract and
* assert that the two together cover every declared key exactly once. Without
* that subtraction there is no way to state "these keys are handled elsewhere"
* as a compile-time fact rather than as a comment.
*/
export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];

/**
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7008-field-edit-widget-host-plumbing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-kanban': patch
---

`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008).

objectui#7009 made the factory forward its declared DOM pass-through block. The rest of
`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole
"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type
error and the widget never received it — the "declared but not delivered" class this
package treats as first-class.

`error` was the live one. `InlineFieldInput` has passed `error` into this factory since
PR #7109 and the factory dropped it, so an inline-edit control that had failed validation
never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was
told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it
computes the validation state and could not hand it over — and now passes `error`, so its
controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays
with the host, per the objectui#3222 contract.

The keys travel through a new sibling executor, `toHostProps` (exported alongside
`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a
`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop.
Three compile-time assertions make the two executors partition the contract, so a future
declared key cannot go undelivered silently.

`dataSource` precedence is stated rather than left to emerge: a host's explicit
`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField`
already implements; the factory is a conduit and resolves nothing. A host that passes no
`dataSource` keeps reading the context exactly as before, so no in-repo host changes
behaviour.
45 changes: 45 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js';
// The package's own executor of the DOM pass-through declaration, reused here
// rather than re-listed — see the note on this component's return statement.
import { toDomProps } from './widgets/toDomProps.js';
// The package's own executor of the NON-DOM half of the same declaration
// (objectui#7008) — a separate function because those keys are not DOM-legal.
import { toHostProps } from './widgets/toHostProps.js';

// The SAME dedicated widgets the form renders — reused for in-place editing
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
Expand DownExpand Up@@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user']);
* each widget's own whitelist carries these onto the real focusable control, so
* nothing here needs to know which element that is. A host that passes nothing
* is unaffected.
*
* The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the
* sibling executor `toHostProps`. The DOM fix left the other half of the
* contract undelivered: `error`, `onUploadingChange` and the "Host plumbing"
* block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
* `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as
* supported, and never reached the widget. `error` was the live one:
* `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory
* dropped it, so a control that had failed validation never reported
* `aria-invalid`. Forwarding is not a widening — every one of those keys is
* already declared on `FieldWidgetComponentProps`, the same argument #7009
* landed on in this file.
*
* ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that
* whitelist is closed for exactly this reason — a `dataSource` adapter routed
* there becomes `dataSource="[object Object]"` on an `<input>`, the leak the
* helper exists to prevent. `toHostProps`' direction-3 assertion makes the two
* sets provably disjoint, so the order of the two spreads below is not a
* question anyone has to answer again.
*
* ## `dataSource` precedence: the explicit prop WINS
*
* Delivering `dataSource` can change behaviour where before it could not
* arrive, because the relational widgets fall back to `SchemaRendererContext`
* (which the grid already provides). The precedence is therefore STATED rather
* than left to emerge: **a host's explicit `dataSource` prop wins over the
* context**. That is not a new decision — `LookupField` already resolves
* `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ??
* contextDataSource` and documents that order on the line that does it. This
* factory is a CONDUIT and resolves nothing: adding a resolution here would
* give `dataSource` a second author, the `field || schema` shape objectui#3233
* removed. A host that passes no `dataSource` keeps reading the context exactly
* as before, so no in-repo host changes behaviour. The full per-key precedence
* table lives on `toHostProps`, next to the list it governs.
*/
export function FieldEditWidget(
props: FieldWidgetComponentProps<any>,
Expand DownExpand Up@@ -324,9 +361,17 @@ export function FieldEditWidget(
// The semantic props stay explicit and come AFTER the spread. They are not in
// the whitelist, so there is no collision to resolve; ordering them this way
// states that this component OWNS them and a host cannot displace them.
//
// `toHostProps` is the same reuse argument applied to the other half of the
// declaration (objectui#7008): the declared NON-DOM keys — `error` and the
// "Host plumbing" block — travel as COMPONENT props, never through the DOM
// whitelist, which is closed against exactly them. The two executors are
// asserted disjoint at compile time, so neither spread can shadow the other,
// and `compact` below still wins because the factory owns it.
return (
<Widget
{...toDomProps(props)}
{...toHostProps(props)}
field={field}
value={value}
onChange={onChange}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
/**
* 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.
*/

/**
* `FieldEditWidget` DELIVERS the NON-DOM half of the contract it DECLARES
* (objectui#7008) — the other half of objectui#6909 / #7009.
*
* ## The defect this pins closed
*
* #7009 made the factory forward `toDomProps(props)`, so the declared DOM block
* finally arrived. `FieldWidgetComponentProps` also declares `error`,
* `onUploadingChange`, and a whole "Host plumbing" block (`dataSource`,
* `dependentValues`, `dependsOn`, `dependsOnLabels`, `emptyHint`,
* `onSelectRecord`, `onCreateNew`) — and nothing carried any of it. A host
* passed them with no type error and the widget never received them.
*
* `error` was the LIVE one, and measurably so on `main` at `71d83a6b1`:
* `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already
* passes `error={error}` into this factory, which dropped it — so a control
* that had failed validation never reported `aria-invalid`. A sighted user saw
* the red hint; a screen-reader user was told nothing. That is the class
* objectui#3222 / #3290 exist to close, and the one #7002 closed for
* `NumberField` one layer down.
*
* ## What binds it, and what this file adds
*
* The fix hands the widget `toHostProps(props)` — a SIBLING executor, not more
* entries in `DOM_PASS_THROUGH_KEYS`, because none of these keys is DOM-legal
* and that whitelist is closed against exactly them. Three compile-time
* assertions in `toHostProps.ts` make the two executors PARTITION the contract,
* so a future declared key cannot go undelivered without a red build.
*
* A type cannot see the two things this file pins: that the keys ARRIVE at
* runtime, and that arriving actually changes what assistive tech is told.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { SchemaRendererContext } from '@object-ui/react';

import { FieldEditWidget } from '../FieldEditWidget';
import type { FieldWidgetComponentProps } from '../widgets/types';

afterEach(() => cleanup());

/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
} as never;

/** `text` resolves to `TextField` — used only where the widget is irrelevant. */
const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never;

describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => {
it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => {
const onUploadingChange = vi.fn();
const onSelectRecord = vi.fn();
const onCreateNew = vi.fn();
const dataSource = { find: vi.fn() };

// `zzcanary` is the control, carried over from the #7009 pin: NOT declared
// on `FieldWidgetComponentProps` (passing it is a compile error, hence the
// cast), but an SDUI node or a field config can carry exactly such a key at
// runtime. Without it, "everything forwards now" would be
// indistinguishable from having reopened the bare `{...props}` spread.
const props = {
field: TEXT_FIELD,
value: '',
onChange: () => {},
readonly: false,
// the two declared controlled-input keys the factory neither owns nor
// routes to the DOM
error: 'Required',
onUploadingChange,
// the declared "Host plumbing" block, minus `compact` (factory-owned)
dataSource,
dependentValues: { account: 'a1' },
dependsOn: 'account',
dependsOnLabels: { account: 'Account' },
emptyHint: 'Pick an account first',
onSelectRecord,
onCreateNew,
zzcanary: 'CANARY-STR',
} as unknown as FieldWidgetComponentProps<string>;

// Called as a plain function rather than rendered: it uses no hooks and its
// return value IS the widget element, so this reads the handoff itself.
const element = FieldEditWidget(props);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

// Exact set, not a subset — a subset check cannot see the control key, and
// an extra key appearing here is the leak this guards.
expect(Object.keys(forwarded).sort()).toEqual(
[
// rendered by the factory itself
'field',
'value',
'onChange',
'readonly',
// the declared NON-DOM keys, via `toHostProps`
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
].sort(),
);

// Identity, not just presence: a conduit hands over the host's own object.
expect(forwarded.dataSource).toBe(dataSource);
expect(forwarded.onSelectRecord).toBe(onSelectRecord);
expect(forwarded.onCreateNew).toBe(onCreateNew);
expect(forwarded.onUploadingChange).toBe(onUploadingChange);
expect(forwarded.error).toBe('Required');

// CONTROL: the undeclared authored key is still dropped.
expect(forwarded).not.toHaveProperty('zzcanary');
});

it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => {
// The #7009 pin asserts an exact boundary set for a host that passes only
// DOM keys. Forwarding the non-DOM block as nine always-present
// `undefined`s would have broken that pin AND made the boundary unreadable
// — "what the host supplied" is the claim, so absence must survive.
const element = FieldEditWidget({
field: TEXT_FIELD,
value: '',
onChange: () => {},
} as FieldWidgetComponentProps<string>);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

for (const key of [
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
]) {
expect(forwarded).not.toHaveProperty(key);
}
// CONTROL: the factory's own props are still there, so the assertion above
// is not passing because the handoff is empty.
expect(forwarded).toHaveProperty('field');
expect(forwarded).toHaveProperty('value');
});

it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => {
const { getByTestId, rerender } = render(
<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} error="Required" />,
);
// `SelectField` puts the DOM pass-through and `aria-invalid` on
// `SelectTrigger` — the focusable `button role="combobox"` the user and
// their screen reader actually meet (objectui#3306) — not on Radix `Root`,
// which renders no element.
const trigger = getByTestId('select-trigger-stage');
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

// CONTROL: the same widget, same host, no `error`. `SelectField` computes
// `!!error`, so a valid field SAYS "false" rather than staying mute — which
// makes this a real two-state reading and not "the attribute exists".
rerender(<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} />);
expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false');
});

it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => {
// The one delivered key that can CHANGE behaviour rather than only add it:
// the relational widgets fall back to `SchemaRendererContext` (which the
// grid already provides), so delivering the prop creates a precedence
// question. `LookupField` already resolves "explicit prop > field-level >
// wrapper field > SchemaRendererContext > none"; the factory is a conduit
// and adds no second authority. This pins that the delivered prop is what
// the widget ends up querying.
const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never;
const makeSource = () => ({
find: vi.fn().mockResolvedValue([]),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }),
});

const fromProp = makeSource();
const fromContext = makeSource();

render(
<SchemaRendererContext.Provider value={{ dataSource: fromContext } as never}>
<FieldEditWidget
field={LOOKUP_FIELD}
value={undefined}
onChange={() => {}}
dataSource={fromProp}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts'));
expect(fromContext.getObjectSchema).not.toHaveBeenCalled();

cleanup();

// CONTROL: drop the prop and the SAME context source IS queried. Without
// this, "the context was not called" would be indistinguishable from a
// context that was never wired up in this test at all.
const contextOnly = makeSource();
render(
<SchemaRendererContext.Provider value={{ dataSource: contextOnly } as never}>
<FieldEditWidget field={LOOKUP_FIELD} value={undefined} onChange={() => {}} />
</SchemaRendererContext.Provider>,
);
await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts'));
});
});
9 changes: 9 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js';
export { toDomProps } from './widgets/toDomProps.js';
export type { DomProps } from './widgets/toDomProps.js';

// The sibling executor for the NON-DOM half of the same declaration
// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as
// COMPONENT props because none of them is DOM-legal. Exported alongside
// `toDomProps` because a host factory authored outside this repo needs the
// pair — reaching for only the first one is how `FieldEditWidget` came to
// deliver half the contract it declares.
export { toHostProps } from './widgets/toHostProps.js';
export type { HostProps } from './widgets/toHostProps.js';

// The native date/time control value adapters (objectui#3127). `DateTimeField`
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
// an ISO instant back, which is also the wire form the platform's `datetime`
Expand Down
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/toDomProps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [
'disabled',
] as const;

type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
/**
* The keys this helper forwards.
*
* Exported so the SIBLING executor — `toHostProps`, which carries the declared
* NON-DOM keys (objectui#7008) — can subtract this set from the contract and
* assert that the two together cover every declared key exactly once. Without
* that subtraction there is no way to state "these keys are handled elsewhere"
* as a compile-time fact rather than as a comment.
*/
export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];

/**
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7008-field-edit-widget-host-plumbing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-kanban': patch
---

`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008).

objectui#7009 made the factory forward its declared DOM pass-through block. The rest of
`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole
"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type
error and the widget never received it — the "declared but not delivered" class this
package treats as first-class.

`error` was the live one. `InlineFieldInput` has passed `error` into this factory since
PR #7109 and the factory dropped it, so an inline-edit control that had failed validation
never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was
told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it
computes the validation state and could not hand it over — and now passes `error`, so its
controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays
with the host, per the objectui#3222 contract.

The keys travel through a new sibling executor, `toHostProps` (exported alongside
`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a
`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop.
Three compile-time assertions make the two executors partition the contract, so a future
declared key cannot go undelivered silently.

`dataSource` precedence is stated rather than left to emerge: a host's explicit
`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField`
already implements; the factory is a conduit and resolves nothing. A host that passes no
`dataSource` keeps reading the context exactly as before, so no in-repo host changes
behaviour.
45 changes: 45 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js';
// The package's own executor of the DOM pass-through declaration, reused here
// rather than re-listed — see the note on this component's return statement.
import { toDomProps } from './widgets/toDomProps.js';
// The package's own executor of the NON-DOM half of the same declaration
// (objectui#7008) — a separate function because those keys are not DOM-legal.
import { toHostProps } from './widgets/toHostProps.js';

// The SAME dedicated widgets the form renders — reused for in-place editing
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
Expand DownExpand Up@@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user']);
* each widget's own whitelist carries these onto the real focusable control, so
* nothing here needs to know which element that is. A host that passes nothing
* is unaffected.
*
* The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the
* sibling executor `toHostProps`. The DOM fix left the other half of the
* contract undelivered: `error`, `onUploadingChange` and the "Host plumbing"
* block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
* `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as
* supported, and never reached the widget. `error` was the live one:
* `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory
* dropped it, so a control that had failed validation never reported
* `aria-invalid`. Forwarding is not a widening — every one of those keys is
* already declared on `FieldWidgetComponentProps`, the same argument #7009
* landed on in this file.
*
* ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that
* whitelist is closed for exactly this reason — a `dataSource` adapter routed
* there becomes `dataSource="[object Object]"` on an `<input>`, the leak the
* helper exists to prevent. `toHostProps`' direction-3 assertion makes the two
* sets provably disjoint, so the order of the two spreads below is not a
* question anyone has to answer again.
*
* ## `dataSource` precedence: the explicit prop WINS
*
* Delivering `dataSource` can change behaviour where before it could not
* arrive, because the relational widgets fall back to `SchemaRendererContext`
* (which the grid already provides). The precedence is therefore STATED rather
* than left to emerge: **a host's explicit `dataSource` prop wins over the
* context**. That is not a new decision — `LookupField` already resolves
* `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ??
* contextDataSource` and documents that order on the line that does it. This
* factory is a CONDUIT and resolves nothing: adding a resolution here would
* give `dataSource` a second author, the `field || schema` shape objectui#3233
* removed. A host that passes no `dataSource` keeps reading the context exactly
* as before, so no in-repo host changes behaviour. The full per-key precedence
* table lives on `toHostProps`, next to the list it governs.
*/
export function FieldEditWidget(
props: FieldWidgetComponentProps<any>,
Expand DownExpand Up@@ -324,9 +361,17 @@ export function FieldEditWidget(
// The semantic props stay explicit and come AFTER the spread. They are not in
// the whitelist, so there is no collision to resolve; ordering them this way
// states that this component OWNS them and a host cannot displace them.
//
// `toHostProps` is the same reuse argument applied to the other half of the
// declaration (objectui#7008): the declared NON-DOM keys — `error` and the
// "Host plumbing" block — travel as COMPONENT props, never through the DOM
// whitelist, which is closed against exactly them. The two executors are
// asserted disjoint at compile time, so neither spread can shadow the other,
// and `compact` below still wins because the factory owns it.
return (
<Widget
{...toDomProps(props)}
{...toHostProps(props)}
field={field}
value={value}
onChange={onChange}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
/**
* 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.
*/

/**
* `FieldEditWidget` DELIVERS the NON-DOM half of the contract it DECLARES
* (objectui#7008) — the other half of objectui#6909 / #7009.
*
* ## The defect this pins closed
*
* #7009 made the factory forward `toDomProps(props)`, so the declared DOM block
* finally arrived. `FieldWidgetComponentProps` also declares `error`,
* `onUploadingChange`, and a whole "Host plumbing" block (`dataSource`,
* `dependentValues`, `dependsOn`, `dependsOnLabels`, `emptyHint`,
* `onSelectRecord`, `onCreateNew`) — and nothing carried any of it. A host
* passed them with no type error and the widget never received them.
*
* `error` was the LIVE one, and measurably so on `main` at `71d83a6b1`:
* `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already
* passes `error={error}` into this factory, which dropped it — so a control
* that had failed validation never reported `aria-invalid`. A sighted user saw
* the red hint; a screen-reader user was told nothing. That is the class
* objectui#3222 / #3290 exist to close, and the one #7002 closed for
* `NumberField` one layer down.
*
* ## What binds it, and what this file adds
*
* The fix hands the widget `toHostProps(props)` — a SIBLING executor, not more
* entries in `DOM_PASS_THROUGH_KEYS`, because none of these keys is DOM-legal
* and that whitelist is closed against exactly them. Three compile-time
* assertions in `toHostProps.ts` make the two executors PARTITION the contract,
* so a future declared key cannot go undelivered without a red build.
*
* A type cannot see the two things this file pins: that the keys ARRIVE at
* runtime, and that arriving actually changes what assistive tech is told.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { SchemaRendererContext } from '@object-ui/react';

import { FieldEditWidget } from '../FieldEditWidget';
import type { FieldWidgetComponentProps } from '../widgets/types';

afterEach(() => cleanup());

/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
} as never;

/** `text` resolves to `TextField` — used only where the widget is irrelevant. */
const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never;

describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => {
it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => {
const onUploadingChange = vi.fn();
const onSelectRecord = vi.fn();
const onCreateNew = vi.fn();
const dataSource = { find: vi.fn() };

// `zzcanary` is the control, carried over from the #7009 pin: NOT declared
// on `FieldWidgetComponentProps` (passing it is a compile error, hence the
// cast), but an SDUI node or a field config can carry exactly such a key at
// runtime. Without it, "everything forwards now" would be
// indistinguishable from having reopened the bare `{...props}` spread.
const props = {
field: TEXT_FIELD,
value: '',
onChange: () => {},
readonly: false,
// the two declared controlled-input keys the factory neither owns nor
// routes to the DOM
error: 'Required',
onUploadingChange,
// the declared "Host plumbing" block, minus `compact` (factory-owned)
dataSource,
dependentValues: { account: 'a1' },
dependsOn: 'account',
dependsOnLabels: { account: 'Account' },
emptyHint: 'Pick an account first',
onSelectRecord,
onCreateNew,
zzcanary: 'CANARY-STR',
} as unknown as FieldWidgetComponentProps<string>;

// Called as a plain function rather than rendered: it uses no hooks and its
// return value IS the widget element, so this reads the handoff itself.
const element = FieldEditWidget(props);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

// Exact set, not a subset — a subset check cannot see the control key, and
// an extra key appearing here is the leak this guards.
expect(Object.keys(forwarded).sort()).toEqual(
[
// rendered by the factory itself
'field',
'value',
'onChange',
'readonly',
// the declared NON-DOM keys, via `toHostProps`
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
].sort(),
);

// Identity, not just presence: a conduit hands over the host's own object.
expect(forwarded.dataSource).toBe(dataSource);
expect(forwarded.onSelectRecord).toBe(onSelectRecord);
expect(forwarded.onCreateNew).toBe(onCreateNew);
expect(forwarded.onUploadingChange).toBe(onUploadingChange);
expect(forwarded.error).toBe('Required');

// CONTROL: the undeclared authored key is still dropped.
expect(forwarded).not.toHaveProperty('zzcanary');
});

it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => {
// The #7009 pin asserts an exact boundary set for a host that passes only
// DOM keys. Forwarding the non-DOM block as nine always-present
// `undefined`s would have broken that pin AND made the boundary unreadable
// — "what the host supplied" is the claim, so absence must survive.
const element = FieldEditWidget({
field: TEXT_FIELD,
value: '',
onChange: () => {},
} as FieldWidgetComponentProps<string>);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

for (const key of [
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
]) {
expect(forwarded).not.toHaveProperty(key);
}
// CONTROL: the factory's own props are still there, so the assertion above
// is not passing because the handoff is empty.
expect(forwarded).toHaveProperty('field');
expect(forwarded).toHaveProperty('value');
});

it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => {
const { getByTestId, rerender } = render(
<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} error="Required" />,
);
// `SelectField` puts the DOM pass-through and `aria-invalid` on
// `SelectTrigger` — the focusable `button role="combobox"` the user and
// their screen reader actually meet (objectui#3306) — not on Radix `Root`,
// which renders no element.
const trigger = getByTestId('select-trigger-stage');
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

// CONTROL: the same widget, same host, no `error`. `SelectField` computes
// `!!error`, so a valid field SAYS "false" rather than staying mute — which
// makes this a real two-state reading and not "the attribute exists".
rerender(<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} />);
expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false');
});

it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => {
// The one delivered key that can CHANGE behaviour rather than only add it:
// the relational widgets fall back to `SchemaRendererContext` (which the
// grid already provides), so delivering the prop creates a precedence
// question. `LookupField` already resolves "explicit prop > field-level >
// wrapper field > SchemaRendererContext > none"; the factory is a conduit
// and adds no second authority. This pins that the delivered prop is what
// the widget ends up querying.
const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never;
const makeSource = () => ({
find: vi.fn().mockResolvedValue([]),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }),
});

const fromProp = makeSource();
const fromContext = makeSource();

render(
<SchemaRendererContext.Provider value={{ dataSource: fromContext } as never}>
<FieldEditWidget
field={LOOKUP_FIELD}
value={undefined}
onChange={() => {}}
dataSource={fromProp}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts'));
expect(fromContext.getObjectSchema).not.toHaveBeenCalled();

cleanup();

// CONTROL: drop the prop and the SAME context source IS queried. Without
// this, "the context was not called" would be indistinguishable from a
// context that was never wired up in this test at all.
const contextOnly = makeSource();
render(
<SchemaRendererContext.Provider value={{ dataSource: contextOnly } as never}>
<FieldEditWidget field={LOOKUP_FIELD} value={undefined} onChange={() => {}} />
</SchemaRendererContext.Provider>,
);
await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts'));
});
});
9 changes: 9 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js';
export { toDomProps } from './widgets/toDomProps.js';
export type { DomProps } from './widgets/toDomProps.js';

// The sibling executor for the NON-DOM half of the same declaration
// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as
// COMPONENT props because none of them is DOM-legal. Exported alongside
// `toDomProps` because a host factory authored outside this repo needs the
// pair — reaching for only the first one is how `FieldEditWidget` came to
// deliver half the contract it declares.
export { toHostProps } from './widgets/toHostProps.js';
export type { HostProps } from './widgets/toHostProps.js';

// The native date/time control value adapters (objectui#3127). `DateTimeField`
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
// an ISO instant back, which is also the wire form the platform's `datetime`
Expand Down
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/toDomProps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [
'disabled',
] as const;

type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
/**
* The keys this helper forwards.
*
* Exported so the SIBLING executor — `toHostProps`, which carries the declared
* NON-DOM keys (objectui#7008) — can subtract this set from the contract and
* assert that the two together cover every declared key exactly once. Without
* that subtraction there is no way to state "these keys are handled elsewhere"
* as a compile-time fact rather than as a comment.
*/
export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];

/**
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7008-field-edit-widget-host-plumbing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-kanban': patch
---

`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008).

objectui#7009 made the factory forward its declared DOM pass-through block. The rest of
`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole
"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type
error and the widget never received it — the "declared but not delivered" class this
package treats as first-class.

`error` was the live one. `InlineFieldInput` has passed `error` into this factory since
PR #7109 and the factory dropped it, so an inline-edit control that had failed validation
never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was
told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it
computes the validation state and could not hand it over — and now passes `error`, so its
controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays
with the host, per the objectui#3222 contract.

The keys travel through a new sibling executor, `toHostProps` (exported alongside
`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a
`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop.
Three compile-time assertions make the two executors partition the contract, so a future
declared key cannot go undelivered silently.

`dataSource` precedence is stated rather than left to emerge: a host's explicit
`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField`
already implements; the factory is a conduit and resolves nothing. A host that passes no
`dataSource` keeps reading the context exactly as before, so no in-repo host changes
behaviour.
45 changes: 45 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js';
// The package's own executor of the DOM pass-through declaration, reused here
// rather than re-listed — see the note on this component's return statement.
import { toDomProps } from './widgets/toDomProps.js';
// The package's own executor of the NON-DOM half of the same declaration
// (objectui#7008) — a separate function because those keys are not DOM-legal.
import { toHostProps } from './widgets/toHostProps.js';

// The SAME dedicated widgets the form renders — reused for in-place editing
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
Expand DownExpand Up@@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user']);
* each widget's own whitelist carries these onto the real focusable control, so
* nothing here needs to know which element that is. A host that passes nothing
* is unaffected.
*
* The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the
* sibling executor `toHostProps`. The DOM fix left the other half of the
* contract undelivered: `error`, `onUploadingChange` and the "Host plumbing"
* block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
* `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as
* supported, and never reached the widget. `error` was the live one:
* `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory
* dropped it, so a control that had failed validation never reported
* `aria-invalid`. Forwarding is not a widening — every one of those keys is
* already declared on `FieldWidgetComponentProps`, the same argument #7009
* landed on in this file.
*
* ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that
* whitelist is closed for exactly this reason — a `dataSource` adapter routed
* there becomes `dataSource="[object Object]"` on an `<input>`, the leak the
* helper exists to prevent. `toHostProps`' direction-3 assertion makes the two
* sets provably disjoint, so the order of the two spreads below is not a
* question anyone has to answer again.
*
* ## `dataSource` precedence: the explicit prop WINS
*
* Delivering `dataSource` can change behaviour where before it could not
* arrive, because the relational widgets fall back to `SchemaRendererContext`
* (which the grid already provides). The precedence is therefore STATED rather
* than left to emerge: **a host's explicit `dataSource` prop wins over the
* context**. That is not a new decision — `LookupField` already resolves
* `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ??
* contextDataSource` and documents that order on the line that does it. This
* factory is a CONDUIT and resolves nothing: adding a resolution here would
* give `dataSource` a second author, the `field || schema` shape objectui#3233
* removed. A host that passes no `dataSource` keeps reading the context exactly
* as before, so no in-repo host changes behaviour. The full per-key precedence
* table lives on `toHostProps`, next to the list it governs.
*/
export function FieldEditWidget(
props: FieldWidgetComponentProps<any>,
Expand DownExpand Up@@ -324,9 +361,17 @@ export function FieldEditWidget(
// The semantic props stay explicit and come AFTER the spread. They are not in
// the whitelist, so there is no collision to resolve; ordering them this way
// states that this component OWNS them and a host cannot displace them.
//
// `toHostProps` is the same reuse argument applied to the other half of the
// declaration (objectui#7008): the declared NON-DOM keys — `error` and the
// "Host plumbing" block — travel as COMPONENT props, never through the DOM
// whitelist, which is closed against exactly them. The two executors are
// asserted disjoint at compile time, so neither spread can shadow the other,
// and `compact` below still wins because the factory owns it.
return (
<Widget
{...toDomProps(props)}
{...toHostProps(props)}
field={field}
value={value}
onChange={onChange}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
/**
* 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.
*/

/**
* `FieldEditWidget` DELIVERS the NON-DOM half of the contract it DECLARES
* (objectui#7008) — the other half of objectui#6909 / #7009.
*
* ## The defect this pins closed
*
* #7009 made the factory forward `toDomProps(props)`, so the declared DOM block
* finally arrived. `FieldWidgetComponentProps` also declares `error`,
* `onUploadingChange`, and a whole "Host plumbing" block (`dataSource`,
* `dependentValues`, `dependsOn`, `dependsOnLabels`, `emptyHint`,
* `onSelectRecord`, `onCreateNew`) — and nothing carried any of it. A host
* passed them with no type error and the widget never received them.
*
* `error` was the LIVE one, and measurably so on `main` at `71d83a6b1`:
* `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already
* passes `error={error}` into this factory, which dropped it — so a control
* that had failed validation never reported `aria-invalid`. A sighted user saw
* the red hint; a screen-reader user was told nothing. That is the class
* objectui#3222 / #3290 exist to close, and the one #7002 closed for
* `NumberField` one layer down.
*
* ## What binds it, and what this file adds
*
* The fix hands the widget `toHostProps(props)` — a SIBLING executor, not more
* entries in `DOM_PASS_THROUGH_KEYS`, because none of these keys is DOM-legal
* and that whitelist is closed against exactly them. Three compile-time
* assertions in `toHostProps.ts` make the two executors PARTITION the contract,
* so a future declared key cannot go undelivered without a red build.
*
* A type cannot see the two things this file pins: that the keys ARRIVE at
* runtime, and that arriving actually changes what assistive tech is told.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { SchemaRendererContext } from '@object-ui/react';

import { FieldEditWidget } from '../FieldEditWidget';
import type { FieldWidgetComponentProps } from '../widgets/types';

afterEach(() => cleanup());

/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
} as never;

/** `text` resolves to `TextField` — used only where the widget is irrelevant. */
const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never;

describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => {
it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => {
const onUploadingChange = vi.fn();
const onSelectRecord = vi.fn();
const onCreateNew = vi.fn();
const dataSource = { find: vi.fn() };

// `zzcanary` is the control, carried over from the #7009 pin: NOT declared
// on `FieldWidgetComponentProps` (passing it is a compile error, hence the
// cast), but an SDUI node or a field config can carry exactly such a key at
// runtime. Without it, "everything forwards now" would be
// indistinguishable from having reopened the bare `{...props}` spread.
const props = {
field: TEXT_FIELD,
value: '',
onChange: () => {},
readonly: false,
// the two declared controlled-input keys the factory neither owns nor
// routes to the DOM
error: 'Required',
onUploadingChange,
// the declared "Host plumbing" block, minus `compact` (factory-owned)
dataSource,
dependentValues: { account: 'a1' },
dependsOn: 'account',
dependsOnLabels: { account: 'Account' },
emptyHint: 'Pick an account first',
onSelectRecord,
onCreateNew,
zzcanary: 'CANARY-STR',
} as unknown as FieldWidgetComponentProps<string>;

// Called as a plain function rather than rendered: it uses no hooks and its
// return value IS the widget element, so this reads the handoff itself.
const element = FieldEditWidget(props);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

// Exact set, not a subset — a subset check cannot see the control key, and
// an extra key appearing here is the leak this guards.
expect(Object.keys(forwarded).sort()).toEqual(
[
// rendered by the factory itself
'field',
'value',
'onChange',
'readonly',
// the declared NON-DOM keys, via `toHostProps`
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
].sort(),
);

// Identity, not just presence: a conduit hands over the host's own object.
expect(forwarded.dataSource).toBe(dataSource);
expect(forwarded.onSelectRecord).toBe(onSelectRecord);
expect(forwarded.onCreateNew).toBe(onCreateNew);
expect(forwarded.onUploadingChange).toBe(onUploadingChange);
expect(forwarded.error).toBe('Required');

// CONTROL: the undeclared authored key is still dropped.
expect(forwarded).not.toHaveProperty('zzcanary');
});

it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => {
// The #7009 pin asserts an exact boundary set for a host that passes only
// DOM keys. Forwarding the non-DOM block as nine always-present
// `undefined`s would have broken that pin AND made the boundary unreadable
// — "what the host supplied" is the claim, so absence must survive.
const element = FieldEditWidget({
field: TEXT_FIELD,
value: '',
onChange: () => {},
} as FieldWidgetComponentProps<string>);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

for (const key of [
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
]) {
expect(forwarded).not.toHaveProperty(key);
}
// CONTROL: the factory's own props are still there, so the assertion above
// is not passing because the handoff is empty.
expect(forwarded).toHaveProperty('field');
expect(forwarded).toHaveProperty('value');
});

it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => {
const { getByTestId, rerender } = render(
<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} error="Required" />,
);
// `SelectField` puts the DOM pass-through and `aria-invalid` on
// `SelectTrigger` — the focusable `button role="combobox"` the user and
// their screen reader actually meet (objectui#3306) — not on Radix `Root`,
// which renders no element.
const trigger = getByTestId('select-trigger-stage');
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

// CONTROL: the same widget, same host, no `error`. `SelectField` computes
// `!!error`, so a valid field SAYS "false" rather than staying mute — which
// makes this a real two-state reading and not "the attribute exists".
rerender(<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} />);
expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false');
});

it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => {
// The one delivered key that can CHANGE behaviour rather than only add it:
// the relational widgets fall back to `SchemaRendererContext` (which the
// grid already provides), so delivering the prop creates a precedence
// question. `LookupField` already resolves "explicit prop > field-level >
// wrapper field > SchemaRendererContext > none"; the factory is a conduit
// and adds no second authority. This pins that the delivered prop is what
// the widget ends up querying.
const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never;
const makeSource = () => ({
find: vi.fn().mockResolvedValue([]),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }),
});

const fromProp = makeSource();
const fromContext = makeSource();

render(
<SchemaRendererContext.Provider value={{ dataSource: fromContext } as never}>
<FieldEditWidget
field={LOOKUP_FIELD}
value={undefined}
onChange={() => {}}
dataSource={fromProp}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts'));
expect(fromContext.getObjectSchema).not.toHaveBeenCalled();

cleanup();

// CONTROL: drop the prop and the SAME context source IS queried. Without
// this, "the context was not called" would be indistinguishable from a
// context that was never wired up in this test at all.
const contextOnly = makeSource();
render(
<SchemaRendererContext.Provider value={{ dataSource: contextOnly } as never}>
<FieldEditWidget field={LOOKUP_FIELD} value={undefined} onChange={() => {}} />
</SchemaRendererContext.Provider>,
);
await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts'));
});
});
9 changes: 9 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js';
export { toDomProps } from './widgets/toDomProps.js';
export type { DomProps } from './widgets/toDomProps.js';

// The sibling executor for the NON-DOM half of the same declaration
// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as
// COMPONENT props because none of them is DOM-legal. Exported alongside
// `toDomProps` because a host factory authored outside this repo needs the
// pair — reaching for only the first one is how `FieldEditWidget` came to
// deliver half the contract it declares.
export { toHostProps } from './widgets/toHostProps.js';
export type { HostProps } from './widgets/toHostProps.js';

// The native date/time control value adapters (objectui#3127). `DateTimeField`
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
// an ISO instant back, which is also the wire form the platform's `datetime`
Expand Down
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/toDomProps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [
'disabled',
] as const;

type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
/**
* The keys this helper forwards.
*
* Exported so the SIBLING executor — `toHostProps`, which carries the declared
* NON-DOM keys (objectui#7008) — can subtract this set from the contract and
* assert that the two together cover every declared key exactly once. Without
* that subtraction there is no way to state "these keys are handled elsewhere"
* as a compile-time fact rather than as a comment.
*/
export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];

/**
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7008-field-edit-widget-host-plumbing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-kanban': patch
---

`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008).

objectui#7009 made the factory forward its declared DOM pass-through block. The rest of
`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole
"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type
error and the widget never received it — the "declared but not delivered" class this
package treats as first-class.

`error` was the live one. `InlineFieldInput` has passed `error` into this factory since
PR #7109 and the factory dropped it, so an inline-edit control that had failed validation
never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was
told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it
computes the validation state and could not hand it over — and now passes `error`, so its
controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays
with the host, per the objectui#3222 contract.

The keys travel through a new sibling executor, `toHostProps` (exported alongside
`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a
`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop.
Three compile-time assertions make the two executors partition the contract, so a future
declared key cannot go undelivered silently.

`dataSource` precedence is stated rather than left to emerge: a host's explicit
`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField`
already implements; the factory is a conduit and resolves nothing. A host that passes no
`dataSource` keeps reading the context exactly as before, so no in-repo host changes
behaviour.
45 changes: 45 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js';
// The package's own executor of the DOM pass-through declaration, reused here
// rather than re-listed — see the note on this component's return statement.
import { toDomProps } from './widgets/toDomProps.js';
// The package's own executor of the NON-DOM half of the same declaration
// (objectui#7008) — a separate function because those keys are not DOM-legal.
import { toHostProps } from './widgets/toHostProps.js';

// The SAME dedicated widgets the form renders — reused for in-place editing
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
Expand DownExpand Up@@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user']);
* each widget's own whitelist carries these onto the real focusable control, so
* nothing here needs to know which element that is. A host that passes nothing
* is unaffected.
*
* The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the
* sibling executor `toHostProps`. The DOM fix left the other half of the
* contract undelivered: `error`, `onUploadingChange` and the "Host plumbing"
* block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
* `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as
* supported, and never reached the widget. `error` was the live one:
* `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory
* dropped it, so a control that had failed validation never reported
* `aria-invalid`. Forwarding is not a widening — every one of those keys is
* already declared on `FieldWidgetComponentProps`, the same argument #7009
* landed on in this file.
*
* ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that
* whitelist is closed for exactly this reason — a `dataSource` adapter routed
* there becomes `dataSource="[object Object]"` on an `<input>`, the leak the
* helper exists to prevent. `toHostProps`' direction-3 assertion makes the two
* sets provably disjoint, so the order of the two spreads below is not a
* question anyone has to answer again.
*
* ## `dataSource` precedence: the explicit prop WINS
*
* Delivering `dataSource` can change behaviour where before it could not
* arrive, because the relational widgets fall back to `SchemaRendererContext`
* (which the grid already provides). The precedence is therefore STATED rather
* than left to emerge: **a host's explicit `dataSource` prop wins over the
* context**. That is not a new decision — `LookupField` already resolves
* `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ??
* contextDataSource` and documents that order on the line that does it. This
* factory is a CONDUIT and resolves nothing: adding a resolution here would
* give `dataSource` a second author, the `field || schema` shape objectui#3233
* removed. A host that passes no `dataSource` keeps reading the context exactly
* as before, so no in-repo host changes behaviour. The full per-key precedence
* table lives on `toHostProps`, next to the list it governs.
*/
export function FieldEditWidget(
props: FieldWidgetComponentProps<any>,
Expand DownExpand Up@@ -324,9 +361,17 @@ export function FieldEditWidget(
// The semantic props stay explicit and come AFTER the spread. They are not in
// the whitelist, so there is no collision to resolve; ordering them this way
// states that this component OWNS them and a host cannot displace them.
//
// `toHostProps` is the same reuse argument applied to the other half of the
// declaration (objectui#7008): the declared NON-DOM keys — `error` and the
// "Host plumbing" block — travel as COMPONENT props, never through the DOM
// whitelist, which is closed against exactly them. The two executors are
// asserted disjoint at compile time, so neither spread can shadow the other,
// and `compact` below still wins because the factory owns it.
return (
<Widget
{...toDomProps(props)}
{...toHostProps(props)}
field={field}
value={value}
onChange={onChange}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
/**
* 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.
*/

/**
* `FieldEditWidget` DELIVERS the NON-DOM half of the contract it DECLARES
* (objectui#7008) — the other half of objectui#6909 / #7009.
*
* ## The defect this pins closed
*
* #7009 made the factory forward `toDomProps(props)`, so the declared DOM block
* finally arrived. `FieldWidgetComponentProps` also declares `error`,
* `onUploadingChange`, and a whole "Host plumbing" block (`dataSource`,
* `dependentValues`, `dependsOn`, `dependsOnLabels`, `emptyHint`,
* `onSelectRecord`, `onCreateNew`) — and nothing carried any of it. A host
* passed them with no type error and the widget never received them.
*
* `error` was the LIVE one, and measurably so on `main` at `71d83a6b1`:
* `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already
* passes `error={error}` into this factory, which dropped it — so a control
* that had failed validation never reported `aria-invalid`. A sighted user saw
* the red hint; a screen-reader user was told nothing. That is the class
* objectui#3222 / #3290 exist to close, and the one #7002 closed for
* `NumberField` one layer down.
*
* ## What binds it, and what this file adds
*
* The fix hands the widget `toHostProps(props)` — a SIBLING executor, not more
* entries in `DOM_PASS_THROUGH_KEYS`, because none of these keys is DOM-legal
* and that whitelist is closed against exactly them. Three compile-time
* assertions in `toHostProps.ts` make the two executors PARTITION the contract,
* so a future declared key cannot go undelivered without a red build.
*
* A type cannot see the two things this file pins: that the keys ARRIVE at
* runtime, and that arriving actually changes what assistive tech is told.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { SchemaRendererContext } from '@object-ui/react';

import { FieldEditWidget } from '../FieldEditWidget';
import type { FieldWidgetComponentProps } from '../widgets/types';

afterEach(() => cleanup());

/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
} as never;

/** `text` resolves to `TextField` — used only where the widget is irrelevant. */
const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never;

describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => {
it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => {
const onUploadingChange = vi.fn();
const onSelectRecord = vi.fn();
const onCreateNew = vi.fn();
const dataSource = { find: vi.fn() };

// `zzcanary` is the control, carried over from the #7009 pin: NOT declared
// on `FieldWidgetComponentProps` (passing it is a compile error, hence the
// cast), but an SDUI node or a field config can carry exactly such a key at
// runtime. Without it, "everything forwards now" would be
// indistinguishable from having reopened the bare `{...props}` spread.
const props = {
field: TEXT_FIELD,
value: '',
onChange: () => {},
readonly: false,
// the two declared controlled-input keys the factory neither owns nor
// routes to the DOM
error: 'Required',
onUploadingChange,
// the declared "Host plumbing" block, minus `compact` (factory-owned)
dataSource,
dependentValues: { account: 'a1' },
dependsOn: 'account',
dependsOnLabels: { account: 'Account' },
emptyHint: 'Pick an account first',
onSelectRecord,
onCreateNew,
zzcanary: 'CANARY-STR',
} as unknown as FieldWidgetComponentProps<string>;

// Called as a plain function rather than rendered: it uses no hooks and its
// return value IS the widget element, so this reads the handoff itself.
const element = FieldEditWidget(props);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

// Exact set, not a subset — a subset check cannot see the control key, and
// an extra key appearing here is the leak this guards.
expect(Object.keys(forwarded).sort()).toEqual(
[
// rendered by the factory itself
'field',
'value',
'onChange',
'readonly',
// the declared NON-DOM keys, via `toHostProps`
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
].sort(),
);

// Identity, not just presence: a conduit hands over the host's own object.
expect(forwarded.dataSource).toBe(dataSource);
expect(forwarded.onSelectRecord).toBe(onSelectRecord);
expect(forwarded.onCreateNew).toBe(onCreateNew);
expect(forwarded.onUploadingChange).toBe(onUploadingChange);
expect(forwarded.error).toBe('Required');

// CONTROL: the undeclared authored key is still dropped.
expect(forwarded).not.toHaveProperty('zzcanary');
});

it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => {
// The #7009 pin asserts an exact boundary set for a host that passes only
// DOM keys. Forwarding the non-DOM block as nine always-present
// `undefined`s would have broken that pin AND made the boundary unreadable
// — "what the host supplied" is the claim, so absence must survive.
const element = FieldEditWidget({
field: TEXT_FIELD,
value: '',
onChange: () => {},
} as FieldWidgetComponentProps<string>);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

for (const key of [
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
]) {
expect(forwarded).not.toHaveProperty(key);
}
// CONTROL: the factory's own props are still there, so the assertion above
// is not passing because the handoff is empty.
expect(forwarded).toHaveProperty('field');
expect(forwarded).toHaveProperty('value');
});

it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => {
const { getByTestId, rerender } = render(
<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} error="Required" />,
);
// `SelectField` puts the DOM pass-through and `aria-invalid` on
// `SelectTrigger` — the focusable `button role="combobox"` the user and
// their screen reader actually meet (objectui#3306) — not on Radix `Root`,
// which renders no element.
const trigger = getByTestId('select-trigger-stage');
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

// CONTROL: the same widget, same host, no `error`. `SelectField` computes
// `!!error`, so a valid field SAYS "false" rather than staying mute — which
// makes this a real two-state reading and not "the attribute exists".
rerender(<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} />);
expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false');
});

it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => {
// The one delivered key that can CHANGE behaviour rather than only add it:
// the relational widgets fall back to `SchemaRendererContext` (which the
// grid already provides), so delivering the prop creates a precedence
// question. `LookupField` already resolves "explicit prop > field-level >
// wrapper field > SchemaRendererContext > none"; the factory is a conduit
// and adds no second authority. This pins that the delivered prop is what
// the widget ends up querying.
const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never;
const makeSource = () => ({
find: vi.fn().mockResolvedValue([]),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }),
});

const fromProp = makeSource();
const fromContext = makeSource();

render(
<SchemaRendererContext.Provider value={{ dataSource: fromContext } as never}>
<FieldEditWidget
field={LOOKUP_FIELD}
value={undefined}
onChange={() => {}}
dataSource={fromProp}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts'));
expect(fromContext.getObjectSchema).not.toHaveBeenCalled();

cleanup();

// CONTROL: drop the prop and the SAME context source IS queried. Without
// this, "the context was not called" would be indistinguishable from a
// context that was never wired up in this test at all.
const contextOnly = makeSource();
render(
<SchemaRendererContext.Provider value={{ dataSource: contextOnly } as never}>
<FieldEditWidget field={LOOKUP_FIELD} value={undefined} onChange={() => {}} />
</SchemaRendererContext.Provider>,
);
await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts'));
});
});
9 changes: 9 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js';
export { toDomProps } from './widgets/toDomProps.js';
export type { DomProps } from './widgets/toDomProps.js';

// The sibling executor for the NON-DOM half of the same declaration
// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as
// COMPONENT props because none of them is DOM-legal. Exported alongside
// `toDomProps` because a host factory authored outside this repo needs the
// pair — reaching for only the first one is how `FieldEditWidget` came to
// deliver half the contract it declares.
export { toHostProps } from './widgets/toHostProps.js';
export type { HostProps } from './widgets/toHostProps.js';

// The native date/time control value adapters (objectui#3127). `DateTimeField`
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
// an ISO instant back, which is also the wire form the platform's `datetime`
Expand Down
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/toDomProps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [
'disabled',
] as const;

type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
/**
* The keys this helper forwards.
*
* Exported so the SIBLING executor — `toHostProps`, which carries the declared
* NON-DOM keys (objectui#7008) — can subtract this set from the contract and
* assert that the two together cover every declared key exactly once. Without
* that subtraction there is no way to state "these keys are handled elsewhere"
* as a compile-time fact rather than as a comment.
*/
export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];

/**
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7008-field-edit-widget-host-plumbing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-kanban': patch
---

`FieldEditWidget` now delivers the NON-DOM half of the contract it declares (objectui#7008).

objectui#7009 made the factory forward its declared DOM pass-through block. The rest of
`FieldWidgetComponentProps` was still dropped: `error`, `onUploadingChange`, and the whole
"Host plumbing" block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
`emptyHint`, `onSelectRecord`, `onCreateNew`). A host could pass any of them with no type
error and the widget never received it — the "declared but not delivered" class this
package treats as first-class.

`error` was the live one. `InlineFieldInput` has passed `error` into this factory since
PR #7109 and the factory dropped it, so an inline-edit control that had failed validation
never reported `aria-invalid`: a sighted user saw the red hint, a screen-reader user was
told nothing. The kanban `RequiredFieldsDialog` had the same hole from the other side — it
computes the validation state and could not hand it over — and now passes `error`, so its
controls are marked. Delivering `error` buys the a11y MARKING only; the message text stays
with the host, per the objectui#3222 contract.

The keys travel through a new sibling executor, `toHostProps` (exported alongside
`toDomProps`), never through the DOM whitelist — none of them is DOM-legal, and routing a
`dataSource` adapter there is the `[object Object]` leak that whitelist exists to stop.
Three compile-time assertions make the two executors partition the contract, so a future
declared key cannot go undelivered silently.

`dataSource` precedence is stated rather than left to emerge: a host's explicit
`dataSource` prop WINS over `SchemaRendererContext`. That is the order `LookupField`
already implements; the factory is a conduit and resolves nothing. A host that passes no
`dataSource` keeps reading the context exactly as before, so no in-repo host changes
behaviour.
45 changes: 45 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ import type { FieldWidgetComponentProps } from './widgets/types.js';
// The package's own executor of the DOM pass-through declaration, reused here
// rather than re-listed — see the note on this component's return statement.
import { toDomProps } from './widgets/toDomProps.js';
// The package's own executor of the NON-DOM half of the same declaration
// (objectui#7008) — a separate function because those keys are not DOM-legal.
import { toHostProps } from './widgets/toHostProps.js';

// The SAME dedicated widgets the form renders — reused for in-place editing
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
Expand DownExpand Up@@ -271,6 +274,40 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user']);
* each widget's own whitelist carries these onto the real focusable control, so
* nothing here needs to know which element that is. A host that passes nothing
* is unaffected.
*
* The host's NON-DOM set is forwarded WHOLE too (objectui#7008), through the
* sibling executor `toHostProps`. The DOM fix left the other half of the
* contract undelivered: `error`, `onUploadingChange` and the "Host plumbing"
* block (`dataSource`, `dependentValues`, `dependsOn`, `dependsOnLabels`,
* `emptyHint`, `onSelectRecord`, `onCreateNew`) still type-checked, read as
* supported, and never reached the widget. `error` was the live one:
* `InlineFieldInput` has passed `error={error}` since PR #7109 and this factory
* dropped it, so a control that had failed validation never reported
* `aria-invalid`. Forwarding is not a widening — every one of those keys is
* already declared on `FieldWidgetComponentProps`, the same argument #7009
* landed on in this file.
*
* ⛔ They do NOT go through `toDomProps`. None of them is DOM-legal, and that
* whitelist is closed for exactly this reason — a `dataSource` adapter routed
* there becomes `dataSource="[object Object]"` on an `<input>`, the leak the
* helper exists to prevent. `toHostProps`' direction-3 assertion makes the two
* sets provably disjoint, so the order of the two spreads below is not a
* question anyone has to answer again.
*
* ## `dataSource` precedence: the explicit prop WINS
*
* Delivering `dataSource` can change behaviour where before it could not
* arrive, because the relational widgets fall back to `SchemaRendererContext`
* (which the grid already provides). The precedence is therefore STATED rather
* than left to emerge: **a host's explicit `dataSource` prop wins over the
* context**. That is not a new decision — `LookupField` already resolves
* `props.dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ??
* contextDataSource` and documents that order on the line that does it. This
* factory is a CONDUIT and resolves nothing: adding a resolution here would
* give `dataSource` a second author, the `field || schema` shape objectui#3233
* removed. A host that passes no `dataSource` keeps reading the context exactly
* as before, so no in-repo host changes behaviour. The full per-key precedence
* table lives on `toHostProps`, next to the list it governs.
*/
export function FieldEditWidget(
props: FieldWidgetComponentProps<any>,
Expand DownExpand Up@@ -324,9 +361,17 @@ export function FieldEditWidget(
// The semantic props stay explicit and come AFTER the spread. They are not in
// the whitelist, so there is no collision to resolve; ordering them this way
// states that this component OWNS them and a host cannot displace them.
//
// `toHostProps` is the same reuse argument applied to the other half of the
// declaration (objectui#7008): the declared NON-DOM keys — `error` and the
// "Host plumbing" block — travel as COMPONENT props, never through the DOM
// whitelist, which is closed against exactly them. The two executors are
// asserted disjoint at compile time, so neither spread can shadow the other,
// and `compact` below still wins because the factory owns it.
return (
<Widget
{...toDomProps(props)}
{...toHostProps(props)}
field={field}
value={value}
onChange={onChange}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
/**
* 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.
*/

/**
* `FieldEditWidget` DELIVERS the NON-DOM half of the contract it DECLARES
* (objectui#7008) — the other half of objectui#6909 / #7009.
*
* ## The defect this pins closed
*
* #7009 made the factory forward `toDomProps(props)`, so the declared DOM block
* finally arrived. `FieldWidgetComponentProps` also declares `error`,
* `onUploadingChange`, and a whole "Host plumbing" block (`dataSource`,
* `dependentValues`, `dependsOn`, `dependsOnLabels`, `emptyHint`,
* `onSelectRecord`, `onCreateNew`) — and nothing carried any of it. A host
* passed them with no type error and the widget never received them.
*
* `error` was the LIVE one, and measurably so on `main` at `71d83a6b1`:
* `InlineFieldInput` (`@object-ui/plugin-detail`, since PR #7109) already
* passes `error={error}` into this factory, which dropped it — so a control
* that had failed validation never reported `aria-invalid`. A sighted user saw
* the red hint; a screen-reader user was told nothing. That is the class
* objectui#3222 / #3290 exist to close, and the one #7002 closed for
* `NumberField` one layer down.
*
* ## What binds it, and what this file adds
*
* The fix hands the widget `toHostProps(props)` — a SIBLING executor, not more
* entries in `DOM_PASS_THROUGH_KEYS`, because none of these keys is DOM-legal
* and that whitelist is closed against exactly them. Three compile-time
* assertions in `toHostProps.ts` make the two executors PARTITION the contract,
* so a future declared key cannot go undelivered without a red build.
*
* A type cannot see the two things this file pins: that the keys ARRIVE at
* runtime, and that arriving actually changes what assistive tech is told.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { SchemaRendererContext } from '@object-ui/react';

import { FieldEditWidget } from '../FieldEditWidget';
import type { FieldWidgetComponentProps } from '../widgets/types';

afterEach(() => cleanup());

/** `select` resolves to `SelectField`, whose trigger carries `aria-invalid`. */
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
} as never;

/** `text` resolves to `TextField` — used only where the widget is irrelevant. */
const TEXT_FIELD = { name: 'f', type: 'text', label: 'F' } as never;

describe('FieldEditWidget delivers its declared NON-DOM block (objectui#7008)', () => {
it('forwards every declared host-plumbing key it is handed, at the factory boundary', () => {
const onUploadingChange = vi.fn();
const onSelectRecord = vi.fn();
const onCreateNew = vi.fn();
const dataSource = { find: vi.fn() };

// `zzcanary` is the control, carried over from the #7009 pin: NOT declared
// on `FieldWidgetComponentProps` (passing it is a compile error, hence the
// cast), but an SDUI node or a field config can carry exactly such a key at
// runtime. Without it, "everything forwards now" would be
// indistinguishable from having reopened the bare `{...props}` spread.
const props = {
field: TEXT_FIELD,
value: '',
onChange: () => {},
readonly: false,
// the two declared controlled-input keys the factory neither owns nor
// routes to the DOM
error: 'Required',
onUploadingChange,
// the declared "Host plumbing" block, minus `compact` (factory-owned)
dataSource,
dependentValues: { account: 'a1' },
dependsOn: 'account',
dependsOnLabels: { account: 'Account' },
emptyHint: 'Pick an account first',
onSelectRecord,
onCreateNew,
zzcanary: 'CANARY-STR',
} as unknown as FieldWidgetComponentProps<string>;

// Called as a plain function rather than rendered: it uses no hooks and its
// return value IS the widget element, so this reads the handoff itself.
const element = FieldEditWidget(props);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

// Exact set, not a subset — a subset check cannot see the control key, and
// an extra key appearing here is the leak this guards.
expect(Object.keys(forwarded).sort()).toEqual(
[
// rendered by the factory itself
'field',
'value',
'onChange',
'readonly',
// the declared NON-DOM keys, via `toHostProps`
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
].sort(),
);

// Identity, not just presence: a conduit hands over the host's own object.
expect(forwarded.dataSource).toBe(dataSource);
expect(forwarded.onSelectRecord).toBe(onSelectRecord);
expect(forwarded.onCreateNew).toBe(onCreateNew);
expect(forwarded.onUploadingChange).toBe(onUploadingChange);
expect(forwarded.error).toBe('Required');

// CONTROL: the undeclared authored key is still dropped.
expect(forwarded).not.toHaveProperty('zzcanary');
});

it('CONTROL: a key the host did not pass stays ABSENT, not `undefined`', () => {
// The #7009 pin asserts an exact boundary set for a host that passes only
// DOM keys. Forwarding the non-DOM block as nine always-present
// `undefined`s would have broken that pin AND made the boundary unreadable
// — "what the host supplied" is the claim, so absence must survive.
const element = FieldEditWidget({
field: TEXT_FIELD,
value: '',
onChange: () => {},
} as FieldWidgetComponentProps<string>);
expect(element).not.toBeNull();
const forwarded = element!.props as Record<string, unknown>;

for (const key of [
'error',
'onUploadingChange',
'dataSource',
'dependentValues',
'dependsOn',
'dependsOnLabels',
'emptyHint',
'onSelectRecord',
'onCreateNew',
]) {
expect(forwarded).not.toHaveProperty(key);
}
// CONTROL: the factory's own props are still there, so the assertion above
// is not passing because the handoff is empty.
expect(forwarded).toHaveProperty('field');
expect(forwarded).toHaveProperty('value');
});

it('`error` reaches a real control as `aria-invalid` — the live a11y defect', async () => {
const { getByTestId, rerender } = render(
<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} error="Required" />,
);
// `SelectField` puts the DOM pass-through and `aria-invalid` on
// `SelectTrigger` — the focusable `button role="combobox"` the user and
// their screen reader actually meet (objectui#3306) — not on Radix `Root`,
// which renders no element.
const trigger = getByTestId('select-trigger-stage');
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

// CONTROL: the same widget, same host, no `error`. `SelectField` computes
// `!!error`, so a valid field SAYS "false" rather than staying mute — which
// makes this a real two-state reading and not "the attribute exists".
rerender(<FieldEditWidget field={SELECT_FIELD} value="" onChange={() => {}} />);
expect(getByTestId('select-trigger-stage')).toHaveAttribute('aria-invalid', 'false');
});

it('`dataSource`: the explicit prop WINS over SchemaRendererContext', async () => {
// The one delivered key that can CHANGE behaviour rather than only add it:
// the relational widgets fall back to `SchemaRendererContext` (which the
// grid already provides), so delivering the prop creates a precedence
// question. `LookupField` already resolves "explicit prop > field-level >
// wrapper field > SchemaRendererContext > none"; the factory is a conduit
// and adds no second authority. This pins that the delivered prop is what
// the widget ends up querying.
const LOOKUP_FIELD = { name: 'account', type: 'lookup', reference_to: 'accounts' } as never;
const makeSource = () => ({
find: vi.fn().mockResolvedValue([]),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'accounts' }),
});

const fromProp = makeSource();
const fromContext = makeSource();

render(
<SchemaRendererContext.Provider value={{ dataSource: fromContext } as never}>
<FieldEditWidget
field={LOOKUP_FIELD}
value={undefined}
onChange={() => {}}
dataSource={fromProp}
/>
</SchemaRendererContext.Provider>,
);

await waitFor(() => expect(fromProp.getObjectSchema).toHaveBeenCalledWith('accounts'));
expect(fromContext.getObjectSchema).not.toHaveBeenCalled();

cleanup();

// CONTROL: drop the prop and the SAME context source IS queried. Without
// this, "the context was not called" would be indistinguishable from a
// context that was never wired up in this test at all.
const contextOnly = makeSource();
render(
<SchemaRendererContext.Provider value={{ dataSource: contextOnly } as never}>
<FieldEditWidget field={LOOKUP_FIELD} value={undefined} onChange={() => {}} />
</SchemaRendererContext.Provider>,
);
await waitFor(() => expect(contextOnly.getObjectSchema).toHaveBeenCalledWith('accounts'));
});
});
9 changes: 9 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3347,6 +3347,15 @@ export { withFieldCarrier } from './withFieldCarrier.js';
export { toDomProps } from './widgets/toDomProps.js';
export type { DomProps } from './widgets/toDomProps.js';

// The sibling executor for the NON-DOM half of the same declaration
// (objectui#7008): `error` plus the "Host plumbing" block, forwarded as
// COMPONENT props because none of them is DOM-legal. Exported alongside
// `toDomProps` because a host factory authored outside this repo needs the
// pair — reaching for only the first one is how `FieldEditWidget` came to
// deliver half the contract it declares.
export { toHostProps } from './widgets/toHostProps.js';
export type { HostProps } from './widgets/toHostProps.js';

// The native date/time control value adapters (objectui#3127). `DateTimeField`
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
// an ISO instant back, which is also the wire form the platform's `datetime`
Expand Down
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/toDomProps.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,7 +123,16 @@ const DOM_PASS_THROUGH_KEYS = [
'disabled',
] as const;

type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
/**
* The keys this helper forwards.
*
* Exported so the SIBLING executor — `toHostProps`, which carries the declared
* NON-DOM keys (objectui#7008) — can subtract this set from the contract and
* assert that the two together cover every declared key exactly once. Without
* that subtraction there is no way to state "these keys are handled elsewhere"
* as a compile-time fact rather than as a comment.
*/
export type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];

/**
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
Expand Down
Loading
Loading