@object-ui/fields 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.
Files changed (142) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  3. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  4. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  5. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  6. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +2881 -2566
  10. package/dist/widgets/AddressField.d.ts +1 -1
  11. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/CharacterCount.d.ts +56 -0
  14. package/dist/widgets/CheckboxesField.d.ts +1 -1
  15. package/dist/widgets/CodeField.d.ts +1 -1
  16. package/dist/widgets/ColorField.d.ts +1 -1
  17. package/dist/widgets/CurrencyField.d.ts +1 -1
  18. package/dist/widgets/EmailField.d.ts +1 -1
  19. package/dist/widgets/FileField.d.ts +1 -1
  20. package/dist/widgets/FilterConditionField.d.ts +1 -1
  21. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  22. package/dist/widgets/GeolocationField.d.ts +1 -1
  23. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  24. package/dist/widgets/ImageField.d.ts +1 -1
  25. package/dist/widgets/LocationField.d.ts +1 -1
  26. package/dist/widgets/LookupField.d.ts +1 -1
  27. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  28. package/dist/widgets/MultiSelectField.d.ts +1 -1
  29. package/dist/widgets/ObjectField.d.ts +1 -1
  30. package/dist/widgets/ObjectRefField.d.ts +1 -1
  31. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  32. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  33. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  34. package/dist/widgets/PercentField.d.ts +1 -1
  35. package/dist/widgets/PhoneField.d.ts +1 -1
  36. package/dist/widgets/QRCodeField.d.ts +1 -1
  37. package/dist/widgets/RadioField.d.ts +1 -1
  38. package/dist/widgets/RatingField.d.ts +1 -1
  39. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  40. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  41. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  42. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  43. package/dist/widgets/RichTextField.d.ts +19 -1
  44. package/dist/widgets/TagsField.d.ts +1 -1
  45. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  46. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  47. package/dist/widgets/TextAreaField.d.ts +44 -5
  48. package/dist/widgets/UrlField.d.ts +1 -1
  49. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  50. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  51. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  52. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  53. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  54. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  55. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  56. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  57. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  58. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  59. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  60. package/dist/widgets/toDomProps.d.ts +91 -0
  61. package/dist/widgets/types.d.ts +128 -33
  62. package/dist/withFieldCarrier.d.ts +56 -0
  63. package/package.json +13 -13
  64. package/src/__tests__/cell-truncation.test.tsx +96 -0
  65. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  66. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  67. package/src/__tests__/validation-feedback.test.tsx +22 -13
  68. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  69. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  70. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  71. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  72. package/src/index.tsx +74 -26
  73. package/src/widgets/AddressField.tsx +31 -12
  74. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  75. package/src/widgets/AvatarField.tsx +9 -2
  76. package/src/widgets/BooleanField.tsx +3 -3
  77. package/src/widgets/CharacterCount.tsx +263 -0
  78. package/src/widgets/CheckboxesField.tsx +31 -14
  79. package/src/widgets/CodeField.tsx +9 -2
  80. package/src/widgets/ColorField.tsx +11 -2
  81. package/src/widgets/CurrencyField.tsx +5 -4
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +5 -5
  85. package/src/widgets/FileField.tsx +13 -3
  86. package/src/widgets/FilterConditionField.tsx +11 -0
  87. package/src/widgets/FormulaField.tsx +1 -1
  88. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  89. package/src/widgets/GeolocationField.tsx +21 -6
  90. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  91. package/src/widgets/GridField.tsx +1 -1
  92. package/src/widgets/ImageField.tsx +9 -2
  93. package/src/widgets/LocationField.tsx +9 -2
  94. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  95. package/src/widgets/LookupField.tsx +41 -6
  96. package/src/widgets/MasterDetailField.tsx +1 -1
  97. package/src/widgets/MultiSelectField.tsx +30 -14
  98. package/src/widgets/NumberField.tsx +3 -3
  99. package/src/widgets/ObjectField.tsx +17 -8
  100. package/src/widgets/ObjectRefField.tsx +10 -0
  101. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  102. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  103. package/src/widgets/OptionsEmptyState.tsx +83 -0
  104. package/src/widgets/PasswordField.tsx +3 -3
  105. package/src/widgets/PeoplePicker.tsx +7 -1
  106. package/src/widgets/PercentField.tsx +5 -4
  107. package/src/widgets/PhoneField.tsx +5 -5
  108. package/src/widgets/QRCodeField.tsx +9 -2
  109. package/src/widgets/RadioField.tsx +24 -14
  110. package/src/widgets/RatingField.tsx +18 -3
  111. package/src/widgets/RecipientPickerField.tsx +18 -0
  112. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  113. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  114. package/src/widgets/RecordPickerDialog.tsx +173 -8
  115. package/src/widgets/RichTextField.tsx +151 -24
  116. package/src/widgets/SelectField.tsx +48 -17
  117. package/src/widgets/SliderField.tsx +1 -1
  118. package/src/widgets/SummaryField.tsx +1 -1
  119. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  120. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  121. package/src/widgets/TagsField.tsx +23 -2
  122. package/src/widgets/TextAreaField.tsx +171 -93
  123. package/src/widgets/TextField.tsx +3 -3
  124. package/src/widgets/TimeField.tsx +2 -2
  125. package/src/widgets/UrlField.tsx +5 -5
  126. package/src/widgets/UserField.tsx +1 -1
  127. package/src/widgets/VectorField.tsx +1 -1
  128. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  129. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  130. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  131. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  132. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  133. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  134. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  135. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  136. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  137. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  138. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  139. package/src/widgets/toDomProps.ts +175 -0
  140. package/src/widgets/types.ts +135 -35
  141. package/src/widgets/useFieldTranslation.ts +60 -0
  142. package/src/withFieldCarrier.tsx +75 -0
@@ -0,0 +1,271 @@
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
+ import React, { useState } from 'react';
10
+ import {
11
+ cn,
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogFooter,
18
+ Button,
19
+ } from '@object-ui/components';
20
+ import { Maximize2, Check, X } from 'lucide-react';
21
+ import { useFieldTranslation } from './useFieldTranslation';
22
+
23
+ /**
24
+ * The fullscreen-edit affordance shared by every long-text widget: the "expand"
25
+ * button plus the full-height dialog it opens.
26
+ *
27
+ * ## Why this is shared rather than copied
28
+ *
29
+ * `ObjectFormSchema.mobile.fullscreenLongText` is ONE form-level promise, and
30
+ * `ObjectForm` projects it onto EVERY long-text field as `mobile_fullscreen`
31
+ * (`field:textarea` → `TextAreaField`, `field:markdown` / `field:html` →
32
+ * `RichTextField`). A user who turns the setting on gets one behaviour, so the
33
+ * behaviour has one implementation. objectui#3301 is what the other
34
+ * arrangement costs: the flag reached `RichTextField` for as long as the
35
+ * feature has existed and that widget simply never read it, so half the
36
+ * documented promise ("textarea/rich-text get an expand button") silently did
37
+ * nothing. A second hand-written copy of the state machine is the same failure
38
+ * with an extra step — it drifts, and nothing reports the drift.
39
+ *
40
+ * What is genuinely per-widget is the EDITOR, so that is the only thing
41
+ * injected: `children` renders it against the draft. Everything a user can
42
+ * observe about the *interaction* — when the affordance appears, that the
43
+ * dialog seeds from the committed value, that typing stays local until "Done",
44
+ * that "Cancel" discards — lives here, once.
45
+ *
46
+ * ## Draft semantics (unchanged from the original `TextAreaField` behaviour)
47
+ *
48
+ * The dialog edits a LOCAL draft seeded from `value` at open time and commits
49
+ * once, on "Done". It deliberately does not stream every keystroke to the host:
50
+ * these widgets sit in a react-hook-form field, and per-keystroke `onChange`
51
+ * during a modal edit would mark the form dirty (and fire validation) for an
52
+ * edit the user may still cancel. "Cancel" therefore needs no undo — nothing
53
+ * was written.
54
+ *
55
+ * ## `disabled` (objectui#3402)
56
+ *
57
+ * The dialog is a SECOND editing surface for the same value, so a field that is
58
+ * not interactive has to be not interactive here too. It was not: hosts landed
59
+ * `disabled` on their inline control only, this component never declared the
60
+ * prop at all, and a disabled long-text field therefore sat correctly greyed out
61
+ * next to a live expand button — click it, type anything, press "Done", and the
62
+ * edit went into form state through `onCommit`. Measured on `origin/main`
63
+ * before the fix: inline `disabled=true`, toggle `disabled=false`, dialog input
64
+ * `disabled=false`, `onChange` called with "EDITED WHILE DISABLED".
65
+ *
66
+ * The gate is shaped like the built-in path's (`FullscreenTextarea` in
67
+ * `components/src/renderers/form/form.tsx`, objectui#3400/PR #3401), because
68
+ * one form-level setting must keep producing one behaviour on both paths:
69
+ *
70
+ * - the toggle STAYS but is `disabled` — `disabled` means "not interactive,
71
+ * muted", not "shown plainly", which is `readonly`'s job (and `readonly`
72
+ * never reaches this component at all — see below);
73
+ * - `openFullscreen` refuses independently of the attribute, since a
74
+ * programmatic dispatch and a lost `pointer-events` rule both get past it;
75
+ * - the dialog locks on its OWN — `disabled` is not merely a static flag, it is
76
+ * also the form's `isSubmitting`, which flips to true while the dialog may
77
+ * already be open. At that moment the toggle is no longer the gate. So the
78
+ * editor is told (third `children` argument) and "Done" is disabled;
79
+ * - and `onCommit` is gated, because that is the single point where a value can
80
+ * leave this component for host state. Nothing native guards it: it is a
81
+ * click handler on a different control reading React state.
82
+ *
83
+ * "Cancel" and `Esc` stay live in every state — a dialog that goes disabled
84
+ * mid-edit must still be closable, or a submit in flight traps the user in it.
85
+ *
86
+ * ## Why there is no `readonly` prop
87
+ *
88
+ * Both hosts early-return a read-only DISPLAY before they compute the affordance
89
+ * (`TextAreaField.tsx`, `RichTextField.tsx`), so this component is never
90
+ * rendered for a read-only field and a `readonly` prop here would be declared
91
+ * with no producer — the shape this package keeps deleting (objectui#3232/#3233).
92
+ * A future host that renders an editor for read-only fields must add the prop
93
+ * AND the producer together; do not add it "for symmetry" beforehand.
94
+ *
95
+ * ## `testIdPrefix`
96
+ *
97
+ * Each host passes its own (`textarea`, `richtext`), yielding the same
98
+ * convention with a per-widget namespace: `<prefix>-fullscreen-toggle` /
99
+ * `-dialog` / `-save`. One form can contain both a textarea and a markdown
100
+ * field, so a single shared id would make a test unable to say which widget it
101
+ * had found. The editor's own test id belongs to `children` for the same
102
+ * reason.
103
+ *
104
+ * ## Copy (objectui#3404)
105
+ *
106
+ * Every string here goes through `useFieldTranslation()` and consumes the
107
+ * EXISTING `form.fullscreen.*` / `common.cancel` keys — the same ones the
108
+ * built-in branch consumes since objectui#3272. Nothing was missing from the
109
+ * ten locale packs; this path simply never read them, so a zh session opened a
110
+ * dialog reading `Cancel` / `Done` while a long-text field rendered by the
111
+ * built-in branch **in the same form** read 「取消 / 完成」.
112
+ *
113
+ * Sharing the keys rather than minting `fields.fullscreen.*` twins is the
114
+ * point: one form-level setting (`mobile.fullscreenLongText`) projects onto
115
+ * both render paths, so two copies of this copy could only drift.
116
+ *
117
+ * `useFieldTranslation` (not bare `useObjectTranslation`) because this widget
118
+ * is rendered without an `I18nProvider` by standalone/embedded hosts and by
119
+ * every bare-render test in this package. Measured before the change: with no
120
+ * provider mounted `useObjectTranslation().t('common.cancel')` returns
121
+ * `"common.cancel"` — the raw key — and the `{{label}}` interpolation collapses
122
+ * entirely. The safe hook's English defaults are byte-identical to the literals
123
+ * they replaced, so the provider-less rendering is unchanged.
124
+ *
125
+ * Focus management, `Esc`, the overlay and the close button are Radix's, via
126
+ * the repo's `Dialog` (`@object-ui/components`) — deliberately not re-answered
127
+ * here.
128
+ */
129
+ export interface FullscreenFieldEditorProps {
130
+ /**
131
+ * The committed value. Seeds the draft each time the dialog opens — read at
132
+ * open time, not held, so a value that changes underneath while the dialog is
133
+ * closed is picked up on the next open.
134
+ */
135
+ value: string;
136
+ /** Called once with the draft when the user confirms. */
137
+ onCommit: (next: string) => void;
138
+ /** Field label — the dialog title and part of the toggle's accessible name. */
139
+ label?: string;
140
+ /** Namespace for this widget's fullscreen test ids. See above. */
141
+ testIdPrefix: string;
142
+ /**
143
+ * The host field is not interactive (objectui#3402). Disables the toggle, the
144
+ * "Done" button and the write-back, and is handed to `children` so the host's
145
+ * own editor renders disabled too. See the `disabled` section above for why
146
+ * each of those is a separate line rather than belt-and-braces.
147
+ *
148
+ * **Producer**: the widget's `disabled` prop, which the form renderer computes
149
+ * as `disabled || fieldDisabled || isSubmitting || optionGroupGated` and
150
+ * forwards to registered widgets (`stripRegisteredFieldProps` does not strip
151
+ * it).
152
+ */
153
+ disabled?: boolean;
154
+ /**
155
+ * The editor itself, rendered inside the dialog body against the draft. The
156
+ * host passes the SAME editor it renders inline, so "fullscreen" is a size
157
+ * change rather than a second, poorer editing surface.
158
+ *
159
+ * The third argument is this component's `disabled`, and the host is expected
160
+ * to put it on the control it renders: only the host knows which element its
161
+ * editor's disabled state belongs on. Ignoring it is not a write-back hole —
162
+ * `onCommit` is gated here regardless — but it does leave a control that looks
163
+ * editable while the field is not, so both in-repo hosts apply it and their
164
+ * tests pin it.
165
+ */
166
+ children: (
167
+ draft: string,
168
+ setDraft: (next: string) => void,
169
+ disabled: boolean,
170
+ ) => React.ReactNode;
171
+ /** Optional footer status for the draft (e.g. a character counter). */
172
+ footer?: (draft: string) => React.ReactNode;
173
+ /**
174
+ * Extra classes for the toggle button. It is absolutely positioned by
175
+ * default and expects a `relative` ancestor; a host whose layout has a
176
+ * better home for it can re-place it here.
177
+ */
178
+ toggleClassName?: string;
179
+ }
180
+
181
+ export function FullscreenFieldEditor({
182
+ value,
183
+ onCommit,
184
+ label,
185
+ testIdPrefix,
186
+ disabled = false,
187
+ children,
188
+ footer,
189
+ toggleClassName,
190
+ }: FullscreenFieldEditorProps) {
191
+ const { t } = useFieldTranslation();
192
+ const [open, setOpen] = useState(false);
193
+ const [draft, setDraft] = useState(value ?? '');
194
+
195
+ const openFullscreen = () => {
196
+ if (disabled) return;
197
+ setDraft(value ?? '');
198
+ setOpen(true);
199
+ };
200
+ const cancelFullscreen = () => setOpen(false);
201
+ const commitFullscreen = () => {
202
+ // THE gate: the one point where a value leaves this component for host
203
+ // state. `disabled` can flip to true while this dialog is open (it carries
204
+ // the form's `isSubmitting`), so this is checked here and not only on the
205
+ // way in. Closing is unconditional — see "Cancel and Esc stay live" above.
206
+ if (!disabled) onCommit(draft);
207
+ setOpen(false);
208
+ };
209
+
210
+ return (
211
+ <>
212
+ <button
213
+ type="button"
214
+ onClick={openFullscreen}
215
+ disabled={disabled}
216
+ className={cn(
217
+ 'absolute top-1.5 right-1.5 inline-flex items-center justify-center size-7 rounded-md bg-background/80 text-muted-foreground hover:text-foreground hover:bg-background border shadow-sm transition-colors',
218
+ 'disabled:opacity-50 disabled:pointer-events-none',
219
+ toggleClassName,
220
+ )}
221
+ aria-label={t('form.fullscreen.toggle', {
222
+ label: label ?? t('form.fullscreen.textFallback'),
223
+ })}
224
+ data-testid={`${testIdPrefix}-fullscreen-toggle`}
225
+ >
226
+ <Maximize2 className="size-3.5" />
227
+ </button>
228
+
229
+ <Dialog open={open} onOpenChange={setOpen}>
230
+ <DialogContent
231
+ className="sm:max-w-3xl h-[100dvh] sm:h-[80vh] max-h-[100dvh] sm:max-h-[80vh] flex flex-col p-0 gap-0"
232
+ data-testid={`${testIdPrefix}-fullscreen-dialog`}
233
+ >
234
+ <DialogHeader className="p-4 border-b">
235
+ <DialogTitle className="text-base">{label ?? t('form.fullscreen.title')}</DialogTitle>
236
+ {/*
237
+ Parity with the built-in branch (`form.tsx`), which has carried
238
+ this sr-only line since #3272. Without it Radix reports no
239
+ `descriptionPresent` and omits `aria-describedby` altogether, so
240
+ the registered path's dialog had no accessible description while
241
+ the identical built-in one did — and `form.fullscreen.description`
242
+ was a key with no consumer here. (The missing-Description console
243
+ warning older Radix versions emitted is NOT why: @radix-ui/react-dialog
244
+ 1.1.23 contains no `console.*` call at all, verified before adding this.)
245
+ */}
246
+ <DialogDescription className="sr-only">
247
+ {t('form.fullscreen.description')}
248
+ </DialogDescription>
249
+ </DialogHeader>
250
+ <div className="flex-1 min-h-0 p-4">{children(draft, setDraft, disabled)}</div>
251
+ <DialogFooter className="p-3 border-t flex-row justify-between sm:justify-end gap-2">
252
+ {footer?.(draft)}
253
+ <div className="flex gap-2 ml-auto">
254
+ <Button type="button" variant="ghost" onClick={cancelFullscreen}>
255
+ <X className="size-4 mr-1" /> {t('common.cancel')}
256
+ </Button>
257
+ <Button
258
+ type="button"
259
+ onClick={commitFullscreen}
260
+ disabled={disabled}
261
+ data-testid={`${testIdPrefix}-fullscreen-save`}
262
+ >
263
+ <Check className="size-4 mr-1" /> {t('form.fullscreen.done')}
264
+ </Button>
265
+ </div>
266
+ </DialogFooter>
267
+ </DialogContent>
268
+ </Dialog>
269
+ </>
270
+ );
271
+ }
@@ -1,7 +1,8 @@
1
- import React from 'react';
1
+ import React, { useId } from 'react';
2
2
  import { Input, Button, Label, EmptyValue } from '@object-ui/components';
3
3
  import { MapPin, Crosshair } from 'lucide-react';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
5
6
 
6
7
  /**
7
8
  * Geolocation data structure
@@ -16,9 +17,20 @@ export interface GeolocationValue {
16
17
  * Geolocation field widget - provides a location picker with coordinates
17
18
  * Supports manual entry and browser geolocation API
18
19
  */
19
- export function GeolocationField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<GeolocationValue>) {
20
+ export function GeolocationField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<GeolocationValue>) {
20
21
  const [isLoading, setIsLoading] = React.useState(false);
21
22
  const location = value || {};
23
+ // DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
24
+ // sub-input (latitude); the composite's validation state goes onto BOTH
25
+ // focusable sub-inputs via `aria-invalid={!!error}`.
26
+ const domProps = toDomProps(props);
27
+ // Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
28
+ // `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
29
+ // ("latitude" / "longitude") collide as soon as a form renders two
30
+ // geolocation fields, and every label's htmlFor then resolves to the
31
+ // FIRST match.
32
+ const groupId = useId();
33
+ const subId = (name: keyof GeolocationValue) => `${groupId}-${name}`;
22
34
 
23
35
  const handleFieldChange = (fieldName: keyof GeolocationValue, fieldValue: string) => {
24
36
  onChange({
@@ -115,9 +127,10 @@ export function GeolocationField({ value, onChange, field, readonly, ...props }:
115
127
 
116
128
  <div className="grid grid-cols-2 gap-3">
117
129
  <div>
118
- <Label htmlFor="latitude" className="text-xs">Latitude</Label>
130
+ <Label htmlFor={subId('latitude')} className="text-xs">Latitude</Label>
119
131
  <Input
120
- id="latitude"
132
+ {...domProps}
133
+ id={subId('latitude')}
121
134
  type="number"
122
135
  value={location.latitude ?? ''}
123
136
  onChange={(e) => handleFieldChange('latitude', e.target.value)}
@@ -125,19 +138,21 @@ export function GeolocationField({ value, onChange, field, readonly, ...props }:
125
138
  disabled={readonly || props.disabled}
126
139
  step="any"
127
140
  className={props.className}
141
+ aria-invalid={!!error}
128
142
  />
129
143
  </div>
130
144
 
131
145
  <div>
132
- <Label htmlFor="longitude" className="text-xs">Longitude</Label>
146
+ <Label htmlFor={subId('longitude')} className="text-xs">Longitude</Label>
133
147
  <Input
134
- id="longitude"
148
+ id={subId('longitude')}
135
149
  type="number"
136
150
  value={location.longitude ?? ''}
137
151
  onChange={(e) => handleFieldChange('longitude', e.target.value)}
138
152
  placeholder="-122.4194"
139
153
  disabled={readonly || props.disabled}
140
154
  step="any"
155
+ aria-invalid={!!error}
141
156
  />
142
157
  </div>
143
158
  </div>
@@ -0,0 +1,88 @@
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
+ /**
10
+ * Unique sub-input ids for the composite `geolocation` widget
11
+ * (objectui#3343).
12
+ *
13
+ * The widget used to hardcode literal ids ("latitude" / "longitude") on its
14
+ * sub-inputs. Two geolocation fields in one form — e.g. origin + destination
15
+ * — then produced duplicate DOM ids, and every `Label htmlFor` resolved to
16
+ * the FIRST match in the document: each sub-label of the second field
17
+ * clicked/announced the first field's input.
18
+ *
19
+ * The fix follows the `groupId` paradigm of RadioField / CheckboxesField:
20
+ * a `useId()` prefix + the sub-field name. These tests pin it by rendering
21
+ * TWO instances in one form:
22
+ *
23
+ * 1. every `[id]` in the document is globally unique (red again if anyone
24
+ * reverts to hardcoded literals — both instances would emit
25
+ * id="latitude");
26
+ * 2. each sub-label of the SECOND instance resolves — via the exact
27
+ * mechanism browsers use for label activation, `for` → getElementById —
28
+ * to an input inside its OWN instance, and focusing it lands there.
29
+ */
30
+ import { describe, it, expect, vi } from 'vitest';
31
+ import React from 'react';
32
+ import { render, within } from '@testing-library/react';
33
+ import '@testing-library/jest-dom';
34
+ import { GeolocationField } from './GeolocationField';
35
+
36
+ const field = { name: 'location', type: 'geolocation' } as any;
37
+
38
+ const SUB_LABELS = ['Latitude', 'Longitude'];
39
+
40
+ function renderTwoInOneForm() {
41
+ return render(
42
+ <form>
43
+ <div data-testid="geo-a">
44
+ <GeolocationField value={{}} onChange={vi.fn()} field={field} />
45
+ </div>
46
+ <div data-testid="geo-b">
47
+ <GeolocationField value={{}} onChange={vi.fn()} field={field} />
48
+ </div>
49
+ </form>,
50
+ );
51
+ }
52
+
53
+ describe('GeolocationField — unique sub-input ids (objectui#3343)', () => {
54
+ it('two geolocation fields in one form produce globally unique DOM ids', () => {
55
+ const { baseElement } = renderTwoInOneForm();
56
+ const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
57
+ // 2 sub-inputs per instance — both instances must actually render ids.
58
+ expect(ids.length).toBeGreaterThanOrEqual(4);
59
+ const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
60
+ expect(duplicates).toEqual([]);
61
+ });
62
+
63
+ it("each sub-label of the SECOND field focuses that field's own input", () => {
64
+ const { getByTestId } = renderTwoInOneForm();
65
+ const first = getByTestId('geo-a');
66
+ const second = getByTestId('geo-b');
67
+
68
+ for (const text of SUB_LABELS) {
69
+ const label = within(second).getByText(text);
70
+ const forId = label.getAttribute('for');
71
+ expect(forId, `label "${text}" must carry htmlFor`).toBeTruthy();
72
+
73
+ // Exactly what the browser does on label click / SR announcement:
74
+ // resolve `for` against the document.
75
+ const control = document.getElementById(forId!);
76
+ expect(control, `htmlFor of "${text}" must resolve`).not.toBeNull();
77
+ expect(control!.tagName).toBe('INPUT');
78
+ expect(
79
+ second.contains(control),
80
+ `"${text}" must resolve into its OWN field, not the first one`,
81
+ ).toBe(true);
82
+ expect(first.contains(control)).toBe(false);
83
+
84
+ (control as HTMLInputElement).focus();
85
+ expect(control).toHaveFocus();
86
+ }
87
+ });
88
+ });
@@ -324,7 +324,7 @@ export function GridField({
324
324
  * the header (`parent.status == 'paid'`). Supplied by MasterDetailForm. */
325
325
  contextRecord?: Record<string, unknown>;
326
326
  }) {
327
- const cfg = (field || (props as any).schema || {}) as any;
327
+ const cfg = (field || {}) as any;
328
328
  const allColumns: GridColumn[] = cfg.columns || [];
329
329
  const rows: Row[] = Array.isArray(value) ? value : [];
330
330
  const contextRecord = props.contextRecord;
@@ -4,6 +4,7 @@ import { useUpload } from '@object-ui/providers';
4
4
  import { useObjectTranslation } from '@object-ui/i18n';
5
5
  import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
6
6
  import { FieldWidgetComponentProps } from './types';
7
+ import { toDomProps } from './toDomProps';
7
8
  import { ImageLightbox } from './ImageLightbox';
8
9
  import { useUploadingSignal } from './useUploadingSignal';
9
10
  import {
@@ -24,9 +25,9 @@ const ImageCropperDialog = lazy(() =>
24
25
  * ImageField - Image upload widget with preview thumbnails
25
26
  * Supports single and multiple image uploads with drag-and-drop and preview display
26
27
  */
27
- export function ImageField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetComponentProps<any>) {
28
+ export function ImageField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
28
29
  const inputRef = useRef<HTMLInputElement>(null);
29
- const imageField = (field || (props as any).schema) as any;
30
+ const imageField = field as any;
30
31
  const multiple = imageField?.multiple || false;
31
32
  const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
32
33
  /**
@@ -212,12 +213,18 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
212
213
  )}
213
214
 
214
215
  <Button
216
+ // DOM pass-through onto the widget's real focusable control — the
217
+ // upload button is the keyboard path to the hidden file input
218
+ // (objectui#3318).
219
+ {...toDomProps(props)}
215
220
  type="button"
216
221
  variant="outline"
217
222
  onClick={() => inputRef.current?.click()}
218
223
  className="w-full"
219
224
  disabled={uploading}
220
225
  data-testid="image-field-upload-button"
226
+ // AFTER the spread so this widget's own computation wins (#3222).
227
+ aria-invalid={!!error}
221
228
  >
222
229
  {uploading ? (
223
230
  <Loader2 className="size-4 mr-2 animate-spin" />
@@ -1,13 +1,14 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * LocationField - Geographic coordinate input for latitude and longitude
7
8
  * Stores location as { latitude, longitude } object and displays as comma-separated pair
8
9
  */
9
- export function LocationField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
10
- const config = field || (props as any).schema;
10
+ export function LocationField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
11
+ const config = field;
11
12
  // Location is stored as { latitude, longitude } object
12
13
  // For display, convert to "latitude, longitude" string format
13
14
  const displayValue = value && typeof value === 'object'
@@ -39,12 +40,18 @@ export function LocationField({ value, onChange, field, readonly, ...props }: Fi
39
40
 
40
41
  return (
41
42
  <Input
43
+ // DOM pass-through onto the real focusable control (objectui#3318).
44
+ {...toDomProps(props)}
42
45
  type="text"
43
46
  value={displayValue}
44
47
  onChange={handleChange}
45
48
  placeholder={config?.placeholder || 'latitude, longitude'}
46
49
  disabled={readonly || props.disabled}
47
50
  className={props.className}
51
+ // AFTER the spread so this widget's own computation wins: `error` is
52
+ // the published validation slot (#3222), `!!undefined` → explicit
53
+ // "false".
54
+ aria-invalid={!!error}
48
55
  />
49
56
  );
50
57
  }
@@ -0,0 +1,135 @@
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
+ /**
10
+ * A gated lookup names its controlling field by LABEL — objectstack#5407.
11
+ *
12
+ * The gate sentence (`lookup.selectFirst`) is translated in all ten packs, but
13
+ * its `{{fields}}` interpolation came straight off `depends_on`, which holds
14
+ * API names. So the rendered hint was localized around a raw identifier in
15
+ * every locale, English included:
16
+ *
17
+ * en Select crm_account first
18
+ * zh-CN 请先选择crm_account
19
+ *
20
+ * The `en` row is why this is not merely an i18n bug: an English user was shown
21
+ * an internal identifier. The form renderer knows the label (it already
22
+ * resolves the same map for the fixed-option widgets' `emptyHint`) and now
23
+ * hands it over as `dependsOnLabels`.
24
+ *
25
+ * All three gated surfaces are asserted — the trigger's visible text, the
26
+ * trigger's `title`, and the "browse all" button's `title` — because they were
27
+ * three independent copies of the same expression and a fix applied to one of
28
+ * them looks identical from the outside.
29
+ */
30
+
31
+ import { render, screen, cleanup } from '@testing-library/react';
32
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
33
+ import { LookupField } from './LookupField';
34
+
35
+ /** Mirrors the reported metadata: the parent field's API name IS an object-ish
36
+ * name (`crm_account`), which is exactly what made the leak so legible. */
37
+ const gatedField = {
38
+ name: 'contact',
39
+ label: 'Contact',
40
+ reference_to: 'crm_contact',
41
+ reference_field: 'name',
42
+ depends_on: ['crm_account'],
43
+ } as any;
44
+
45
+ const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any;
46
+
47
+ function renderGated(extra: Record<string, unknown>) {
48
+ return render(
49
+ <LookupField
50
+ field={gatedField}
51
+ value={undefined}
52
+ onChange={vi.fn()}
53
+ readonly={false}
54
+ dataSource={dataSource}
55
+ {...({ dependentValues: { crm_account: null }, ...extra } as any)}
56
+ />,
57
+ );
58
+ }
59
+
60
+ beforeEach(() => {
61
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
62
+ window.matchMedia = ((query: string) => ({
63
+ matches: false,
64
+ media: query,
65
+ onchange: null,
66
+ addEventListener: () => {},
67
+ removeEventListener: () => {},
68
+ addListener: () => {},
69
+ removeListener: () => {},
70
+ dispatchEvent: () => false,
71
+ })) as any;
72
+ });
73
+
74
+ afterEach(() => cleanup());
75
+
76
+ describe('LookupField gate hint names the controlling field (objectstack#5407)', () => {
77
+ it('interpolates the label the host supplied, not the API name', () => {
78
+ renderGated({ dependsOnLabels: { crm_account: 'Account' } });
79
+
80
+ const trigger = screen.getByTestId('lookup-trigger-gated');
81
+ expect(trigger).toHaveTextContent('Select Account first');
82
+ // The identifier must be gone from the copy entirely, not merely
83
+ // accompanied by the label.
84
+ expect(trigger).not.toHaveTextContent('crm_account');
85
+ expect(trigger).toHaveAttribute('title', 'Select Account first');
86
+ });
87
+
88
+ it('labels the gated "browse all" button the same way', () => {
89
+ renderGated({ dependsOnLabels: { crm_account: 'Account' } });
90
+
91
+ const browse = screen.getByTestId('browse-all-records');
92
+ expect(browse).toBeDisabled();
93
+ expect(browse).toHaveAttribute('title', 'Select Account first');
94
+ });
95
+
96
+ it('joins several controlling fields by their labels', () => {
97
+ render(
98
+ <LookupField
99
+ field={{ ...gatedField, depends_on: ['crm_account', 'lead_source'] }}
100
+ value={undefined}
101
+ onChange={vi.fn()}
102
+ readonly={false}
103
+ dataSource={dataSource}
104
+ {...({
105
+ dependentValues: { crm_account: null, lead_source: null },
106
+ dependsOnLabels: { crm_account: 'Account', lead_source: 'Lead Source' },
107
+ } as any)}
108
+ />,
109
+ );
110
+
111
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
112
+ 'Select Account, Lead Source first',
113
+ );
114
+ });
115
+
116
+ it('falls back to the API name for a field the host did not map', () => {
117
+ // Standalone use (no host form) has to keep rendering what it rendered
118
+ // before — the map is an enrichment, not a new requirement. This is also
119
+ // the assertion that would go red if `dependsOnLabels` were consulted for
120
+ // its VALUES rather than keyed by field name.
121
+ renderGated({ dependsOnLabels: { some_other_field: 'Irrelevant' } });
122
+
123
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
124
+ 'Select crm_account first',
125
+ );
126
+ });
127
+
128
+ it('falls back to the API name when the host passes no map at all', () => {
129
+ renderGated({});
130
+
131
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
132
+ 'Select crm_account first',
133
+ );
134
+ });
135
+ });