diff --git a/.changeset/option-widgets-empty-hint-single-source.md b/.changeset/option-widgets-empty-hint-single-source.md
new file mode 100644
index 000000000..52e98ffce
--- /dev/null
+++ b/.changeset/option-widgets-empty-hint-single-source.md
@@ -0,0 +1,45 @@
+---
+"@object-ui/components": patch
+"@object-ui/fields": patch
+"@object-ui/i18n": patch
+---
+
+The option widgets' "this list cannot be filled" message now has one source, and
+it is translated (objectui#3231).
+
+FROM: `SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` each
+carried their own copy of the empty/gated state, each destructured the declared
+`emptyHint` prop into `_emptyHint` and dropped it, and each rendered a hardcoded
+English literal (`'No options available'`, `` `Select ${…} first` ``) even in a
+Chinese or Japanese session. TO: one shared `OptionsEmptyState` — the host's
+`emptyHint` when it supplied one, otherwise a translated fallback
+(`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale
+packs).
+
+`emptyHint` was declared, produced by the form renderer and transported, then
+lost three times over — so no registered widget could ever render it. All three
+breaks are fixed, because closing only the last one delivers nothing:
+
+- `isOptionField` compared the raw resolved type against `'select'` /`'radio'` /
+ `'multiselect'` / `'checkboxes'`. Object-derived forms emit
+ `mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none of
+ them, so for every option field coming from an object schema — the normal case
+ in the console — the whole cascade block was skipped and no hint was computed
+ at all. It now normalizes the `field:` prefix, the same normalization
+ `stripRegisteredFieldProps` already applied a few lines below.
+- `stripRegisteredFieldProps` then removed the `emptyHint` key from what was
+ left. It is now forwarded to the four cascade option types, alongside
+ `dependentValues`. This stays an allow-list rather than a blanket
+ pass-through: every other registered widget spreads its leftover props onto a
+ DOM node, where an unknown `emptyHint` attribute is a React warning.
+- the widgets themselves discarded it. Keeping it out of the `...props` spread
+ was correct; not using it afterwards was not.
+
+User-visible effect: a dependency-gated option list now prompts with the
+controlling field's **label** ("Select Country first") instead of its raw
+metadata name, in the session's language; an unconfigured list says so in the
+session's language too. The gate sentence is one i18n key shared by the renderer
+and the widget fallback, so the two sides cannot word it differently.
+
+Untouched: the built-in (unregistered) `select` branch of the form renderer,
+which already consumed `emptyHint`. That is a separate live path.
diff --git a/packages/components/src/renderers/form/__tests__/form-empty-hint-delivery.test.tsx b/packages/components/src/renderers/form/__tests__/form-empty-hint-delivery.test.tsx
new file mode 100644
index 000000000..5ee40bfa9
--- /dev/null
+++ b/packages/components/src/renderers/form/__tests__/form-empty-hint-delivery.test.tsx
@@ -0,0 +1,137 @@
+/**
+ * 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.
+ */
+
+/**
+ * The dependency-gate hint must actually REACH a registered option widget
+ * (objectui#3231) — the producer half of "declared ≠ delivered".
+ *
+ * `emptyHint` was declared on the widget contract, computed here for a gated
+ * option list (#2284) and handed to `renderFieldComponent` … and then lost
+ * twice on the way out, so no registered widget could ever render it:
+ *
+ * 1. `isOptionField` compared the RAW resolved type against `'select'` etc.
+ * Object-derived forms emit `mapFieldTypeToFormType`'s prefixed ids
+ * (`field:select`), which matched nothing — so for every option field that
+ * came from an object schema (the normal case in the console) the whole
+ * cascade block was skipped and no hint was computed at all.
+ * 2. `stripRegisteredFieldProps` then removed the `emptyHint` key from what
+ * was left, so even the bare-type forms that DID compute a hint delivered
+ * nothing.
+ *
+ * The strip is otherwise correct — every other registered widget spreads its
+ * leftover props onto a DOM node, where an unknown `emptyHint` attribute is a
+ * React warning — so the forward is an ALLOW-LIST over the cascade option
+ * types. Both directions are pinned below.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+// Module scope, not `beforeAll` — the cold transform must not be billed to
+// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010).
+import '../../../renderers';
+
+/**
+ * Surfaces the received `emptyHint` so the injection can be asserted — both
+ * its VALUE and whether the key was passed at all. The renderer always sets
+ * `emptyHint` on the props object (`undefined` when the list is not gated), so
+ * key presence is what distinguishes "stripped" from "forwarded, empty".
+ */
+function EmptyHintProbe(props: any) {
+ return (
+
+ {props.emptyHint === undefined ? 'NO-HINT' : String(props.emptyHint)}
+
+ );
+}
+
+const OPTION_TYPES = ['select', 'radio', 'multiselect', 'checkboxes'] as const;
+
+beforeAll(() => {
+ // The fields package owns the real widgets; components tests never load it,
+ // so stand the probe in for each registered option widget.
+ for (const type of OPTION_TYPES) {
+ ComponentRegistry.register(`field:${type}`, EmptyHintProbe, { namespace: 'test' });
+ }
+ // A registered widget that is NOT an option field — the strip must still
+ // hold for it (its props land on a DOM node).
+ ComponentRegistry.register('field:lookup', EmptyHintProbe, { namespace: 'test' });
+}, 30000);
+
+function renderForm(fields: any[]) {
+ const Form = ComponentRegistry.get('form')!;
+ return render();
+}
+
+const gatedField = (type: string) => ({
+ name: 'province',
+ label: 'Province',
+ type,
+ dependsOn: 'country',
+ options: [
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
+ ],
+});
+
+const emptyParent = { name: 'country', label: 'Country', type: 'input', defaultValue: '' };
+
+describe('form renderer — emptyHint delivery to registered option widgets (objectui#3231)', () => {
+ // The prefixed ids are what `mapFieldTypeToFormType` emits, i.e. what every
+ // object-derived form in the console actually renders.
+ it.each(OPTION_TYPES)('a registered field:%s receives the computed gate hint', (type) => {
+ renderForm([emptyParent, gatedField(`field:${type}`)]);
+
+ // Built from the controlling field's LABEL ("Country"), not its raw name —
+ // that label resolution is the reason the host owns this string at all.
+ const probe = screen.getByTestId('hint-probe-province');
+ expect(probe).toHaveTextContent('Select Country first');
+ expect(probe).toHaveAttribute('data-has-key', 'yes');
+ });
+
+ // `select` is a BUILTIN_FIELD_TYPE, so a bare `type: 'select'` never reaches
+ // the registry at all — it renders the inline branch, which already consumed
+ // `emptyHint`. The other three do resolve through `field:`.
+ it.each(['radio', 'multiselect', 'checkboxes'] as const)(
+ 'a hand-written `type: %s` schema delivers it too',
+ (type) => {
+ renderForm([emptyParent, gatedField(type)]);
+
+ expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('Select Country first');
+ },
+ );
+
+ it('withdraws the hint once the gate lifts — an ungated list is not "empty"', async () => {
+ renderForm([emptyParent, gatedField('field:select')]);
+
+ expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('Select Country first');
+
+ // Picking the parent lifts the gate, so the host has nothing to say and the
+ // widget goes back to owning its own (now non-empty) list.
+ fireEvent.change(screen.getByLabelText(/country/i), { target: { value: 'cn' } });
+ await waitFor(() => {
+ expect(screen.getByTestId('hint-probe-province')).toHaveTextContent('NO-HINT');
+ });
+ });
+
+ it('still withholds the key from registered widgets that would spread it onto the DOM', () => {
+ // The renderer sets `emptyHint` on the props of EVERY field, so without the
+ // strip this probe would report `data-has-key="yes"` — and a real widget
+ // would spread an unknown `emptyHint` attribute onto its DOM node. The
+ // forward is an allow-list over the four cascade option types, not a
+ // blanket "stop stripping it".
+ renderForm([emptyParent, { name: 'contact', label: 'Contact', type: 'lookup', dependsOn: 'country' }]);
+
+ const probe = screen.getByTestId('hint-probe-contact');
+ expect(probe).toHaveAttribute('data-has-key', 'no');
+ expect(probe).toHaveTextContent('NO-HINT');
+ });
+});
diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx
index 116f01f20..c2ef273a6 100644
--- a/packages/components/src/renderers/form/form.tsx
+++ b/packages/components/src/renderers/form/form.tsx
@@ -110,6 +110,9 @@ const panePercent = (size: number | undefined): string | undefined =>
const useSafeFormTranslation = createSafeTranslation(
{
'common.selectOption': 'Select an option',
+ // objectui#3231 — the dependency-gate sentence (#2284). Shared with the
+ // option widgets' own fallback so both sides render one wording.
+ 'fields.options.selectFirst': 'Select {{fields}} first',
'validation.required': '{{field}} is required',
'validation.minLength': '{{field}} must be at least {{min}} characters',
'validation.maxLength': '{{field}} must be at most {{max}} characters',
@@ -266,7 +269,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
mobile_fullscreen: _mobileFullscreen,
fullscreen: _fullscreen,
dependentValues,
- emptyHint: _emptyHint,
+ emptyHint,
schema: _schema,
...fieldProps
} = props;
@@ -275,7 +278,12 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
return {
...fieldProps,
...(DATA_SOURCE_FIELD_TYPES.has(normalizedType) ? { dataSource, dependentValues } : {}),
- ...(CASCADE_OPTION_FIELD_TYPES.has(normalizedType) ? { dependentValues } : {}),
+ // The cascade option widgets own the gate hint's presentation, so they get
+ // the computed `emptyHint` alongside the live record (objectui#3231). It is
+ // stripped by default because every OTHER registered widget spreads its
+ // leftover props onto a DOM node, where an unknown `emptyHint` attribute is
+ // a React warning — hence an allow-list, not an unconditional pass-through.
+ ...(CASCADE_OPTION_FIELD_TYPES.has(normalizedType) ? { dependentValues, emptyHint } : {}),
};
}
@@ -1203,11 +1211,15 @@ ComponentRegistry.register('form',
// the live record + `current_user`), and gate the whole control
// while a declared `dependsOn` parent is still empty — surfacing a
// "select the parent first" hint instead of an unfiltered list.
- const isOptionField =
- resolvedType === 'select' ||
- resolvedType === 'radio' ||
- resolvedType === 'multiselect' ||
- resolvedType === 'checkboxes';
+ // `field:select` and `select` name the SAME field kind — the object-form
+ // path (`mapFieldTypeToFormType`) emits the prefixed id, hand-written
+ // form schemas the bare one. Comparing the raw string recognised only the
+ // bare form, so every option field coming from an object schema fell out
+ // of this block entirely and no gate hint was ever computed for it
+ // (objectui#3231). `normalizeFieldType` is the same normalization
+ // `stripRegisteredFieldProps` already applies a few lines down; the two
+ // must agree on what a `select` is.
+ const isOptionField = CASCADE_OPTION_FIELD_TYPES.has(normalizeFieldType(resolvedType));
const rawOptions = (fieldProps as any).options as SelectOption[] | undefined;
// Resolve gating + `visibleWhen` filtering through the shared
// core helper so this pre-filter can't drift from the widgets'
@@ -1219,8 +1231,15 @@ ComponentRegistry.register('form',
const optionGroupGated = cascade?.gated ?? false;
const dependsOnFields = cascade?.dependsOnFields ?? [];
const effectiveOptions = cascade ? cascade.options : rawOptions;
+ // Same i18n key the option widgets fall back to (`fields.options.
+ // selectFirst`, objectui#3231): one sentence, two callers — this one
+ // interpolates the controlling fields' LABELS, a standalone widget its
+ // raw metadata names — so the gate can never read differently depending
+ // on which side produced it.
const gatedHint = optionGroupGated
- ? `Select ${dependsOnFields.map((fn) => fieldLabelByName[fn] || fn).join(' / ')} first`
+ ? t('fields.options.selectFirst', {
+ fields: dependsOnFields.map((fn) => fieldLabelByName[fn] || fn).join(' / '),
+ })
: undefined;
// colSpan classes for grid layout.
diff --git a/packages/fields/src/widgets/CheckboxesField.tsx b/packages/fields/src/widgets/CheckboxesField.tsx
index 8a00ff33a..30e07d09b 100644
--- a/packages/fields/src/widgets/CheckboxesField.tsx
+++ b/packages/fields/src/widgets/CheckboxesField.tsx
@@ -2,6 +2,7 @@ import React, { useId, useEffect } from 'react';
import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
import type { OptionLike } from '@object-ui/core';
import { FieldWidgetComponentProps } from './types';
+import { OptionsEmptyState } from './OptionsEmptyState';
import { useCascadingOptions } from './useCascadingOptions';
type Option = OptionLike;
@@ -27,7 +28,7 @@ export function CheckboxesField({
schema,
dependentValues,
dependsOn: dependsOnProp,
- emptyHint: _emptyHint,
+ emptyHint,
dataSource: _dataSource,
...props
}: FieldWidgetComponentProps) {
@@ -70,19 +71,18 @@ export function CheckboxesField({
}
// No offered options is unfillable — surface a legible state instead of an
- // empty checkbox list: a dependency-gated list prompts for its controlling
- // field; an unconfigured / fully-filtered list says so. Mirrors the select.
+ // empty checkbox list: the host's `emptyHint` when it computed one, else this
+ // widget's own translated copy. Shared with the select / multiselect / radio
+ // so the four cannot drift again (objectui#3231).
if (options.length === 0) {
- const hint = gated
- ? `Select ${dependsOnFields.join(' / ')} first`
- : 'No options available';
return (
-
- {hint}
-
+
);
}
diff --git a/packages/fields/src/widgets/MultiSelectField.tsx b/packages/fields/src/widgets/MultiSelectField.tsx
index 53e9d402e..b537a4e77 100644
--- a/packages/fields/src/widgets/MultiSelectField.tsx
+++ b/packages/fields/src/widgets/MultiSelectField.tsx
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import { Badge, EmptyValue, cn } from '@object-ui/components';
import type { OptionLike } from '@object-ui/core';
import { FieldWidgetComponentProps } from './types';
+import { OptionsEmptyState } from './OptionsEmptyState';
import { useCascadingOptions } from './useCascadingOptions';
interface Option extends OptionLike { color?: string }
@@ -29,7 +30,7 @@ export function MultiSelectField({
schema,
dependentValues,
dependsOn: dependsOnProp,
- emptyHint: _emptyHint,
+ emptyHint,
dataSource: _dataSource,
...props
}: FieldWidgetComponentProps) {
@@ -71,19 +72,18 @@ export function MultiSelectField({
}
// No offered options is unfillable — surface a legible state instead of an
- // empty chip row: a dependency-gated list prompts for its controlling field;
- // an unconfigured / fully-filtered list says so. Mirrors the single select.
+ // empty chip row: the host's `emptyHint` when it computed one, else this
+ // widget's own translated copy. Shared with the single select / radio /
+ // checkboxes so the four cannot drift again (objectui#3231).
if (options.length === 0) {
- const hint = gated
- ? `Select ${dependsOnFields.join(' / ')} first`
- : 'No options available';
return (
-
- {hint}
-
+
);
}
diff --git a/packages/fields/src/widgets/OptionsEmptyState.no-provider.test.tsx b/packages/fields/src/widgets/OptionsEmptyState.no-provider.test.tsx
new file mode 100644
index 000000000..3ab8945a1
--- /dev/null
+++ b/packages/fields/src/widgets/OptionsEmptyState.no-provider.test.tsx
@@ -0,0 +1,88 @@
+/**
+ * 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.
+ */
+
+/**
+ * The option widgets' empty/gate copy with NO `I18nProvider` mounted
+ * (objectui#3231) — standalone/embedded usage, and every test in the repo that
+ * renders a field widget bare.
+ *
+ * Routing the copy through `useFieldTranslation()` must not turn it into a raw
+ * i18n key when nothing is configured: `createSafeTranslation` probes the
+ * instance and falls back to its English defaults map. That is the behaviour
+ * the removed literals used to provide unconditionally, so it gets its own pin.
+ *
+ * Why a separate FILE rather than another case in `OptionsEmptyState.test.tsx`:
+ * mounting `I18nProvider` calls `initReactI18next`, which installs that
+ * instance as react-i18next's GLOBAL default. Once any sibling test has done
+ * so there is no "no provider" state left to observe in that module graph, and
+ * the assertion would silently read the last locale mounted instead. Vitest's
+ * per-file isolation (`isolate: true` on the `dom` project) is what makes this
+ * file's observation honest.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { SelectField } from './SelectField';
+import { MultiSelectField } from './MultiSelectField';
+import { RadioField } from './RadioField';
+import { CheckboxesField } from './CheckboxesField';
+
+const WIDGETS = [
+ { label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' },
+ {
+ label: 'MultiSelectField',
+ Widget: MultiSelectField,
+ type: 'multiselect',
+ testId: 'multiselect-empty-province',
+ },
+ { label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' },
+ {
+ label: 'CheckboxesField',
+ Widget: CheckboxesField,
+ type: 'checkboxes',
+ testId: 'checkboxes-empty-province',
+ },
+] as const;
+
+const gatedField = (type: string) =>
+ ({
+ name: 'province',
+ type,
+ dependsOn: 'country',
+ options: [{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }],
+ }) as any;
+
+const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any;
+
+function renderWidget(Widget: React.ComponentType, field: any) {
+ return render(
+ ,
+ );
+}
+
+describe('option widgets — English fallback survives with no i18n configured', () => {
+ it.each(WIDGETS)('$label renders the gate sentence, not a raw key', ({ Widget, type, testId }) => {
+ renderWidget(Widget, gatedField(type));
+ const box = screen.getByTestId(testId);
+ expect(box).toHaveTextContent('Select country first');
+ expect(box.textContent).not.toContain('fields.options');
+ });
+
+ it.each(WIDGETS)('$label renders the empty sentence, not a raw key', ({ Widget, type, testId }) => {
+ renderWidget(Widget, unconfiguredField(type));
+ const box = screen.getByTestId(testId);
+ expect(box).toHaveTextContent('No options available');
+ expect(box.textContent).not.toContain('fields.options');
+ });
+});
diff --git a/packages/fields/src/widgets/OptionsEmptyState.test.tsx b/packages/fields/src/widgets/OptionsEmptyState.test.tsx
new file mode 100644
index 000000000..ca6cfd590
--- /dev/null
+++ b/packages/fields/src/widgets/OptionsEmptyState.test.tsx
@@ -0,0 +1,169 @@
+/**
+ * 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.
+ */
+
+/**
+ * The empty / dependency-gated state of the fixed-option widgets (objectui#3231).
+ *
+ * Two invariants, pinned for ALL FOUR widgets — they are what regressed:
+ *
+ * 1. **declared ⇒ delivered.** `emptyHint` is declared on
+ * `FieldWidgetComponentProps` and computed by the form renderer. Every one
+ * of the four widgets used to destructure it into `_emptyHint` and drop it
+ * on the floor, so the host's hint could never reach a user once the field
+ * went through the `field:` registry (i.e. always, in the real console).
+ * 2. **the fallback is translated.** With no host hint the widget renders its
+ * own copy — which used to be a hardcoded English literal
+ * (`'No options available'`, `` `Select ${…} first` ``) even though
+ * `useFieldTranslation()` was already imported in one of the same files.
+ *
+ * All four are covered deliberately: they were four independent copies of the
+ * same block, which is exactly why they drifted together and why one widget's
+ * test would have proven nothing about the other three. They now share
+ * `OptionsEmptyState`; these tests keep that true from the outside, so the
+ * guarantee survives someone re-inlining the block.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { I18nProvider } from '@object-ui/i18n';
+import { SelectField } from './SelectField';
+import { MultiSelectField } from './MultiSelectField';
+import { RadioField } from './RadioField';
+import { CheckboxesField } from './CheckboxesField';
+
+interface WidgetCase {
+ label: string;
+ Widget: React.ComponentType;
+ type: string;
+ testId: string;
+}
+
+const WIDGETS: WidgetCase[] = [
+ { label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' },
+ {
+ label: 'MultiSelectField',
+ Widget: MultiSelectField,
+ type: 'multiselect',
+ testId: 'multiselect-empty-province',
+ },
+ { label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' },
+ {
+ label: 'CheckboxesField',
+ Widget: CheckboxesField,
+ type: 'checkboxes',
+ testId: 'checkboxes-empty-province',
+ },
+];
+
+/** Gated: `country` is empty, so no option is offered yet (#2284). */
+const gatedField = (type: string) =>
+ ({
+ name: 'province',
+ type,
+ dependsOn: 'country',
+ options: [
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
+ ],
+ }) as any;
+
+/** Not gated, simply nothing to offer — the other way a list ends up empty. */
+const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any;
+
+function renderWidget(
+ { Widget }: WidgetCase,
+ field: any,
+ extra: Record = {},
+ language?: string,
+) {
+ const element = (
+
+ );
+ return render(
+ language ? (
+
+ {element}
+
+ ) : (
+ element
+ ),
+ );
+}
+
+describe('option widgets — the host-supplied `emptyHint` reaches the user (objectui#3231)', () => {
+ it.each(WIDGETS)(
+ '$label renders the host hint instead of its own gate copy',
+ (widget) => {
+ // What the form renderer actually computes: a sentence built from the
+ // controlling field's human LABEL, which the widget cannot know (its
+ // metadata only carries the name `country`).
+ renderWidget(widget, gatedField(widget.type), {
+ emptyHint: 'Choose the Country field first',
+ });
+
+ const box = screen.getByTestId(widget.testId);
+ expect(box).toHaveTextContent('Choose the Country field first');
+ // …and NOT the widget's own copy, which is what used to render.
+ expect(box.textContent).not.toMatch(/select country first/i);
+ },
+ );
+
+ it.each(WIDGETS)(
+ '$label renders the host hint for an unconfigured (ungated) list too',
+ (widget) => {
+ // `emptyHint` is a declared prop of the widget contract, not a private
+ // channel of one form-renderer branch: supplied ⇒ used, always.
+ renderWidget(widget, unconfiguredField(widget.type), {
+ emptyHint: 'Options come from the price book',
+ });
+
+ expect(screen.getByTestId(widget.testId)).toHaveTextContent(
+ 'Options come from the price book',
+ );
+ },
+ );
+
+ it.each(WIDGETS)('$label prefers a host hint over its translated fallback', (widget) => {
+ // Even under a non-English locale the HOST wins — it is the single source.
+ renderWidget(widget, gatedField(widget.type), { emptyHint: 'Pick a Country first' }, 'zh');
+
+ const box = screen.getByTestId(widget.testId);
+ expect(box).toHaveTextContent('Pick a Country first');
+ expect(box.textContent).not.toContain('请先选择');
+ });
+});
+
+describe('option widgets — the fallback copy is translated, never a hardcoded literal', () => {
+ it.each(WIDGETS)('$label translates the gate copy when no host hint is given', (widget) => {
+ renderWidget(widget, gatedField(widget.type), {}, 'zh');
+
+ const box = screen.getByTestId(widget.testId);
+ expect(box).toHaveTextContent('请先选择country');
+ expect(box.textContent).not.toMatch(/select .* first/i);
+ });
+
+ it.each(WIDGETS)('$label translates the empty copy when no host hint is given', (widget) => {
+ renderWidget(widget, unconfiguredField(widget.type), {}, 'zh');
+
+ const box = screen.getByTestId(widget.testId);
+ expect(box).toHaveTextContent('暂无可选项');
+ expect(box.textContent).not.toMatch(/no options available/i);
+ });
+});
+
+// The no-provider fallback lives in `OptionsEmptyState.no-provider.test.tsx`:
+// mounting `I18nProvider` here installs its instance as react-i18next's global
+// default (`initReactI18next`), so after any test above there is no longer a
+// "no provider" state to observe within this module graph.
+
diff --git a/packages/fields/src/widgets/OptionsEmptyState.tsx b/packages/fields/src/widgets/OptionsEmptyState.tsx
new file mode 100644
index 000000000..555b2c0e5
--- /dev/null
+++ b/packages/fields/src/widgets/OptionsEmptyState.tsx
@@ -0,0 +1,83 @@
+/**
+ * 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.
+ */
+
+import React from 'react';
+import { cn } from '@object-ui/components';
+import { useFieldTranslation } from './useFieldTranslation';
+
+/**
+ * The "this option list cannot be filled" state shared by every fixed-option
+ * widget (`SelectField` single, `MultiSelectField`, `RadioField`,
+ * `CheckboxesField`) — objectui#3231.
+ *
+ * ## One producer, one consumer
+ *
+ * `emptyHint` is a declared prop on `FieldWidgetComponentProps`, computed by
+ * the form renderer (`form.tsx`, the `#2284` dependency gate) and forwarded to
+ * these widgets. **When the host supplies it, it wins**: the host resolves the
+ * controlling fields to their human LABELS ("Select Country first"), where a
+ * widget on its own only knows the raw metadata names ("Select country first").
+ * Absent a host hint — a standalone widget, the inline grid editor, an action
+ * param dialog — the widget falls back to its own copy.
+ *
+ * Each of the four widgets used to inline this box, destructure `emptyHint`
+ * into `_emptyHint` and throw it away, then render a hardcoded English literal.
+ * Four independent copies is precisely why they drifted together, so the box
+ * AND the copy live here only: adding a fifth option widget cannot re-introduce
+ * the gap without deliberately re-implementing it.
+ *
+ * ## Fallback copy is translated
+ *
+ * The fallback goes through `useFieldTranslation()` (`fields.options.*`,
+ * present in all ten locale packs) rather than an English string literal — the
+ * gate sentence is the SAME i18n key the form renderer uses, so the two can
+ * never say different things in the same locale.
+ */
+export interface OptionsEmptyStateProps {
+ /**
+ * Host-computed hint. Wins over the widget's own copy whenever it is a
+ * non-empty string — this is the whole point of the prop.
+ */
+ emptyHint?: string;
+ /** The list is waiting on a `dependsOn` controlling field (vs. unconfigured). */
+ gated: boolean;
+ /** Controlling field names, used by the fallback gate sentence. */
+ dependsOnFields: readonly string[];
+ /** Widget-specific stable locator, e.g. `select-empty-${fieldName}`. */
+ testId?: string;
+ /** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */
+ className?: string;
+}
+
+export function OptionsEmptyState({
+ emptyHint,
+ gated,
+ dependsOnFields,
+ testId,
+ className,
+}: OptionsEmptyStateProps) {
+ const { t } = useFieldTranslation();
+ // The host's hint when it computed one; otherwise this widget's own copy,
+ // translated. Never an English literal — that was the reported defect.
+ const hint =
+ emptyHint ||
+ (gated
+ ? t('fields.options.selectFirst', { fields: dependsOnFields.join(' / ') })
+ : t('fields.options.empty'));
+ return (
+
+ {hint}
+
+ );
+}
diff --git a/packages/fields/src/widgets/RadioField.tsx b/packages/fields/src/widgets/RadioField.tsx
index c3d1e6f8e..3171eccf4 100644
--- a/packages/fields/src/widgets/RadioField.tsx
+++ b/packages/fields/src/widgets/RadioField.tsx
@@ -2,6 +2,7 @@ import React, { useId, useEffect } from 'react';
import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
import { isValueStillOffered, type OptionLike } from '@object-ui/core';
import { FieldWidgetComponentProps } from './types';
+import { OptionsEmptyState } from './OptionsEmptyState';
import { useCascadingOptions } from './useCascadingOptions';
type Option = OptionLike;
@@ -27,7 +28,7 @@ export function RadioField({
schema,
dependentValues,
dependsOn: dependsOnProp,
- emptyHint: _emptyHint,
+ emptyHint,
dataSource: _dataSource,
...props
}: FieldWidgetComponentProps) {
@@ -61,19 +62,18 @@ export function RadioField({
}
// No offered options is unfillable — surface a legible state instead of an
- // empty radio group: a dependency-gated list prompts for its controlling
- // field; an unconfigured / fully-filtered list says so. Mirrors the select.
+ // empty radio group: the host's `emptyHint` when it computed one, else this
+ // widget's own translated copy. Shared with the select / multiselect /
+ // checkboxes so the four cannot drift again (objectui#3231).
if (options.length === 0) {
- const hint = gated
- ? `Select ${dependsOnFields.join(' / ')} first`
- : 'No options available';
return (
-
- {hint}
-
+
);
}
diff --git a/packages/fields/src/widgets/SelectField.tsx b/packages/fields/src/widgets/SelectField.tsx
index 5ac85b54c..e3635eb85 100644
--- a/packages/fields/src/widgets/SelectField.tsx
+++ b/packages/fields/src/widgets/SelectField.tsx
@@ -12,6 +12,7 @@ import { SelectFieldMetadata } from '@object-ui/types';
import { useFieldTranslation } from './useFieldTranslation';
import { FieldWidgetComponentProps } from './types';
import { MultiSelectField } from './MultiSelectField';
+import { OptionsEmptyState } from './OptionsEmptyState';
import { useCascadingOptions } from './useCascadingOptions';
/**
@@ -56,7 +57,7 @@ function SingleSelectField({
schema,
dependentValues,
dependsOn: dependsOnProp,
- emptyHint: _emptyHint,
+ emptyHint,
dataSource: _dataSource,
...props
}: FieldWidgetComponentProps) {
@@ -93,19 +94,18 @@ function SingleSelectField({
// A select with no options is unfillable — a silently-empty Radix dropdown
// reads as "broken widget" and hides the real cause. Surface a legible state:
- // a dependency-gated list prompts for its controlling field; an unconfigured
- // list says so. Mirrors the inline form renderer's behaviour.
+ // the host's `emptyHint` when it computed one (it knows the controlling
+ // fields' LABELS), else this widget's own translated copy. Shared with the
+ // other option widgets so the four cannot drift again (objectui#3231).
if (options.length === 0) {
- const hint = gated
- ? `Select ${dependsOnFields.join(' / ')} first`
- : 'No options available';
return (
-
- {hint}
-
+
);
}
diff --git a/packages/fields/src/widgets/types.ts b/packages/fields/src/widgets/types.ts
index 21e3af451..f427e8452 100644
--- a/packages/fields/src/widgets/types.ts
+++ b/packages/fields/src/widgets/types.ts
@@ -99,9 +99,13 @@ export type FieldWidgetComponentProps = {
*/
dependsOn?: DependsOnInput;
/**
- * Hint shown when a dependency-gated option list is still waiting on its
- * controlling field. Forwarded by the form renderer; the option widgets in
- * this package currently discard it and render their own message instead.
+ * Hint shown when an option list cannot be filled — typically a
+ * dependency-gated list still waiting on its controlling field (#2284).
+ * Forwarded by the form renderer, which resolves the controlling fields to
+ * their human LABELS. **When supplied it wins**; the option widgets fall back
+ * to their own translated copy only when a host computed none (objectui#3231
+ * — they used to discard this prop and always render their own hardcoded
+ * English). See `OptionsEmptyState`, the single consumer.
*/
emptyHint?: string;
/**
diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts
index 01dd9c503..1edefa47d 100644
--- a/packages/fields/src/widgets/useFieldTranslation.ts
+++ b/packages/fields/src/widgets/useFieldTranslation.ts
@@ -39,6 +39,12 @@ const FIELD_DEFAULTS: Record = {
'lookup.nextPage': 'Next page',
'lookup.jumpToPage': 'Jump to page',
'lookup.retry': 'Retry',
+ // objectui#3231 — the empty / dependency-gated state of the fixed-option
+ // widgets (select, multiselect, radio, checkboxes). Only used when the host
+ // supplies no `emptyHint`; the gate sentence shares its key with the form
+ // renderer so both cannot drift apart in a locale.
+ 'fields.options.empty': 'No options available',
+ 'fields.options.selectFirst': 'Select {{fields}} first',
// objectstack#3821 — sharing-rule authoring widgets (object-ref /
// recipient-picker / filter-condition). The recipient placeholder is keyed
// PER TYPE rather than interpolating the enum value into an English
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index c863d3649..fc16ce393 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -172,6 +172,10 @@ const ar = {
basicEditorHint: "محرر النص الغني (أساسي)",
placeholder: "اكتب شيئاً...",
},
+ options: {
+ empty: "لا توجد خيارات متاحة",
+ selectFirst: "اختر {{fields}} أولاً",
+ },
objectRef: {
loading: "جارٍ تحميل الكائنات…",
placeholder: "اختر كائناً",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index 3a7bd4160..cd0f2b1ad 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -172,6 +172,10 @@ const de = {
basicEditorHint: "Rich-Text-Editor (einfach)",
placeholder: "Text eingeben...",
},
+ options: {
+ empty: "Keine Optionen verfügbar",
+ selectFirst: "Zuerst {{fields}} auswählen",
+ },
objectRef: {
loading: "Objekte werden geladen…",
placeholder: "Objekt auswählen",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 49e07da4e..006506be3 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -178,6 +178,14 @@ const en = {
basicEditorHint: 'Rich text editor (basic)',
placeholder: 'Enter text...',
},
+ // objectui#3231 — the "this option list cannot be filled" copy shared by
+ // the fixed-option widgets (select / multiselect / radio / checkboxes) AND
+ // by the form renderer's gate hint. One key, one sentence: the renderer
+ // interpolates field LABELS, a standalone widget its raw field names.
+ options: {
+ empty: 'No options available',
+ selectFirst: 'Select {{fields}} first',
+ },
objectRef: {
loading: 'Loading objects…',
placeholder: 'Select an object',
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index f048de42a..2ca12106d 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -172,6 +172,10 @@ const es = {
basicEditorHint: "Editor de texto enriquecido (básico)",
placeholder: "Escribe algo...",
},
+ options: {
+ empty: "No hay opciones disponibles",
+ selectFirst: "Seleccione primero {{fields}}",
+ },
objectRef: {
loading: "Cargando objetos…",
placeholder: "Seleccionar un objeto",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index 5a3a99691..d89034537 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -172,6 +172,10 @@ const fr = {
basicEditorHint: "Éditeur de texte enrichi (basique)",
placeholder: "Écrivez quelque chose...",
},
+ options: {
+ empty: "Aucune option disponible",
+ selectFirst: "Sélectionnez d’abord {{fields}}",
+ },
objectRef: {
loading: "Chargement des objets…",
placeholder: "Sélectionner un objet",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 4fa0c897e..3d6dda287 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -172,6 +172,10 @@ const ja = {
basicEditorHint: "リッチテキストエディター(基本)",
placeholder: "テキストを入力...",
},
+ options: {
+ empty: "選択できる項目がありません",
+ selectFirst: "まず {{fields}} を選択してください",
+ },
objectRef: {
loading: "オブジェクトを読み込み中…",
placeholder: "オブジェクトを選択",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index 0276cbef8..2bcdddc1a 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -172,6 +172,10 @@ const ko = {
basicEditorHint: "서식 있는 텍스트 편집기 (기본)",
placeholder: "내용을 입력하세요...",
},
+ options: {
+ empty: "선택할 수 있는 옵션이 없습니다",
+ selectFirst: "먼저 {{fields}}을(를) 선택하세요",
+ },
objectRef: {
loading: "객체를 불러오는 중…",
placeholder: "객체 선택",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 76b4b8fb6..e6e2d6c0b 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -172,6 +172,10 @@ const pt = {
basicEditorHint: "Editor de texto rico (básico)",
placeholder: "Digite algo...",
},
+ options: {
+ empty: "Nenhuma opção disponível",
+ selectFirst: "Selecione primeiro {{fields}}",
+ },
objectRef: {
loading: "Carregando objetos…",
placeholder: "Selecionar um objeto",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 8019dc616..cb65afaaf 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -172,6 +172,10 @@ const ru = {
basicEditorHint: "Редактор форматированного текста (базовый)",
placeholder: "Введите текст...",
},
+ options: {
+ empty: "Нет доступных вариантов",
+ selectFirst: "Сначала выберите {{fields}}",
+ },
objectRef: {
loading: "Загрузка объектов…",
placeholder: "Выберите объект",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 6f45ff45e..a18d651a3 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -177,6 +177,10 @@ const zh = {
basicEditorHint: '富文本编辑器(基础)',
placeholder: '请输入文字...',
},
+ options: {
+ empty: '暂无可选项',
+ selectFirst: '请先选择{{fields}}',
+ },
objectRef: {
loading: '正在加载对象…',
placeholder: '请选择对象',