@object-ui/components 17.2.0 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,569 @@
1
1
  # @object-ui/components
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3889ffb: Console chrome i18n gaps (objectstack#5407).
8
+
9
+ - A dependency-gated lookup now names its controlling field by its **label**
10
+ instead of its raw API name. The sentence was localized but the interpolated
11
+ name was not, so every locale — English included — read `Select crm_account
12
+ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
13
+ lookup-side counterpart of `emptyHint`, which it already resolves to labels
14
+ for the fixed-option widgets); a name the host does not cover still falls
15
+ back to itself.
16
+ - The page-header overflow trigger's `More actions` accessible name now reads
17
+ `detail.moreActions`, the same key `action:menu`'s own overflow trigger uses,
18
+ so the two cannot diverge per locale.
19
+ - The activity-feed reaction button's `Add reaction` accessible name is now a
20
+ bundle key (`detail.addReaction`, added to all ten packs).
21
+ - The "check the highlighted fields" toast joins field names with a per-locale
22
+ separator (`validation.formInvalidJoiner`) instead of a hardcoded `、`
23
+ (U+3001) — right for zh/ja by accident, wrong in English and every Latin
24
+ locale. Latin packs use `, `, CJK `、`, Arabic `، `.
25
+ - The Spanish `validation.required` / `validation.unique` templates gained
26
+ their own masculine head noun (`El campo {{field}} es obligatorio`) so the
27
+ adjective agrees for feminine field labels too — `Cuenta es obligatorio` was
28
+ ungrammatical.
29
+
30
+ - 56409c2: Field widgets are finally told when their field fails validation, and the props
31
+ slot that carries it takes the name the published contract gives it
32
+ (objectui#3222).
33
+
34
+ **Breaking** for anyone implementing a field widget (see migration below). The
35
+ repo version policy keeps this a `minor` — objectui's major tracks
36
+ `@objectstack`'s — so read the bump as "breaking within objectui".
37
+
38
+ ## The a11y defect this fixes
39
+
40
+ `@objectstack/spec/ui`'s `FieldWidgetPropsSchema` — the published contract that
41
+ third-party and AI-authored field widgets are written against — has always
42
+ declared `error?: string`. `@object-ui/fields` declared its own slot as
43
+ `errorMessage`. That looked like a naming split; it was worse:
44
+
45
+ ```
46
+ producers of `errorMessage` anywhere in packages/ + apps/ : 0
47
+ reads of `errorMessage` in packages/fields/src : 15 (7 widgets)
48
+ reads of `props.error` : 0
49
+ ```
50
+
51
+ The slot was dead under BOTH spellings. No host ever passed it: the form
52
+ renderer showed validation text through its own `<FormMessage/>` and never
53
+ forwarded the prop. So `EmailField`, `CurrencyField`, `UrlField`,
54
+ `RichTextField`, `PercentField`, `TextAreaField` and `PhoneField` each computed
55
+ `aria-invalid={!!errorMessage}` from a value that was `undefined` forever —
56
+ **`aria-invalid` had never once been set, and a screen reader was never told
57
+ the field had failed validation.**
58
+
59
+ Worse than "never set": `<FormControl>` is a Radix `Slot` that hands its child a
60
+ CORRECT `aria-invalid`, but a widget's own attribute is written after the props
61
+ spread, so it wins. Those seven widgets were actively overwriting the right
62
+ answer with `false`.
63
+
64
+ FROM: `renderFieldComponent` received no validation state, and the widget props
65
+ type declared `errorMessage?: string`, which nothing produced.
66
+ TO: the form renderer passes react-hook-form's `fieldState.error?.message` down
67
+ as `error` when it renders a registered widget, and the props type declares
68
+ `error?: string`. Both ends of the contract are live for the first time; a
69
+ rename alone would only have swapped one dead key for another.
70
+
71
+ ## Migration for widget authors
72
+
73
+ ```diff
74
+ -export function MyField({ value, onChange, field, readonly, errorMessage }: FieldWidgetComponentProps< string >) {
75
+ - return <Input value={value} aria-invalid={!!errorMessage} />;
76
+ +export function MyField({ value, onChange, field, readonly, error }: FieldWidgetComponentProps< string >) {
77
+ + return <Input value={value} aria-invalid={!!error} />;
78
+ ```
79
+
80
+ No alias is kept. `errorMessage` was retained nowhere on purpose — a tolerant
81
+ second spelling is exactly the de-facto second contract AGENTS.md #0.1 forbids,
82
+ and it is what would let a missed call site go quiet again. Because
83
+ objectui#3221 had already removed the type's `[key: string]: any`, every missed
84
+ site is a compile error rather than a silent `any`, so the compiler — not grep
85
+ — validated this rename.
86
+
87
+ ## Responsibilities are split, not duplicated
88
+
89
+ The widget consumes `error` **only** to drive `aria-invalid` on the control it
90
+ renders (which only it can do — `aria-invalid` has to sit on the input element).
91
+ The message TEXT stays with `<FormMessage/>` in the form renderer. A widget that
92
+ also renders the text double-displays it, and the docs, the agent prompt and the
93
+ tests all now say so.
94
+
95
+ For the same reason `required` — also declared by the spec, also never delivered
96
+ — is deliberately NOT lowered into widget props: the required marker has exactly
97
+ one author, the renderer's `<FormLabel>`, and giving widgets the flag invites a
98
+ second asterisk. The a11y state a widget could legitimately carry is
99
+ `aria-required`, which needs no contract change at all (`AriaAttributes` is
100
+ already part of the type and widgets already forward it).
101
+
102
+ Builtin field types are unaffected: they render inside `<FormControl>`, whose
103
+ Slot already supplies `aria-invalid`, so `error` is stripped there rather than
104
+ leaking into the DOM as a stray attribute.
105
+
106
+ Docs updated to match: `content/docs/guide/plugin-development.md`,
107
+ `skills/objectui/guides/plugin-development.md` and
108
+ `.github/prompts/component.prompt.md` — the last of which additionally used the
109
+ spec's non-generic type alias as a generic (`FieldWidgetProps< number >`) and
110
+ destructured a `mode` prop that exists on neither type.
111
+
112
+ - 042e09d: **BREAKING (v17)** — field widgets receive their metadata on ONE key, `field`.
113
+ `schema` is removed from the widget contract (objectui#3233).
114
+
115
+ ## What changed
116
+
117
+ `schema` was a second carrier for what `field` already means. Two producers fed
118
+ it: `SchemaRenderer` passed the authored node as `schema`, and the form
119
+ renderer's `renderFieldComponent` passed `schema={props.field || props.schema ||
120
+ props}` _alongside_ `field`. The predictable result was ~30 widgets resolving
121
+ their config as `field || schema` — one concept, two spellings, a de-facto
122
+ second contract (AGENTS.md #0.1).
123
+
124
+ - `FieldWidgetComponentProps` no longer declares `schema`. Reading
125
+ `props.schema` is now a compile error, not a silent `any`.
126
+ - Both producers converged. The form renderer passes `field` only. The SDUI node
127
+ → `field` translation happens exactly once, in a new registration adapter
128
+ (`withFieldCarrier`), which every built-in field widget is registered through.
129
+ - All `field || schema` reads in `@object-ui/fields` are now plain `field` reads.
130
+
131
+ ## Migrating a widget you wrote
132
+
133
+ **Reading the metadata** — replace the fallback with the single key:
134
+
135
+ ```diff
136
+ -const config = field || (props as any).schema;
137
+ +const config = field;
138
+ ```
139
+
140
+ **Registering a widget** — if your widget can be rendered from a schema node
141
+ (anything `SchemaRenderer` dispatches, not just forms), wrap it once so it still
142
+ gets `field`:
143
+
144
+ ```diff
145
+ +import { withFieldCarrier } from '@object-ui/fields';
146
+ +
147
+ -ComponentRegistry.register('color', ColorField, { namespace: 'field' });
148
+ +ComponentRegistry.register('color', withFieldCarrier(ColorField), { namespace: 'field' });
149
+ ```
150
+
151
+ `withFieldCarrier` forwards the node **by reference** — nothing is copied,
152
+ narrowed or renamed — and consumes `schema` so it cannot reach the DOM through a
153
+ widget's `...props` spread.
154
+
155
+ A third-party widget that still reads `props.schema` and is **not** re-registered
156
+ through the adapter will read `undefined` in v17 and silently render an empty /
157
+ default state. That is the deliberate cost of a major boundary: one contract
158
+ beats N dialects, and a widget that picks the wrong spelling should fail at
159
+ compile time rather than work under one host and not another.
160
+
161
+ ## What did NOT change
162
+ - **Host metadata (SDUI JSON) is untouched.** No authored schema changes; this is
163
+ a change to how widgets are _written_, not to what apps declare.
164
+ - **`schema` is still the universal SDUI prop** every registered component
165
+ receives from `SchemaRenderer` (`element:*`, `page:*`, grids, reports). Only
166
+ the _field-widget_ contract retired it. In particular `renderFieldComponent`
167
+ still passes `schema` when a form field type resolves to a plain component
168
+ through the bare-name fallback (e.g. `type: 'text'` reaching the display text
169
+ widget) — that component's contract is the node, and dropping it there would
170
+ render `undefined.className`.
171
+
172
+ ## Payload equivalence
173
+
174
+ Every path that used to deliver a payload through `schema` now delivers the
175
+ identical object through `field`, and both halves are pinned by tests asserting
176
+ **object identity**, not shape:
177
+
178
+ - form path — `packages/components/src/renderers/form/__tests__/form-field-carrier.test.tsx`
179
+ - SDUI path — `packages/fields/src/__tests__/field-carrier-sdui.test.tsx`
180
+
181
+ ### Patch Changes
182
+
183
+ - 532cf8b: Deliver the required state to the control in the five renderers outside the object form that still painted it as an asterisk only (objectui#3299 — the same defect #3290/#3298 fixed in `form.tsx`).
184
+
185
+ Each site converges on the reference shape (`EmbeddableForm.tsx`): the control carries `aria-required={required || undefined}` and the asterisk is `aria-hidden="true"`, so assistive tech announces required once, as a state — instead of hearing a bare "asterisk" folded into the accessible name, or nothing at all.
186
+
187
+ - `@object-ui/app-shell` — `ActionParamDialog` (both the boolean row and the default branch, delivered through the real field widgets' `toDomProps` whitelist) and `CreateViewDialog` (display label, machine name, and every type-specific required-field selector).
188
+ - `@object-ui/components` — the custom `ActionParamDialog` (all five typed branches, including the Radix select trigger) and `FieldContainer`, whose existing Slot injection (`id` / `aria-describedby` / `aria-invalid`) now also injects `aria-required`, covering every consumer in one place.
189
+ - `@object-ui/plugin-detail` — `InlineCreateRelated`'s create-tab inputs.
190
+
191
+ Deliberately NOT the native `required` attribute (#3290 ruling): each of these hosts runs its own validation, and native `required` would arm the browser's constraint-validation bubble beside it. The SDUI controls that already use native `required` (`renderers/form/{input,textarea,select,checkbox}.tsx`, `basic/text-input.tsx`) are unchanged — they don't have a second validator, so their channel is already correct.
192
+
193
+ - 680080a: The required state now reaches the input control as `aria-required`, instead of
194
+ existing only as part of the control's accessible name (objectui#3290).
195
+
196
+ The form renderer has always computed a correct `required` — the static
197
+ `required` flag merged with the `requiredWhen` CEL verdict — and then spent it
198
+ on exactly one thing: the red asterisk in `<FormLabel>`. That asterisk carried
199
+ `aria-label="required"`, so the only path from the computed state to assistive
200
+ tech was `<label for>` folding it into the control's **accessible name**: the
201
+ field was announced as "Title required".
202
+
203
+ A state smuggled through a name is broken three ways:
204
+
205
+ - it is read in name order rather than announced as a state, and "list the
206
+ required fields" style navigation cannot see it at all;
207
+ - a field rendered without a `label` (compact layouts, inline grid editing)
208
+ draws no asterisk, so the signal disappears entirely;
209
+ - `requiredWhen` makes required **dynamic**, and a state channel can express
210
+ the flip where a name cannot.
211
+
212
+ ## What changed
213
+ - Every field control — built-in (`input` / `textarea` / `checkbox` / `switch` /
214
+ `select`) and registered widget alike — now receives `aria-required="true"`
215
+ when the field resolves required, and **no attribute at all** when it does
216
+ not. Absence rather than `aria-required="false"` is deliberate: it is what
217
+ the `requiredWhen`-turns-false case has to produce.
218
+ - The red asterisk is now `aria-hidden="true"` and no longer carries
219
+ `aria-label="required"`. It renders exactly as before for sighted users; it
220
+ simply stops being announced, so the state is reported once (as a state) and
221
+ not twice.
222
+
223
+ **No field widget needed a change.** `aria-required` is already declared and
224
+ typed on the widget props contract (`FieldWidgetComponentProps &
225
+ AriaAttributes`) and every widget forwards its leftover props to the control it
226
+ renders, so all 48 widgets pick it up unmodified.
227
+
228
+ Two non-changes, both deliberate:
229
+
230
+ - **No native `required` attribute.** That would arm the browser's own
231
+ constraint-validation bubble alongside react-hook-form's `<FormMessage/>` —
232
+ two validators, two UIs, one field. `aria-required` reports the state without
233
+ triggering native validation.
234
+ - **No `required` boolean in the widget props contract.** A boolean would give
235
+ the required marker a second author, and the next widget draws its own
236
+ asterisk next to the renderer's — the double-display failure objectui#3222
237
+ already declined for the validation message.
238
+
239
+ If you select the asterisk in a test, it is now
240
+ `span[data-required-marker]` — an explicit locator, rather than an
241
+ accessibility attribute doubling as a test hook.
242
+
243
+ - a7651e6: The form renderer's built-in `select` branch stops saying "No options available"
244
+ in English to non-English sessions (objectui#3263).
245
+
246
+ FROM: the inline branch that renders a `type: 'select'` field — the one taken
247
+ whenever the field is a `BUILTIN_FIELD_TYPES` member, i.e. before the `field:`
248
+ registry is consulted — rendered `{emptyHint || 'No options available'}`. TO:
249
+ `{emptyHint || t('fields.options.empty')}`, the same i18n key the registered
250
+ option widgets fall back to (objectui#3231, all ten locale packs).
251
+
252
+ This was the last hardcoded copy of that sentence in `form.tsx`, and the file was
253
+ half-translated in a way a user could see inside one widget: the dependency-gate
254
+ sentence next to it already went through `t()`
255
+ (`fields.options.selectFirst`), so under a `zh` session a gated select read
256
+ "请先选择Country" and the same select — one keystroke later, when the parent
257
+ value matched no option — flipped to English.
258
+
259
+ `fields.options.empty` is added to `useSafeFormTranslation`'s defaults map, the
260
+ pattern `fields.options.selectFirst` already follows there, so a form rendered
261
+ with no `I18nProvider` (standalone widget, test, embedded form) produces the
262
+ byte-identical English string it produced before. Both halves are pinned by
263
+ tests: the Chinese rendering in one file, the no-provider English fallback in
264
+ another (mounting a provider installs it as react-i18next's global default,
265
+ which would erase the state the second one observes).
266
+
267
+ The box moved from an inline `<div>` into a small `BuiltinSelectEmptyState`
268
+ component in the same file, because `renderFieldComponent` is a plain helper that
269
+ early-returns on the registered-widget path — a hook called there would run
270
+ conditionally. It forwards its rest props, since `<FormControl>` is a Radix
271
+ `Slot` that supplies the control's `id` / `aria-describedby`; a test pins that
272
+ the field's `<label for>` still resolves to this box.
273
+
274
+ Deliberately NOT unified with `@object-ui/fields`' `OptionsEmptyState`: different
275
+ package, different render path (inline branch vs. registry). What the two share
276
+ is the i18n key, not a component — merging them would impose one path's markup
277
+ and props on the other.
278
+
279
+ - b71fc92: Localize the last untranslated console-chrome accessible names (objectstack#5430)
280
+
281
+ Four icon-only controls still carried hardcoded English accessible names, so
282
+ under a non-English session they were the only English left in the record
283
+ chrome — and because the controls have no visible label, that literal _is_ the
284
+ control to a screen reader and to the hover tooltip.
285
+
286
+ - `page:header`'s `role="toolbar"` — now `detail.pageHeaderActions` (its `⋯`
287
+ overflow trigger eight lines below was fixed in #5407; the toolbar was missed)
288
+ - `ReactionPicker`'s `role="listbox"` popup — now `detail.emojiPicker`
289
+ - `ReactionPicker`'s per-reaction chip, which built its name by concatenation
290
+ with English pluralization baked in (`reaction${count !== 1 ? 's' : ''}`) —
291
+ now `detail.reactionCount` / `detail.reactionCountOne`
292
+ - `NavigationOverlay`'s drawer close and split-panel close — now `common.close`
293
+ (the key the rest of the console already uses) and `common.closePanel`
294
+
295
+ The pluralized label follows this repo's **two-key** convention
296
+ (`detail.relatedRecords`/`relatedRecordOne`, `lookup.recordCount`/`recordCountOne`)
297
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
298
+ form, so those packs would legitimately omit the `_one` half and
299
+ `all-locales-key-parity` would read that as a lost key.
300
+
301
+ All five new keys are added to all ten locale packs.
302
+
303
+ - 34595eb: The Combobox trigger now declares `type="button"` explicitly, so it can never
304
+ submit an enclosing `<form>` (objectui#3344). The current Radix
305
+ `PopoverTrigger` happens to supply `type="button"` through its Slot, but that
306
+ form-safety guarantee was an upstream implementation detail — it is now a
307
+ locally declared, regression-tested contract, matching the explicit style of
308
+ LookupField / MultiSelectField / RatingField. The default sits before the
309
+ trigger pass-through spread (objectui#3318), so a consumer who explicitly
310
+ passes `type` (e.g. `type="submit"`) still wins.
311
+ - 9cbcbf4: The form renderer's built-in `textarea` branch reads the fullscreen long-text
312
+ flag on one spelling (objectui#3303).
313
+
314
+ FROM: the branch resolved the affordance as `mobile_fullscreen || fullscreen`,
315
+ and both prop strips (`stripRendererOnlyProps`, `stripRegisteredFieldProps`)
316
+ carried a matching entry discarding a `fullscreen` key. TO: a single read of
317
+ `mobile_fullscreen`, with no strip entry left for the alias.
318
+
319
+ No runtime behaviour changes for anything that exists, because the second term
320
+ was permanently `undefined`. `fullscreen` had **no producer**: a repo-wide grep
321
+ plus `objectstack`'s `packages/spec` turns up only the unrelated
322
+ feedback/loading overlay property of the same name, never a form field. The one
323
+ real producer is `ObjectForm`, which stamps `mobile_fullscreen` onto long-text
324
+ fields from `ObjectFormSchema.mobile.fullscreenLongText` — the same single
325
+ carrier `TextAreaField` and `RichTextField` read.
326
+
327
+ This closes the last member of the convergence run objectui#3232 / #3233 /
328
+ #3245 / #3301 started. The changeset for #3232 named this branch explicitly as
329
+ "a separate live path" still accepting two spellings; it is now single-read like
330
+ the two widgets, so the same authored metadata behaves the same way whether a
331
+ field type resolves to a registered widget or falls through to the built-in
332
+ branch.
333
+
334
+ Why a no-producer alias is worth removing rather than leaving as harmless
335
+ insurance: it is not insurance, it is a contract that never held. The renderer
336
+ advertised a spelling to whoever reads it next — very much including an AI
337
+ writing form metadata — and that spelling silently does nothing, with no error
338
+ and nowhere to look. That is the lenient consumer fallback AGENTS.md #0.1
339
+ forbids, and the identical mechanism behind #3245 and #3301. Dropping the strip
340
+ entries matters for the same reason: a key nobody produces should not get a
341
+ dedicated discard, it should be in the ordinary unrecognised-key class, so a
342
+ typo is as visible as any other typo instead of being quietly swallowed.
343
+
344
+ Pinned by tests in both places the alias lived: the built-in branch renders the
345
+ expand affordance for `mobile_fullscreen` and not for `fullscreen` (the
346
+ canonical case is asserted alongside the alias case, so the negative cannot pass
347
+ for the empty reason of the affordance having disappeared altogether), and the
348
+ strips are shown to own `mobile_fullscreen` — stripped from the top-level props,
349
+ delivered on `field` — while `fullscreen` is now indistinguishable from an
350
+ arbitrary unknown key.
351
+
352
+ - 85c4c9c: 表单内置 `textarea` 的全屏编辑对话框现在能拿到字段自己的 label:对话框标题显示字段名而不是恒定的通用词「编辑文本」,同一张表单上多个长文本字段的展开按钮也终于有了互不相同的无障碍名(objectui#3393)。
353
+
354
+ `renderFormField` 在解构字段配置时把 `label` 单独取走了(它要渲染 `<FormLabel>`),而下游 `renderFieldComponent` 唯一调用点重建 props 对象时显式补回了 `field` / `inputType` / `options` / `placeholder` / `emptyHint` / `dependsOnLabels` 等等,唯独漏了 `label`。于是内置 `textarea` 分支里的 `label` 恒为 `undefined`,`FullscreenTextarea` 中两条依赖它的分支从写下那天起就没走到过:
355
+
356
+ - 对话框标题 `label ?? t('form.fullscreen.title')` 永远落到通用词——一个叫「备注」的字段点开全屏编辑,标题不会说自己是「备注」;
357
+ - 展开按钮的无障碍名永远插值通用名词,一张有三个长文本字段的表单上三个按钮读屏完全一样。读屏用户无法判断自己要展开的是哪个字段,这是可达性缺陷而不是观感问题。
358
+
359
+ ## 改了什么
360
+ - 调用点显式转发 `label`(与 `placeholder` / `emptyHint` 同法),这是唯一的行为改动。
361
+ - `label` 属于 renderer-only:`stripRendererOnlyProps` 与 `stripRegisteredFieldProps` 各加一条丢弃项,所以它既不会变成 DOM 上的 `label="备注"` 杂属性(每个内置分支都会把剩余 props 直接摊到 DOM 节点上),也不会成为注册型 widget 新收到的 prop——自 v17 起 `field` 是它们唯一的元数据载体(objectui#3233),label 一直在那里读。
362
+ - 内置 `textarea` 分支里那句 `const { label: _label, ...rest }` 随之删除。它本想拦住 label 落到 DOM,但既然从来没有 label 送进来,它拦的是不存在的东西(ESLint 一直报着 `'_label' is assigned a value but never used`),而且只护住了这一个分支。现在这件事由 strip 统一负责,所有分支同等受护。
363
+
364
+ 十个语言包零改动:#3272 把 `form.fullscreen.toggle` 做成了带 `{{label}}` 插值的整句(zh 插在句尾、ja 插在句首),label 一通,十个语言的句子直接就对。字段没有 label 时仍回落到被翻译的通用词。
365
+
366
+ - fd54c3e: The form renderer's built-in `textarea` branch now honours `readonly` / `disabled` on its fullscreen exit, which previously bypassed both (objectui#3400). `renderFieldComponent` destructures `readonly` off its props, so it is absent from the `...rest` each branch spreads; the plain exit put it back as `readOnly={readonly}` plus the read-only tint, the `mobile_fullscreen` exit put back neither. `FullscreenTextarea` then renders three controls — the inline textarea, the expand button, and the dialog's own textarea — of which only the first ever saw a spread prop. So a read-only long-text field was editable in place without even opening the dialog, and a disabled one looked correctly greyed out while its expand button stayed live, its dialog accepted any edit, and "Done" wrote that edit back into form state. Neither is an exotic combination: `ObjectForm` stamps `mobile_fullscreen` onto every long-text field when `mobile.fullscreenLongText` is set without consulting either flag, `readonly` is also resolved at runtime from a `readonlyWhen` CEL rule, and `disabled` is additionally true for the whole form while a submit is in flight — so a submit in progress did not stop a long-text edit either. A read-only field now renders no expand button at all, matching the registered `TextAreaField` path so both renderers give the same metadata the same behaviour; a disabled field keeps the button but disables it. Both states also lock the dialog's textarea and its "Done" button independently, because `disabled` can flip to true while the dialog is already open. Editable long-text fields are unaffected.
367
+ - 4eeb932: The form renderer's last user-visible English literals now go through i18n (#3272). The fullscreen long-text editor (`mobile_fullscreen`) was an entire untranslated dialog — title, screen-reader description, `Cancel` / `Done` footer buttons, and the expand trigger's accessible name — rendering English inside an otherwise translated zh/ja/ar form; it now reads the new `form.fullscreen.*` keys, shipped in all ten locale packs.
368
+
369
+ **Behaviour change worth reading if you author forms:** `submitLabel` and `cancelLabel` no longer default to the literals `'Submit'` and `'Cancel'` in the renderer. They default to _unset_, and the action bar falls back at render time to `common.submit` / `common.cancel`, so a form that declares no button copy now follows the session language instead of being silently frozen to English. A label you DO declare still wins verbatim in every locale — including an English one under a zh session, and including an explicit empty string (the fallback uses `??`, so `submitLabel: ''` renders a blank button rather than being overwritten). The only forms whose rendered text changes are those that never declared the labels and are viewed in a non-English session — which is the bug. `FormSchema.submitLabel` / `cancelLabel` stay optional strings; no spec or type change.
370
+
371
+ Also removed the built-in `select` branch's second `|| 'Select an option'` fallback. The single call site already supplies `t('common.selectOption')`, so the literal was reachable only through an authored `placeholder: ''` — where it replaced the author's deliberate blank with an untranslated English word.
372
+
373
+ - 53811d1: Associate the label with its control at the two form surfaces where the two were never programmatically connected (objectui#3341 — found while implementing #3299/PR #3340, and deliberately left out of that PR's scope fence as a different class of defect).
374
+
375
+ `aria-required` reaching the control (#3299) only fixes the required _state_; at these two sites the control's accessible _name_ was still wrong, because the label pointed at nothing:
376
+
377
+ - `@object-ui/plugin-detail` — `InlineCreateRelated`'s create-tab fields rendered a `<label>` with no `htmlFor` beside an `<Input>` with no `id`, and the two were siblings rather than wrapper/child. The field label was unreachable for assistive tech, and clicking the label did not focus the input. The ids are namespaced with `React.useId`, because `field.name` alone is unique only within one instance and a detail page mounts one of these per related list.
378
+ - `@object-ui/components` — the custom `ActionParamDialog`'s `select` branch rendered `<Label htmlFor={param.name}>` but never put the matching `id` on its Radix `SelectTrigger`, so the reference dangled. The textarea / number / date / text branches already set `id={param.name}`; select was the only one that did not.
379
+
380
+ `SelectTrigger` renders a `<button role="combobox">`, and `button` is a labelable element, so the plain `htmlFor`/`id` pair is the correct association there — no `aria-labelledby` required. No spec change and no widget-props contract change.
381
+
382
+ - d915c47: Relation fields (`lookup` / `master_detail` / `user` / `tree`) are now usable in action and conditional-formatting predicates: they bind as the stored foreign key on every surface, and the fields a predicate reads are included in the query projection (#3501).
383
+
384
+ Before this, one predicate over one relation field had four different fates, decided by things its author does not control. `$expand` **replaces** the id in place with the whole related record, and a view expands exactly the relations it shows as COLUMNS — so `record.owner == "U1"` was **true** where the column was absent, **false** where it was displayed, and a **fault** where the field was neither displayed nor projected (a list's `$select` was built from its columns alone, and CEL treats an absent key as a fault, not as null). A fault is fail-CLOSED on the row kebab and the selection bar and fail-OPEN on the lenient paths, so the same authoring mistake hid the button from everyone on one surface and showed it to everyone on the next, with nothing on screen to point at either. The server, meanwhile, only ever sees the id — so client and server could not agree, which is the one thing ADR-0036 / ADR-0058 exist to guarantee.
385
+
386
+ Two changes close it. `toPredicateRecord` (new, `@object-ui/core`) collapses expanded relation values back to their ids when a record is bound for evaluation — driven by the object's own field types, not by sniffing for an `id` key, so a `json` field that happens to carry one is untouched. It is threaded through `evalRowPredicate` / `resolveConditionalFormatting` (via a new `fields` option), `useRowPredicate`, `partitionBulkRows`, and both `page:header` evaluators, with the object schema supplied by `ObjectGrid` / `ListView` / `ObjectKanban` / the record context. Kanban card formatting is threaded the same way, so a rule cannot match on the grid view of a list and silently never match on its board. Display is unaffected — a detail-page title still renders the related record's name, and the schema-only `kanban-ui` entry point (which has no object schema to offer) keeps using the payload verbatim. `collectPredicateFieldRefs` / `listViewPredicates` (new) harvest the `record.x` / `data.x` references out of a view's conditional formatting, row-action defs, bulk-action defs, promoted object actions and `userActions` overrides, and add them to `$select` — intersected with the object's declared fields plus the platform columns every object carries (`isProjectableField`), because an unknown key is not ignored by every backend. No `$expand` is added: a predicate wants the foreign key, which is what an unexpanded relation already is.
387
+
388
+ - 825bbe3: The option widgets' "this list cannot be filled" message now has one source, and
389
+ it is translated (objectui#3231).
390
+
391
+ FROM: `SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` each
392
+ carried their own copy of the empty/gated state, each destructured the declared
393
+ `emptyHint` prop into `_emptyHint` and dropped it, and each rendered a hardcoded
394
+ English literal (`'No options available'`, `` `Select ${…} first` ``) even in a
395
+ Chinese or Japanese session. TO: one shared `OptionsEmptyState` — the host's
396
+ `emptyHint` when it supplied one, otherwise a translated fallback
397
+ (`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale
398
+ packs).
399
+
400
+ `emptyHint` was declared, produced by the form renderer and transported, then
401
+ lost three times over — so no registered widget could ever render it. All three
402
+ breaks are fixed, because closing only the last one delivers nothing:
403
+
404
+ - `isOptionField` compared the raw resolved type against `'select'` /`'radio'` /
405
+ `'multiselect'` / `'checkboxes'`. Object-derived forms emit
406
+ `mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none of
407
+ them, so for every option field coming from an object schema — the normal case
408
+ in the console — the whole cascade block was skipped and no hint was computed
409
+ at all. It now normalizes the `field:` prefix, the same normalization
410
+ `stripRegisteredFieldProps` already applied a few lines below.
411
+ - `stripRegisteredFieldProps` then removed the `emptyHint` key from what was
412
+ left. It is now forwarded to the four cascade option types, alongside
413
+ `dependentValues`. This stays an allow-list rather than a blanket
414
+ pass-through: every other registered widget spreads its leftover props onto a
415
+ DOM node, where an unknown `emptyHint` attribute is a React warning.
416
+ - the widgets themselves discarded it. Keeping it out of the `...props` spread
417
+ was correct; not using it afterwards was not.
418
+
419
+ User-visible effect: a dependency-gated option list now prompts with the
420
+ controlling field's **label** ("Select Country first") instead of its raw
421
+ metadata name, in the session's language; an unconfigured list says so in the
422
+ session's language too. The gate sentence is one i18n key shared by the renderer
423
+ and the widget fallback, so the two sides cannot word it differently.
424
+
425
+ Untouched: the built-in (unregistered) `select` branch of the form renderer,
426
+ which already consumed `emptyHint`. That is a separate live path.
427
+
428
+ - 5dd0127: Localize the record-overlay and tab-badge chrome that #5430's sweep left behind (objectstack#5506)
429
+
430
+ Four more console-chrome strings were still hardcoded English literals. Unlike
431
+ #5430's set they are not all accessible names — one is visible copy, and one was
432
+ a component **default** that only the console happened to override.
433
+
434
+ - `page:tabs`' count badge built its `aria-label` by template literal,
435
+ `` `${formatTabCount(count)} items` ``. The badge renders digits only, so that
436
+ label _is_ the badge to a screen reader — and the English plural was baked in
437
+ with no singular branch at all, so a related list with one row announced
438
+ "1 items". Now `common.itemCount` / `common.itemCountOne`.
439
+ - `NavigationOverlay`'s drag-resize handle (`role="separator"`, no visible label)
440
+ — now `common.resizeDrawer`.
441
+ - `NavigationOverlay`'s `expandLabel` **default**. Hosts may override it and the
442
+ console does, but the default is what every other host ships — and it feeds
443
+ both `aria-label` and `title` of an icon-only button. Now
444
+ `detail.openAsFullPage`, still overridable by the prop.
445
+ - `NavigationOverlay`'s `resolvedTitle` fallback, `'Record Detail'` — **visible**
446
+ overlay heading, not just an a11y name. Now `detail.recordDetail`.
447
+ - The sr-only `SheetDescription`/`DialogDescription` prose
448
+ `Record detail overlay for {title}.`, which existed in three copies
449
+ (drawer / modal / popover) — now one `detail.recordDetailOverlay` key with a
450
+ `{{title}}` placeholder.
451
+
452
+ The count badge follows this repo's **two-key** plural convention
453
+ (`detail.reactionCount`/`reactionCountOne`, `detail.relatedRecords`/`relatedRecordOne`)
454
+ rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
455
+ form, so those packs would legitimately omit the `_one` half and
456
+ `all-locales-key-parity` would read that as a lost key. The formatted count
457
+ (`1.2k`, not `1200`) is interpolated so the accessible name and the visible
458
+ digits never disagree — and because i18next skips its own plural resolution when
459
+ `count` is a string, the two-key scheme stays in charge of the choice.
460
+
461
+ Both touched components moved from `useSafeTranslate` to `createSafeTranslation`,
462
+ which carries an options bag (two of the new keys interpolate) and an English
463
+ defaults map. That map is what keeps the provider-less path English, which
464
+ consumers outside this package depend on — `plugin-view`'s `ObjectView.test.tsx`
465
+ and `e2e/live/inline-edit-polish-2572.spec.ts` address this chrome by English
466
+ accessible name with no `I18nProvider` mounted.
467
+
468
+ All six new keys are added to all ten locale packs.
469
+
470
+ - 06632e9: `PageRenderer` no longer renders its own `<h1>` when the page authors a titled `page:header`, so a page has exactly one level-1 heading. Every non-record page used to render the page `title`/`label` as an `h1` _and_ let the `page:header` block render a second one — on the showcase master-detail page both said "New Project + Tasks", producing a broken document outline, a page title a screen reader announces twice, and the same string printed twice on screen. Record pages already delegated the whole title block to `page:header`; that rule now holds for `app` / `home` / `utility` pages too, and it is what the live e2e was reporting as a Playwright strict-mode violation (`getByRole('heading', { name })` resolving to 2 elements, objectui#3434).
471
+
472
+ Delegation is deliberately conservative: only a `page:header` whose title renders literal text takes the heading over. A header with no title — or one whose title interpolates to nothing (e.g. `title: '{name}'` with no record in scope) — renders no heading of its own, so the page keeps its implicit `h1` rather than ending up with none. The page-level `description` is unaffected; it is the page's own prose, not a duplicate of the header `subtitle`.
473
+
474
+ Author-visible effect: on a page carrying both a `label` and a titled `page:header`, only the header's title is shown (e.g. app-crm's welcome page shows "Welcome to the CRM", not also "CRM Welcome"). Pages without a `page:header` are unchanged.
475
+
476
+ - a4cff5b: Conditional-rule predicates that fail to evaluate are no longer silent
477
+ (objectstack#5149, appeal 2). `evalFieldPredicate` — the canonical funnel for
478
+ `visibleWhen` / `readonlyWhen` / `requiredWhen`, view-level `visibleOn`, legacy
479
+ `condition`, per-option `visibleWhen`, screen-field predicates and list
480
+ conditional formatting — now logs **one `console.warn` per predicate text**
481
+ when evaluation fails (parse error, unbound identifier, engine fault), carrying
482
+ the predicate source, the engine's failure reason, and the field/rule locator
483
+ the call site provides. Renderer call sites thread that locator
484
+ (`visibleWhen of field 'amount'`), so a broken predicate identifies itself in
485
+ the browser console instead of being indistinguishable from an absent one.
486
+
487
+ Verdicts are unchanged: evaluation still fails open to the caller's safe
488
+ default (flipping that default is objectstack#5149 appeal 1, tracked
489
+ separately). Fault-probing callers (`evalRowPredicate`'s fail-closed path,
490
+ `ExpressionEvaluator`'s `throwOnError`) opt out via the new
491
+ `diagnostic.warn: false` and keep their own single diagnostic, so no broken
492
+ predicate ever warns twice.
493
+
494
+ - 71be406: The close button that the `Sheet` and `Dialog` primitives auto-render now announces itself in the session locale instead of always in English. Both buttons are icon-only (a lucide `X`), so their `sr-only` span is not decoration — it IS the control's accessible name, and upstream Shadcn ships it as a hardcoded English literal. Under zh/ja/es every drawer and modal in the console (~20 `SheetContent` consumers plus every `DialogContent` consumer — ChatDock, ActivityFeed, metadata-admin, AiChatPage, BuildDebugDrawer, PeoplePicker, RecordDetailDrawer, …) announced "Close" to a screen reader. The span now renders `<CloseSrLabel />`, which resolves `common.close` (present in all ten locale packs since objectstack#5430) and falls back to English when no `I18nProvider` is mounted, so existing suites and e2e specs that address these controls by their English name are unaffected (objectstack#5505).
495
+
496
+ Because `packages/components/src/ui/**` is regenerated from the Shadcn registry, the edit is not a hand patch that the next `pnpm shadcn:update` would silently revert: it is declared as data in `scripts/shadcn-local-patches.mjs`, re-applied automatically on every sync (including `--force`), and enforced in both directions — `pnpm shadcn:check` now exits non-zero if a declared patch is missing from the file on disk or can no longer be re-applied to current upstream, and an offline test gates the same invariant on every PR.
497
+
498
+ - 8d8094a: 20 more registered field widgets now announce a failed validation to assistive
499
+ tech: `multiselect`, `radio`, `checkboxes`, `tags`, `lookup`, `master_detail`,
500
+ `user`, `owner`, `file`, `image`, `location`, `object`, `color`, `rating`,
501
+ `code`, `avatar`, `address`, `geolocation`, `qrcode` and `object-ref` carry
502
+ `aria-invalid="true"` on their real focusable control after a validation
503
+ failure, where before the red message rendered while a screen reader was told
504
+ nothing (objectui#3318, the registry-wide gap objectui#3306's sweep measured).
505
+
506
+ Each widget follows the objectui#3222/#3306 pattern: the `toDomProps(props)`
507
+ whitelist spread goes onto the control the user actually focuses — the input,
508
+ the lookup trigger button, the radiogroup (`role="radiogroup"` is the
509
+ ARIA-designated carrier for a set of radios), every chip/checkbox/star of the
510
+ composite option widgets, the upload dropzone/button — followed by an explicit
511
+ `aria-invalid={!!error}` computed from the published `error` slot. Wrapper
512
+ `<div>`s never carry the state, and `name` is withheld from non-form-control
513
+ elements (the objectui#3291 leak class).
514
+
515
+ `Combobox` (`@object-ui/components`) now accepts standard button attributes
516
+ and forwards them to its focusable `role="combobox"` trigger, giving
517
+ combobox-based widgets an element to deliver `aria-invalid` /
518
+ `aria-describedby` to — the same seam objectui#3306 opened on
519
+ `SelectTrigger`.
520
+
521
+ Nine types remain on the objectui#3318 ratchet ledger with their blockers
522
+ documented there (`formula`/`summary`/`auto_number`/`vector` render no
523
+ focusable control; `grid`, `slider`, `signature` need component-level design;
524
+ `filter-condition`/`recipient-picker` deliver in their editable states but
525
+ render a dependency-gate hint with no control in a fresh form).
526
+
527
+ - Updated dependencies [18cd432]
528
+ - Updated dependencies [d915c47]
529
+ - Updated dependencies [b71fc92]
530
+ - Updated dependencies [65516ba]
531
+ - Updated dependencies [94c5b7c]
532
+ - Updated dependencies [ca0fa8f]
533
+ - Updated dependencies [3889ffb]
534
+ - Updated dependencies [5781fb1]
535
+ - Updated dependencies [7e2406a]
536
+ - Updated dependencies [9e9e9a9]
537
+ - Updated dependencies [4eeb932]
538
+ - Updated dependencies [5c856ec]
539
+ - Updated dependencies [23018cc]
540
+ - Updated dependencies [68b6a28]
541
+ - Updated dependencies [0554e88]
542
+ - Updated dependencies [d915c47]
543
+ - Updated dependencies [f44d872]
544
+ - Updated dependencies [28b2e65]
545
+ - Updated dependencies [509104a]
546
+ - Updated dependencies [825bbe3]
547
+ - Updated dependencies [6195841]
548
+ - Updated dependencies [5dd0127]
549
+ - Updated dependencies [a415684]
550
+ - Updated dependencies [a4cff5b]
551
+ - Updated dependencies [175bd79]
552
+ - Updated dependencies [5af2852]
553
+ - Updated dependencies [f833d3a]
554
+ - Updated dependencies [a6ec93d]
555
+ - Updated dependencies [2a9513d]
556
+ - Updated dependencies [d22ae31]
557
+ - Updated dependencies [c7ed4c3]
558
+ - Updated dependencies [2409e1d]
559
+ - Updated dependencies [789fe3e]
560
+ - @object-ui/core@17.3.0
561
+ - @object-ui/types@17.3.0
562
+ - @object-ui/i18n@17.3.0
563
+ - @object-ui/react@17.3.0
564
+ - @object-ui/sdui-parser@17.3.0
565
+ - @object-ui/react-runtime@17.3.0
566
+
3
567
  ## 17.2.0
4
568
 
5
569
  ### Minor Changes
@@ -10,7 +10,17 @@ export interface ComboboxOption {
10
10
  value: string;
11
11
  label: string;
12
12
  }
13
- export interface ComboboxProps {
13
+ /**
14
+ * Beyond its own named props, the combobox accepts standard button attributes
15
+ * (`id`, `aria-*`, `data-*`, handlers, …) and forwards them to the trigger —
16
+ * the focusable `role="combobox"` button a user and their screen reader
17
+ * actually interact with. Without this seam a field widget rendering a
18
+ * Combobox had no element to deliver `aria-invalid` / `aria-describedby` to
19
+ * after a validation failure (objectui#3318; the same reason #3306 routes the
20
+ * select's pass-through onto `SelectTrigger`). `value` / `onChange` are
21
+ * omitted: this component's own `value` / `onValueChange` contract owns them.
22
+ */
23
+ export interface ComboboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onChange"> {
14
24
  options: ComboboxOption[];
15
25
  value?: string;
16
26
  onValueChange?: (value: string) => void;
@@ -20,4 +30,4 @@ export interface ComboboxProps {
20
30
  className?: string;
21
31
  disabled?: boolean;
22
32
  }
23
- export declare function Combobox({ options, value, onValueChange, placeholder, searchPlaceholder, emptyText, className, disabled, }: ComboboxProps): React.JSX.Element;
33
+ export declare function Combobox({ options, value, onValueChange, placeholder, searchPlaceholder, emptyText, className, disabled, ...triggerProps }: ComboboxProps): React.JSX.Element;
@@ -53,7 +53,13 @@ export interface NavigationOverlayProps {
53
53
  * When omitted, the expand button is not rendered.
54
54
  */
55
55
  onExpand?: () => void;
56
- /** Optional label for the expand button (accessible name & tooltip). */
56
+ /**
57
+ * Optional label for the expand button (accessible name & tooltip).
58
+ *
59
+ * When omitted the overlay resolves `detail.openAsFullPage` from the session
60
+ * locale (English when no `I18nProvider` is mounted) — it is no longer a
61
+ * hardcoded English literal (objectstack#5506).
62
+ */
57
63
  expandLabel?: string;
58
64
  /**
59
65
  * Optional storage key for persisting the user's manually-resized drawer
package/dist/index.css CHANGED
@@ -443,6 +443,9 @@
443
443
  .left-\[50\%\] {
444
444
  left: 50%;
445
445
  }
446
+ .isolate {
447
+ isolation: isolate;
448
+ }
446
449
  .z-10 {
447
450
  z-index: 10;
448
451
  }