@object-ui/fields 17.1.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 +786 -0
- package/dist/FieldEditWidget.d.ts +2 -2
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2882 -2569
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/AutoNumberField.d.ts +2 -2
- package/dist/widgets/AvatarField.d.ts +2 -2
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +2 -2
- package/dist/widgets/CodeField.d.ts +2 -2
- package/dist/widgets/ColorField.d.ts +2 -2
- package/dist/widgets/CurrencyField.d.ts +2 -2
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +2 -2
- package/dist/widgets/FileField.d.ts +2 -2
- package/dist/widgets/FilterConditionField.d.ts +2 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
- package/dist/widgets/MasterDetailField.d.ts +2 -2
- package/dist/widgets/MultiSelectField.d.ts +2 -2
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +2 -2
- package/dist/widgets/ObjectRefField.d.ts +2 -2
- package/dist/widgets/OptionsEmptyState.d.ts +45 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PasswordField.d.ts +2 -2
- package/dist/widgets/PercentField.d.ts +2 -2
- package/dist/widgets/PhoneField.d.ts +2 -2
- package/dist/widgets/QRCodeField.d.ts +2 -2
- package/dist/widgets/RadioField.d.ts +2 -2
- package/dist/widgets/RatingField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.d.ts +2 -2
- package/dist/widgets/RecordPickerDialog.d.ts +17 -1
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
- package/dist/widgets/RichTextField.d.ts +20 -2
- package/dist/widgets/SelectField.d.ts +2 -2
- package/dist/widgets/SignatureField.d.ts +2 -2
- package/dist/widgets/SliderField.d.ts +2 -2
- package/dist/widgets/SummaryField.d.ts +2 -2
- package/dist/widgets/TagsField.d.ts +2 -2
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/TextAreaField.d.ts +45 -6
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +2 -2
- package/dist/widgets/UserField.d.ts +2 -2
- package/dist/widgets/VectorField.d.ts +2 -2
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/file-value.d.ts +20 -33
- package/dist/widgets/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +215 -4
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- package/src/FieldEditWidget.tsx +6 -4
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
- package/src/__tests__/widget-props-contract.test.tsx +142 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/index.tsx +77 -27
- package/src/widgets/AddressField.tsx +32 -13
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +10 -3
- package/src/widgets/BooleanField.tsx +5 -5
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +35 -18
- package/src/widgets/CodeField.tsx +10 -3
- package/src/widgets/ColorField.tsx +12 -3
- package/src/widgets/CurrencyField.tsx +6 -5
- package/src/widgets/DateField.tsx +4 -4
- package/src/widgets/DateTimeField.tsx +4 -4
- package/src/widgets/EmailField.tsx +6 -6
- package/src/widgets/FileField.tsx +14 -4
- package/src/widgets/FilterConditionField.tsx +14 -3
- package/src/widgets/FormulaField.tsx +3 -3
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +22 -7
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +4 -4
- package/src/widgets/ImageField.tsx +10 -3
- package/src/widgets/LocationField.tsx +10 -3
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +50 -15
- package/src/widgets/MasterDetailField.tsx +3 -3
- package/src/widgets/MultiSelectField.tsx +34 -18
- package/src/widgets/NumberField.tsx +5 -5
- package/src/widgets/ObjectField.tsx +18 -9
- package/src/widgets/ObjectRefField.tsx +14 -4
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +83 -0
- package/src/widgets/PasswordField.tsx +5 -5
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +6 -5
- package/src/widgets/PhoneField.tsx +6 -6
- package/src/widgets/QRCodeField.tsx +10 -3
- package/src/widgets/RadioField.tsx +28 -18
- package/src/widgets/RatingField.tsx +19 -4
- package/src/widgets/RecipientPickerField.tsx +22 -4
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +173 -8
- package/src/widgets/RichTextField.tsx +152 -25
- package/src/widgets/SelectField.tsx +53 -22
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +3 -3
- package/src/widgets/SummaryField.tsx +3 -3
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +25 -4
- package/src/widgets/TextAreaField.tsx +172 -94
- package/src/widgets/TextField.tsx +5 -5
- package/src/widgets/TimeField.tsx +4 -4
- package/src/widgets/UrlField.tsx +6 -6
- package/src/widgets/UserField.tsx +3 -3
- package/src/widgets/VectorField.tsx +3 -3
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/file-value.ts +21 -5
- package/src/widgets/toDomProps.ts +175 -0
- package/src/widgets/types.ts +230 -7
- package/src/widgets/useFieldTranslation.ts +60 -0
- package/src/withFieldCarrier.tsx +75 -0
|
@@ -3,7 +3,8 @@ import { Button, EmptyValue } from '@object-ui/components';
|
|
|
3
3
|
import { useUpload } from '@object-ui/providers';
|
|
4
4
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
5
5
|
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
|
|
6
|
-
import {
|
|
6
|
+
import { FieldWidgetComponentProps } from './types';
|
|
7
|
+
import { toDomProps } from './toDomProps';
|
|
7
8
|
import { useUploadingSignal } from './useUploadingSignal';
|
|
8
9
|
import {
|
|
9
10
|
fileValueForSubmit,
|
|
@@ -115,11 +116,11 @@ function useFileUploads(opts: {
|
|
|
115
116
|
* Supports single and multiple file uploads with configurable accepted file types.
|
|
116
117
|
* L2: File size validation, per-file progress indicators, error messages.
|
|
117
118
|
*/
|
|
118
|
-
export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }:
|
|
119
|
+
export function FileField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
119
120
|
const { t } = useObjectTranslation();
|
|
120
121
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
121
122
|
const cameraRef = useRef<HTMLInputElement>(null);
|
|
122
|
-
const fileField =
|
|
123
|
+
const fileField = field as any;
|
|
123
124
|
const multiple = fileField?.multiple || false;
|
|
124
125
|
const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
|
|
125
126
|
const maxSize = fileField?.maxSize as number | undefined; // bytes
|
|
@@ -209,6 +210,8 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
|
|
|
209
210
|
}
|
|
210
211
|
};
|
|
211
212
|
|
|
213
|
+
const { name: _domName, ...dropzoneDomProps } = toDomProps(props);
|
|
214
|
+
|
|
212
215
|
return (
|
|
213
216
|
<div className={props.className}>
|
|
214
217
|
<input
|
|
@@ -233,8 +236,13 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
|
|
|
233
236
|
)}
|
|
234
237
|
|
|
235
238
|
<div className="space-y-2">
|
|
236
|
-
{/* Drag-and-drop zone
|
|
239
|
+
{/* Drag-and-drop zone — the widget's real focusable control (it is the
|
|
240
|
+
keyboard path to the hidden file input), so the DOM pass-through
|
|
241
|
+
and the validation state land here (objectui#3318). `name` is
|
|
242
|
+
withheld: only DOM-legal on form controls, and on this div it is
|
|
243
|
+
exactly the leak #3291 sweeps for. */}
|
|
237
244
|
<div
|
|
245
|
+
{...dropzoneDomProps}
|
|
238
246
|
onDragOver={handleDragOver}
|
|
239
247
|
onDragLeave={handleDragLeave}
|
|
240
248
|
onDrop={handleDrop}
|
|
@@ -255,6 +263,8 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
|
|
|
255
263
|
inputRef.current?.click();
|
|
256
264
|
}
|
|
257
265
|
}}
|
|
266
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
267
|
+
aria-invalid={!!error}
|
|
258
268
|
>
|
|
259
269
|
<Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
|
|
260
270
|
<div className="text-center">
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { FilterBuilder, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -297,11 +298,12 @@ export function FilterConditionField({
|
|
|
297
298
|
onChange,
|
|
298
299
|
readonly,
|
|
299
300
|
className,
|
|
301
|
+
error,
|
|
300
302
|
...props
|
|
301
|
-
}:
|
|
303
|
+
}: FieldWidgetComponentProps<string | object>) {
|
|
302
304
|
const ctx = React.useContext(SchemaRendererContext);
|
|
303
305
|
const { t } = useFieldTranslation();
|
|
304
|
-
const dataSource: any =
|
|
306
|
+
const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
305
307
|
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
|
|
306
308
|
const objectName = String(dependentValues.object_name ?? '');
|
|
307
309
|
|
|
@@ -397,10 +399,19 @@ export function FilterConditionField({
|
|
|
397
399
|
{rawMode ? (
|
|
398
400
|
<>
|
|
399
401
|
<textarea
|
|
402
|
+
// DOM pass-through onto the raw-JSON editing surface
|
|
403
|
+
// (objectui#3318). NOTE this widget stays on the #3318 ledger
|
|
404
|
+
// regardless: its dependency-gated state (no `object_name` chosen
|
|
405
|
+
// yet — the state a fresh form and the registry sweep render) is a
|
|
406
|
+
// plain hint paragraph with no focusable control.
|
|
407
|
+
{...toDomProps(props)}
|
|
400
408
|
className="min-h-[96px] w-full rounded border bg-background px-2 py-1 font-mono text-xs"
|
|
401
409
|
value={rawValue}
|
|
402
410
|
placeholder='{ "type": "customer", "is_active": true }'
|
|
403
411
|
onChange={(e) => onChange(e.target.value as any)}
|
|
412
|
+
// The form's validation slot (#3222) OR this widget's own
|
|
413
|
+
// unparsable-JSON state, which already renders its red message.
|
|
414
|
+
aria-invalid={!!error || !parsed.ok}
|
|
404
415
|
/>
|
|
405
416
|
{!parsed.ok && (
|
|
406
417
|
<span className="text-xs text-destructive">{t('fields.filterCondition.invalidJson')}</span>
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* FormulaField - Read-only computed field
|
|
7
7
|
* Values are computed on the backend and cannot be edited
|
|
8
8
|
*/
|
|
9
|
-
export function FormulaField({ value, field, ...props }:
|
|
10
|
-
const formulaField =
|
|
9
|
+
export function FormulaField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
|
|
10
|
+
const formulaField = field as any;
|
|
11
11
|
const returnType = formulaField?.return_type || 'text';
|
|
12
12
|
|
|
13
13
|
if (value == null) {
|
|
@@ -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
|
-
import {
|
|
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 }:
|
|
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=
|
|
130
|
+
<Label htmlFor={subId('latitude')} className="text-xs">Latitude</Label>
|
|
119
131
|
<Input
|
|
120
|
-
|
|
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=
|
|
146
|
+
<Label htmlFor={subId('longitude')} className="text-xs">Longitude</Label>
|
|
133
147
|
<Input
|
|
134
|
-
id=
|
|
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
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useCallback, useRef } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { FieldWidgetComponentProps } from './types';
|
|
3
3
|
import {
|
|
4
4
|
cn,
|
|
5
5
|
Button,
|
|
@@ -306,7 +306,7 @@ export function GridField({
|
|
|
306
306
|
displayMode,
|
|
307
307
|
onAdd,
|
|
308
308
|
...props
|
|
309
|
-
}:
|
|
309
|
+
}: FieldWidgetComponentProps<Row[]> & {
|
|
310
310
|
/** When provided, each row shows an "expand" button that opens the row in a
|
|
311
311
|
* full form (the host — e.g. MasterDetailForm — renders the drawer/modal and
|
|
312
312
|
* writes the edited values back). Lets a "fat" child be edited in a real form
|
|
@@ -324,10 +324,10 @@ 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 ||
|
|
327
|
+
const cfg = (field || {}) as any;
|
|
328
328
|
const allColumns: GridColumn[] = cfg.columns || [];
|
|
329
329
|
const rows: Row[] = Array.isArray(value) ? value : [];
|
|
330
|
-
const contextRecord =
|
|
330
|
+
const contextRecord = props.contextRecord;
|
|
331
331
|
|
|
332
332
|
// Per-cell CEL rule state (B2 in grids). A column with no readonlyWhen/
|
|
333
333
|
// requiredWhen resolves to its static flags (cheap fast-path — no engine
|
|
@@ -3,7 +3,8 @@ import { Button, EmptyValue } from '@object-ui/components';
|
|
|
3
3
|
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
|
-
import {
|
|
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 }:
|
|
28
|
+
export function ImageField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
28
29
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
29
|
-
const imageField =
|
|
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
|
-
import {
|
|
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 }:
|
|
10
|
-
const config = field
|
|
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
|
}
|