@object-ui/fields 17.3.0 → 17.5.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.
Files changed (156) hide show
  1. package/CHANGELOG.md +941 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -1,17 +1,9 @@
1
1
  import { default as React } from 'react';
2
2
  import { FieldWidgetComponentProps } from './types';
3
- /**
4
- * Address data structure
5
- */
6
- export interface AddressValue {
7
- street?: string;
8
- city?: string;
9
- state?: string;
10
- zipCode?: string;
11
- country?: string;
12
- }
3
+ import { AddressValue } from './address-format';
4
+ export type { AddressValue } from './address-format';
13
5
  /**
14
6
  * Address field widget - provides a structured address input
15
- * Supports street, city, state, zip code, and country
7
+ * Supports street, city, state, postal code, and country
16
8
  */
17
9
  export declare function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>): React.JSX.Element;
@@ -1,155 +1,45 @@
1
1
  import { default as React } from 'react';
2
+ import { FullscreenEditorProps } from '@object-ui/components';
2
3
  /**
3
- * The fullscreen-edit affordance shared by every long-text widget: the "expand"
4
- * button plus the full-height dialog it opens.
5
- *
6
- * ## Why this is shared rather than copied
7
- *
8
- * `ObjectFormSchema.mobile.fullscreenLongText` is ONE form-level promise, and
9
- * `ObjectForm` projects it onto EVERY long-text field as `mobile_fullscreen`
10
- * (`field:textarea` → `TextAreaField`, `field:markdown` / `field:html` →
11
- * `RichTextField`). A user who turns the setting on gets one behaviour, so the
12
- * behaviour has one implementation. objectui#3301 is what the other
13
- * arrangement costs: the flag reached `RichTextField` for as long as the
14
- * feature has existed and that widget simply never read it, so half the
15
- * documented promise ("textarea/rich-text get an expand button") silently did
16
- * nothing. A second hand-written copy of the state machine is the same failure
17
- * with an extra step — it drifts, and nothing reports the drift.
18
- *
19
- * What is genuinely per-widget is the EDITOR, so that is the only thing
20
- * injected: `children` renders it against the draft. Everything a user can
21
- * observe about the *interaction* — when the affordance appears, that the
22
- * dialog seeds from the committed value, that typing stays local until "Done",
23
- * that "Cancel" discards — lives here, once.
24
- *
25
- * ## Draft semantics (unchanged from the original `TextAreaField` behaviour)
26
- *
27
- * The dialog edits a LOCAL draft seeded from `value` at open time and commits
28
- * once, on "Done". It deliberately does not stream every keystroke to the host:
29
- * these widgets sit in a react-hook-form field, and per-keystroke `onChange`
30
- * during a modal edit would mark the form dirty (and fire validation) for an
31
- * edit the user may still cancel. "Cancel" therefore needs no undo — nothing
32
- * was written.
33
- *
34
- * ## `disabled` (objectui#3402)
35
- *
36
- * The dialog is a SECOND editing surface for the same value, so a field that is
37
- * not interactive has to be not interactive here too. It was not: hosts landed
38
- * `disabled` on their inline control only, this component never declared the
39
- * prop at all, and a disabled long-text field therefore sat correctly greyed out
40
- * next to a live expand button — click it, type anything, press "Done", and the
41
- * edit went into form state through `onCommit`. Measured on `origin/main`
42
- * before the fix: inline `disabled=true`, toggle `disabled=false`, dialog input
43
- * `disabled=false`, `onChange` called with "EDITED WHILE DISABLED".
44
- *
45
- * The gate is shaped like the built-in path's (`FullscreenTextarea` in
46
- * `components/src/renderers/form/form.tsx`, objectui#3400/PR #3401), because
47
- * one form-level setting must keep producing one behaviour on both paths:
48
- *
49
- * - the toggle STAYS but is `disabled` — `disabled` means "not interactive,
50
- * muted", not "shown plainly", which is `readonly`'s job (and `readonly`
51
- * never reaches this component at all — see below);
52
- * - `openFullscreen` refuses independently of the attribute, since a
53
- * programmatic dispatch and a lost `pointer-events` rule both get past it;
54
- * - the dialog locks on its OWN — `disabled` is not merely a static flag, it is
55
- * also the form's `isSubmitting`, which flips to true while the dialog may
56
- * already be open. At that moment the toggle is no longer the gate. So the
57
- * editor is told (third `children` argument) and "Done" is disabled;
58
- * - and `onCommit` is gated, because that is the single point where a value can
59
- * leave this component for host state. Nothing native guards it: it is a
60
- * click handler on a different control reading React state.
61
- *
62
- * "Cancel" and `Esc` stay live in every state — a dialog that goes disabled
63
- * mid-edit must still be closable, or a submit in flight traps the user in it.
64
- *
65
- * ## Why there is no `readonly` prop
66
- *
67
- * Both hosts early-return a read-only DISPLAY before they compute the affordance
68
- * (`TextAreaField.tsx`, `RichTextField.tsx`), so this component is never
69
- * rendered for a read-only field and a `readonly` prop here would be declared
70
- * with no producer — the shape this package keeps deleting (objectui#3232/#3233).
71
- * A future host that renders an editor for read-only fields must add the prop
72
- * AND the producer together; do not add it "for symmetry" beforehand.
73
- *
74
- * ## `testIdPrefix`
75
- *
76
- * Each host passes its own (`textarea`, `richtext`), yielding the same
77
- * convention with a per-widget namespace: `<prefix>-fullscreen-toggle` /
78
- * `-dialog` / `-save`. One form can contain both a textarea and a markdown
79
- * field, so a single shared id would make a test unable to say which widget it
80
- * had found. The editor's own test id belongs to `children` for the same
81
- * reason.
82
- *
83
- * ## Copy (objectui#3404)
84
- *
85
- * Every string here goes through `useFieldTranslation()` and consumes the
86
- * EXISTING `form.fullscreen.*` / `common.cancel` keys — the same ones the
87
- * built-in branch consumes since objectui#3272. Nothing was missing from the
88
- * ten locale packs; this path simply never read them, so a zh session opened a
89
- * dialog reading `Cancel` / `Done` while a long-text field rendered by the
90
- * built-in branch **in the same form** read 「取消 / 完成」.
91
- *
92
- * Sharing the keys rather than minting `fields.fullscreen.*` twins is the
93
- * point: one form-level setting (`mobile.fullscreenLongText`) projects onto
94
- * both render paths, so two copies of this copy could only drift.
95
- *
96
- * `useFieldTranslation` (not bare `useObjectTranslation`) because this widget
97
- * is rendered without an `I18nProvider` by standalone/embedded hosts and by
98
- * every bare-render test in this package. Measured before the change: with no
99
- * provider mounted `useObjectTranslation().t('common.cancel')` returns
100
- * `"common.cancel"` — the raw key — and the `{{label}}` interpolation collapses
101
- * entirely. The safe hook's English defaults are byte-identical to the literals
102
- * they replaced, so the provider-less rendering is unchanged.
103
- *
104
- * Focus management, `Esc`, the overlay and the close button are Radix's, via
105
- * the repo's `Dialog` (`@object-ui/components`) — deliberately not re-answered
106
- * here.
4
+ * The fullscreen-edit affordance shared by every long-text widget in this
5
+ * package (`field:textarea` → `TextAreaField`, `field:markdown` / `field:html`
6
+ * → `RichTextField`).
7
+ *
8
+ * ## This is now a thin wrapper (objectui#3398)
9
+ *
10
+ * The affordance, the dialog, the draft/commit state machine and the
11
+ * `readOnly` / `disabled` semantics all live in `FullscreenEditor`
12
+ * (`@object-ui/components`). They used to live here AND, separately, in the
13
+ * form renderer's built-in `textarea` branch — one form-level promise
14
+ * (`ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text
15
+ * field as `mobile_fullscreen`) answered twice, on the two render paths.
16
+ *
17
+ * Two copies of a state machine drift, and these did: objectui#3400 measured a
18
+ * read-only field editable through the built-in branch's dialog, objectui#3402
19
+ * measured the same hole for `disabled` here, and #3393 (label in the dialog
20
+ * title) and #3272 (i18n) each landed on one side before the other. The
21
+ * maintainer's ruling of 2026-08-10 was one implementation hoisted to the
22
+ * dependency-legal side — and the measured import graph allows exactly that
23
+ * direction: this package depends on `@object-ui/components`, which declares no
24
+ * dependency on `@object-ui/fields` in `dependencies` or `peerDependencies`.
25
+ *
26
+ * ## Why the wrapper survives the merge rather than being deleted
27
+ *
28
+ * It carries the one thing that is genuinely about THIS package: `readOnly` is
29
+ * not part of the contract here. Both hosts early-return a read-only DISPLAY
30
+ * before they compute the affordance (`TextAreaField.tsx`, `RichTextField.tsx`),
31
+ * so a `readOnly` reaching this component would be a prop with no producer on
32
+ * this path — the shape this package keeps deleting (objectui#3232/#3233). The
33
+ * `Omit` below is that statement in the type system, and it is why the hoist did
34
+ * not simply re-export the primitive. A future host that renders an editor for
35
+ * read-only fields must widen this type AND pass the prop together; the
36
+ * primitive already answers it (no affordance at all).
37
+ *
38
+ * Everything else — `value` / `onCommit` / `label` / `testIdPrefix` /
39
+ * `disabled` / `children` / `footer` — passes straight through, so the hosts and
40
+ * their pins are unchanged. `testIdPrefix` still namespaces this package's ids
41
+ * (`textarea-`, `richtext-`), distinct from the built-in branch's
42
+ * `form-textarea-`, so a test can still say which render path it found.
107
43
  */
108
- export interface FullscreenFieldEditorProps {
109
- /**
110
- * The committed value. Seeds the draft each time the dialog opens — read at
111
- * open time, not held, so a value that changes underneath while the dialog is
112
- * closed is picked up on the next open.
113
- */
114
- value: string;
115
- /** Called once with the draft when the user confirms. */
116
- onCommit: (next: string) => void;
117
- /** Field label — the dialog title and part of the toggle's accessible name. */
118
- label?: string;
119
- /** Namespace for this widget's fullscreen test ids. See above. */
120
- testIdPrefix: string;
121
- /**
122
- * The host field is not interactive (objectui#3402). Disables the toggle, the
123
- * "Done" button and the write-back, and is handed to `children` so the host's
124
- * own editor renders disabled too. See the `disabled` section above for why
125
- * each of those is a separate line rather than belt-and-braces.
126
- *
127
- * **Producer**: the widget's `disabled` prop, which the form renderer computes
128
- * as `disabled || fieldDisabled || isSubmitting || optionGroupGated` and
129
- * forwards to registered widgets (`stripRegisteredFieldProps` does not strip
130
- * it).
131
- */
132
- disabled?: boolean;
133
- /**
134
- * The editor itself, rendered inside the dialog body against the draft. The
135
- * host passes the SAME editor it renders inline, so "fullscreen" is a size
136
- * change rather than a second, poorer editing surface.
137
- *
138
- * The third argument is this component's `disabled`, and the host is expected
139
- * to put it on the control it renders: only the host knows which element its
140
- * editor's disabled state belongs on. Ignoring it is not a write-back hole —
141
- * `onCommit` is gated here regardless — but it does leave a control that looks
142
- * editable while the field is not, so both in-repo hosts apply it and their
143
- * tests pin it.
144
- */
145
- children: (draft: string, setDraft: (next: string) => void, disabled: boolean) => React.ReactNode;
146
- /** Optional footer status for the draft (e.g. a character counter). */
147
- footer?: (draft: string) => React.ReactNode;
148
- /**
149
- * Extra classes for the toggle button. It is absolutely positioned by
150
- * default and expects a `relative` ancestor; a host whose layout has a
151
- * better home for it can re-place it here.
152
- */
153
- toggleClassName?: string;
154
- }
155
- export declare function FullscreenFieldEditor({ value, onCommit, label, testIdPrefix, disabled, children, footer, toggleClassName, }: FullscreenFieldEditorProps): React.JSX.Element;
44
+ export type FullscreenFieldEditorProps = Omit<FullscreenEditorProps, 'readOnly'>;
45
+ export declare function FullscreenFieldEditor(props: FullscreenFieldEditorProps): React.JSX.Element;
@@ -11,7 +11,8 @@ import { FieldWidgetComponentProps } from './types';
11
11
  *
12
12
  * Column config (a subset of `GridColumnDefinition`):
13
13
  * { field, label?, type?, options?, width?, required?, prefix?, step? }
14
- * type ∈ 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file'
14
+ * type ∈ 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time'
15
+ * | 'select' | 'lookup' | 'file'
15
16
  *
16
17
  * Field-level config (from `GridFieldMetadata`):
17
18
  * columns, min_rows, max_rows, allow_add, allow_delete, total_field
@@ -19,7 +20,16 @@ import { FieldWidgetComponentProps } from './types';
19
20
  export interface GridColumn {
20
21
  field: string;
21
22
  label?: string;
22
- type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file';
23
+ /**
24
+ * Cell control + read/write adapter for the column.
25
+ *
26
+ * `date` / `datetime` / `time` are three DISTINCT controls, not one
27
+ * (objectui#3569). Collapsing `datetime` onto the `date` control did not
28
+ * merely under-render it — `<input type="date">` hands back a bare
29
+ * `YYYY-MM-DD` on change, so touching the day of a `datetime` cell silently
30
+ * DELETED its time component from the record.
31
+ */
32
+ type?: 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file';
23
33
  options?: Array<{
24
34
  label: string;
25
35
  value: string;
@@ -1,4 +1,5 @@
1
1
  import { default as React } from 'react';
2
+ import { HostGroupProps } from './toHostGroupProps';
2
3
  /**
3
4
  * The "this option list cannot be filled" state shared by every fixed-option
4
5
  * widget (`SelectField` single, `MultiSelectField`, `RadioField`,
@@ -41,5 +42,18 @@ export interface OptionsEmptyStateProps {
41
42
  testId?: string;
42
43
  /** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */
43
44
  className?: string;
45
+ /**
46
+ * The host label's IDREF plumbing, when this box is the whole rendered surface
47
+ * of a group-labelled field (objectui#3990) — a `checkboxes` / `radio` /
48
+ * `multiselect` whose option list came back empty renders NOTHING but this
49
+ * box, so the visible label named zero elements until it landed here.
50
+ *
51
+ * A closed three-key type rather than an open props tail: only `id`,
52
+ * `aria-labelledby` and the `role` that makes them meaningful may reach this
53
+ * element, and reopening a spread is what objectui#3291 exists to prevent.
54
+ * Absent for the single `SelectField` — it is not group-labelled, its label
55
+ * still uses a plain `for`, and it must keep emitting no such attributes.
56
+ */
57
+ hostGroupProps?: HostGroupProps;
44
58
  }
45
- export declare function OptionsEmptyState({ emptyHint, gated, dependsOnFields, testId, className, }: OptionsEmptyStateProps): React.JSX.Element;
59
+ export declare function OptionsEmptyState({ emptyHint, gated, dependsOnFields, testId, className, hostGroupProps, }: OptionsEmptyStateProps): React.JSX.Element;
@@ -126,13 +126,39 @@ export interface RecordPickerDialogProps {
126
126
  */
127
127
  lookupFilters?: LookupFilterDef[];
128
128
  /**
129
- * Hard filter constraints applied to every query, in QueryParams.$filter
130
- * record form. Unlike `lookupFilters`, entries here never surface in the
131
- * filter bar and cannot be overridden by user filter input — used for the
132
- * dependent (cascading) lookup chain, where the parent field's value MUST
133
- * scope the candidate set (#2215).
129
+ * Hard filter constraint applied to every query. Unlike `lookupFilters`,
130
+ * entries here never surface in the filter bar and cannot be overridden by
131
+ * user filter input.
132
+ *
133
+ * Two shapes, discriminated STRUCTURALLY (`Array.isArray`), because the two
134
+ * callers speak two legitimate vocabularies and neither should be bent into
135
+ * the other:
136
+ *
137
+ * - **`QueryParams.$filter` record form** (`{ account: 'a1' }`) — the
138
+ * dependent (cascading) lookup chain, where the parent field's value MUST
139
+ * scope the candidate set (#2215). Merged by KEY OVERWRITE, so a cascaded
140
+ * value REPLACES a stale `lookupFilters` entry on the same field instead of
141
+ * intersecting with it. That precedence is load-bearing: an `and` of both
142
+ * would ask for `account = 'stale' AND account = 'a1'` and return nothing.
143
+ * - **A spec `ViewFilterRule[]`** (`[{ field, operator, value? }]`) — an
144
+ * author's `record:related_list.add.picker.filter`, handed over VERBATIM
145
+ * (#3831). Lowered by `mergeFilterNodes`, the repo's single filter sink, so
146
+ * all 19 `VIEW_FILTER_OPERATORS` reach the wire — including the four
147
+ * (`before`, `after`, `is_empty`, `is_not_empty`) the record form has no
148
+ * `$op` for. No second operator vocabulary is introduced here: two already
149
+ * exist (the spec's `AST_OPERATOR_MAP`, data-objectstack's
150
+ * `FILTER_OPERATOR_ALIASES`) and #3948 is what a third costs.
151
+ *
152
+ * The discriminator is exact rather than heuristic — every AST node is an
153
+ * ARRAY and a rule is a plain OBJECT, the same predicate `toFilterNode` uses.
154
+ *
155
+ * Typed `unknown` rather than `Record< string, any >` on purpose: that type
156
+ * ACCEPTED a rule array (TypeScript lets an array satisfy a string index of
157
+ * `any`), the old object-spread merge then flattened it to
158
+ * `{"0": {...}, "1": {...}}`, and the query filtered on columns literally
159
+ * named `0`/`1` — type-check green, wrong query, no diagnostic anywhere.
134
160
  */
135
- baseFilter?: Record<string, any>;
161
+ baseFilter?: unknown;
136
162
  /**
137
163
  * Cell renderer resolver function.
138
164
  * When provided, columns with a `type` property will be rendered using the
@@ -5,11 +5,31 @@ import { FieldWidgetComponentProps } from './types';
5
5
  *
6
6
  * A field declared `multiple: true` selects zero-or-more values (spec:
7
7
  * `multiple` is valid on `select`), so it renders the multi-value chip picker
8
- * — the same widget the `multiselect` type uses. Delegating here (rather than
9
- * only at a type-resolution layer) means every surface that renders the
10
- * `select` widget — the object form, the inline grid editor, and
11
- * `ActionParamDialog` — inherits multi-select identically, with no drift
12
- * between them. Single-value selects keep the cascading dropdown below.
8
+ * — the same widget the `multiselect` type uses. Single-value selects keep the
9
+ * cascading dropdown below.
10
+ *
11
+ * The FORM no longer arrives here with `multiple` (objectui#3986):
12
+ * `mapFieldTypeToFormType` now resolves a `select` + `multiple: true` field to
13
+ * `field:multiselect`, so the object-form path renders `MultiSelectField`
14
+ * directly under its own registry id — which is what carries the
15
+ * `labelling: 'group'` declaration the host label needs. Deciding the widget at
16
+ * the type-resolution layer is what keeps the declaration and the render from
17
+ * disagreeing; a delegation invisible to the resolver could not.
18
+ *
19
+ * The branch below stays because it is NOT dead — measured entrances that reach
20
+ * it with `multiple` set, none of which consult that resolver:
21
+ *
22
+ * - **the inline grid editor** — `FieldEditWidget` looks `select` up in its own
23
+ * `EDIT_WIDGETS` table, which SHORT-CIRCUITS before the alias map is
24
+ * consulted, and forwards the whole metadata object as `field`;
25
+ * - **`ActionParamDialog`** — `paramToField` resolves through
26
+ * `resolveFormWidgetType`, which likewise returns `select` from
27
+ * `fieldWidgetMap` before reaching the alias map, and carries
28
+ * `multiple: param.multiple` on the field it builds;
29
+ * - **hand-written SDUI** — a `{ type: 'field:select' }` node whose metadata
30
+ * declares `multiple`, which addresses this widget by name.
31
+ *
32
+ * All three then inherit multi-select from here identically, with no drift.
13
33
  *
14
34
  * Both branches resolve per-option `visibleWhen` cascading / role-gating through
15
35
  * the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
@@ -0,0 +1,68 @@
1
+ import { AddressValue } from '@objectstack/spec/data';
2
+ /**
3
+ * Address data structure — `z.input` of `@objectstack/spec`'s
4
+ * `AddressValueSchema`, which is what the platform stores and what
5
+ * `/api/v1/data/**` serves back.
6
+ *
7
+ * The postal code is spelled `postalCode` (objectstack#5143). `AddressField`
8
+ * used to read and write `zipCode`, a key that appears nowhere in the spec, so
9
+ * the stored postal code never reached the input — and whatever the user then
10
+ * typed into that apparently-empty box was written under a key
11
+ * `AddressValueSchema` strips: lost outright on a new record, and on an
12
+ * existing one silently discarded while the stale stored value survived. (A
13
+ * postal code nobody touched did survive an unrelated edit, because the write
14
+ * spread the whole stored object through; the issue's account of that half is
15
+ * corrected in `AddressField.postalCode.test.tsx`.) One name, on both sides:
16
+ * the contract's.
17
+ *
18
+ * IMPORTED rather than declared since objectui#4167. rc.6 publishes
19
+ * `AddressValue` under this exact name, and the local copy — whose own comment
20
+ * already claimed to be "the part names of `AddressSchema`" — declared five of
21
+ * the seven parts. `countryCode` and `formatted` were missing, which is
22
+ * objectstack#4115's failure class precisely: a canonical CLAIM over a narrower
23
+ * shape, believed by the next reader. The claim is now the code.
24
+ *
25
+ * `AddressField` still renders five inputs and {@link formatAddress} still
26
+ * composes five parts, and that is a deliberate split rather than an omission:
27
+ * `formatted` is a derived one-line rendering of the parts (what this module
28
+ * computes) and `countryCode` is the ISO pair for `country`, neither of which a
29
+ * human types into a part box. Binding the type does not publish them as
30
+ * inputs — it stops the type from asserting the platform cannot store them, and
31
+ * it means a write from the widget no longer looks type-clean while dropping a
32
+ * key the contract carries: the `{ ...address }` spread in `handleFieldChange`
33
+ * preserves both, and now says so.
34
+ *
35
+ * The declaration moved here (objectui#4037) so the display cell renderer and
36
+ * the widget share one definition; the SOURCE of that definition is the spec.
37
+ */
38
+ export type { AddressValue };
39
+ /**
40
+ * The shape written by builds up to and including 17.0.0-rc.1, whose postal
41
+ * code landed under `zipCode` (objectstack#5143). Read-time compatibility ONLY,
42
+ * and deliberately not part of {@link AddressValue}: authoring code must not be
43
+ * able to spell `zipCode`, and nothing here ever writes it back — the first
44
+ * edit of any part normalizes the record onto `postalCode` (see
45
+ * `AddressField`'s `handleFieldChange`). This is not a producer alias to be
46
+ * honoured forever; it exists to carry data THIS widget mis-wrote, and can go
47
+ * once no such data remains.
48
+ */
49
+ export type LegacyAddressValue = AddressValue & {
50
+ zipCode?: string;
51
+ };
52
+ /** Postal code of a stored address, preferring the canonical key. */
53
+ export declare function readPostalCode(addr: AddressValue): string | undefined;
54
+ /**
55
+ * Formats a stored address as a single line: `Street, City, State ZIP, Country`.
56
+ *
57
+ * Ordering and separators are NOT invented here — they are the rule
58
+ * `AddressField` already applied in its readonly branch, and the sub-field set
59
+ * its inputs expose (street / city / state / postalCode / country). Missing
60
+ * parts are dropped rather than spaced over, so a street-only address is
61
+ * `"中策路 1 号"` and never `", , ,"`.
62
+ *
63
+ * Returns `''` when no part is usable — callers decide what an empty address
64
+ * looks like (an `EmptyValue` placeholder for the cell renderer), and a value
65
+ * carrying no recognized part at all is NOT silently blanked: see
66
+ * `AddressCellRenderer`, which falls back to JSON so unknown data stays visible.
67
+ */
68
+ export declare function formatAddress(addr: AddressValue): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The client-side `maxSize` guard shared by the file and image widgets
3
+ * (objectui#4141).
4
+ *
5
+ * Why this is shared rather than mirrored per widget: the guard is the only
6
+ * thing standing between a declared `maxSize` and a real `presigned → PUT →
7
+ * complete` round trip, and `paramToField` copies `maxSize` onto the field
8
+ * config for **every** param type regardless of which widget ends up rendering
9
+ * it. So a widget that re-derives the check drifts silently — the image param
10
+ * shipped receiving the constraint and never reading it, while the file param
11
+ * rejected the identical pick, and nothing failed until QA drove the two side by
12
+ * side. One exported function makes the difference visible: a widget either
13
+ * calls it or conspicuously does not.
14
+ *
15
+ * Both widgets deliberately share the `fields.file.exceedsMaxSize` key too. The
16
+ * sentence names a file and a limit and says nothing file-widget-specific, it is
17
+ * already translated in all 11 built-in locales, and a parallel `fields.image.*`
18
+ * twin would be the same sentence maintained twice — drifting the moment one
19
+ * translator rewords one of them.
20
+ */
21
+ /**
22
+ * Minimal shape of the i18next `t` this module needs, matching the established
23
+ * `TranslateFn` pattern in `app-shell/src/providers/writeWarningToast`.
24
+ */
25
+ export type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
26
+ /**
27
+ * The rejection message for a pick that exceeds `maxSize`, or `null` when the
28
+ * pick is within the limit — so a caller reads it as `if (err) reject`.
29
+ *
30
+ * A falsy `maxSize` (undeclared, or an explicit `0`) means **unrestricted**,
31
+ * preserving the `if (maxSize && …)` semantics the file widget has always
32
+ * applied: an undeclared limit must never turn into a 0-byte one.
33
+ *
34
+ * Takes `name` and `size` separately instead of a `File` because the image
35
+ * widget's crop path validates a re-encoded `Blob`, which carries no name of its
36
+ * own — that name comes from the cropper's output.
37
+ */
38
+ export declare function maxSizeError(t: TranslateFn, pick: {
39
+ name: string;
40
+ size: number;
41
+ }, maxSize: number | undefined): string | null;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Stored value → `<input type="date">` value (`YYYY-MM-DD`).
10
+ *
11
+ * A leading `YYYY-MM-DD` is passed through VERBATIM rather than re-parsed: a
12
+ * date-only string is parsed as UTC midnight by `new Date()`, so reading local
13
+ * calendar components back out of it shifts the day by one everywhere west of
14
+ * Greenwich. `2026-06-17` must stay June 17th in every zone.
15
+ */
16
+ export declare function toDateInputValue(value: unknown): string;
17
+ /**
18
+ * Stored value → `<input type="datetime-local">` value (`YYYY-MM-DDTHH:mm`),
19
+ * expressed in the viewer's local wall clock — the same instant the detail
20
+ * page shows, so opening the editor never appears to move the time.
21
+ *
22
+ * A value that is ALREADY a zone-less local datetime is truncated to minutes
23
+ * in place instead of being round-tripped through `Date`, which would
24
+ * reinterpret it and then re-render it, twice risking an off-by-a-zone shift.
25
+ */
26
+ export declare function toDateTimeInputValue(value: unknown): string;
27
+ /**
28
+ * `<input type="datetime-local">` value → stored value (ISO-8601, UTC).
29
+ *
30
+ * The control hands us a zone-less local wall clock; `new Date()` reads that
31
+ * form as local time, which is the inverse of what `toDateTimeInputValue`
32
+ * wrote — the pair is what keeps read and write on one basis.
33
+ *
34
+ * An unparseable string is returned untouched rather than blanked: dropping a
35
+ * value the user can see in the box would be a data loss dressed up as a fix.
36
+ */
37
+ export declare function fromDateTimeInputValue(value: string): string;
@@ -1,10 +1,4 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
1
+ import { DomProps as CoreDomProps } from '@object-ui/core';
8
2
  /**
9
3
  * The RUNTIME EXECUTOR of the "DOM pass-through" section of
10
4
  * {@link FieldWidgetComponentProps} (objectui#3291).
@@ -59,13 +53,39 @@
59
53
  * open spread. If a field node should be able to author one, DECLARE it on
60
54
  * `FieldWidgetComponentProps` first and add it here — do not reopen the spread
61
55
  * (AGENTS.md #0.1: fix the contract, never widen the consumer).
56
+ *
57
+ * ## Where the mechanism lives now, and why the key list stayed here
58
+ *
59
+ * objectui#4425 phase 2 promoted this whitelist to the SDUI widget contract
60
+ * generally, so the MECHANISM — filter by a declared key list plus the `aria-*`
61
+ * / `data-*` open families — moved to `@object-ui/core`
62
+ * (`utils/dom-props.ts`, `pickDomProps`), where every plugin package can reach
63
+ * it without depending on `@object-ui/fields` (the objectui#4409
64
+ * dependency-direction method: `plugin-chatbot` declares `@object-ui/core` and
65
+ * does not declare this package).
66
+ *
67
+ * The KEY LIST did not move, because it is not the same list. Measured on
68
+ * objectui#4431:
69
+ *
70
+ * - `name` and `disabled` are here and NOT in the SDUI set: both are legal
71
+ * only on form controls, which is what every widget in this package renders
72
+ * and what `FieldWidgetComponentProps` declares. `name` on the `<div>` an
73
+ * SDUI container widget renders is a leak — one of the 14 attributes #4431
74
+ * closed — and two widgets here already hand-strip it when they spread onto
75
+ * something that is not a control (`ObjectRefField`'s trigger,
76
+ * `FileField`'s dropzone).
77
+ * - `role` is in the SDUI set and NOT here: `SchemaRenderer` resolves it for
78
+ * every node from `@objectstack/spec`'s `AriaPropsSchema`, while this
79
+ * contract deliberately does not declare it (see "Deliberately NOT
80
+ * forwarded" above).
81
+ *
82
+ * So: one mechanism, two declarations, each bound by the assertions below to
83
+ * the contract it executes. Not two judges.
62
84
  */
63
85
  declare const DOM_PASS_THROUGH_KEYS: readonly ["id", "name", "autoFocus", "tabIndex", "onBlur", "onFocus", "onClick", "className", "disabled"];
64
86
  type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
65
87
  /** The subset of `P` this helper forwards, with each key's declared type. */
66
- export type DomProps<P> = Pick<P, Extract<keyof P, DomPassThroughKey>> & {
67
- [K in Extract<keyof P, `data-${string}` | `aria-${string}`>]: P[K];
68
- };
88
+ export type DomProps<P> = CoreDomProps<P, DomPassThroughKey>;
69
89
  /**
70
90
  * Keep only what may legitimately become a DOM attribute, and drop everything
71
91
  * else — renderer plumbing, and any extra key an author put on the field