@object-ui/fields 17.5.0 → 17.7.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 +6150 -0
- package/README.md +108 -8
- package/dist/FieldEditWidget.d.ts +70 -8
- package/dist/coerceToSafeValue.d.ts +54 -0
- package/dist/currency.d.ts +1 -1
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.css +53 -11
- package/dist/index.d.ts +653 -128
- package/dist/index.js +4275 -3171
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +20 -4
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +43 -2
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +10 -4
- package/dist/widgets/GridField.d.ts +102 -86
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +31 -4
- package/dist/widgets/LookupField.d.ts +9 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +10 -3
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +38 -8
- package/dist/widgets/PasswordField.d.ts +15 -1
- package/dist/widgets/PeoplePicker.d.ts +17 -2
- package/dist/widgets/PercentField.d.ts +3 -2
- package/dist/widgets/PersonRow.d.ts +13 -2
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +44 -1
- package/dist/widgets/RecordPickerDialog.d.ts +33 -16
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SelectionTray.d.ts +13 -2
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/file-affordance.d.ts +33 -0
- package/dist/widgets/file-size-guard.d.ts +5 -23
- package/dist/widgets/file-value.d.ts +38 -9
- package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
- package/dist/widgets/nativeDateValue.d.ts +9 -27
- package/dist/widgets/number-format.d.ts +137 -0
- package/dist/widgets/numberBadInput.d.ts +135 -0
- package/dist/widgets/personDisplay.d.ts +22 -4
- package/dist/widgets/richTextDisplay.d.ts +116 -0
- package/dist/widgets/toDomProps.d.ts +10 -1
- package/dist/widgets/toHostGroupProps.d.ts +172 -21
- package/dist/widgets/toHostProps.d.ts +105 -0
- package/dist/widgets/types.d.ts +19 -6
- package/dist/widgets/upload-error-message.d.ts +6 -0
- package/dist/widgets/uploadingScope.d.ts +142 -0
- package/dist/widgets/useCascadingOptions.d.ts +3 -2
- package/dist/widgets/useRecordQuery.d.ts +4 -2
- package/dist/widgets/useUploadingSignal.d.ts +8 -0
- package/package.json +11 -13
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import React, { useRef, useEffect } from 'react';
|
|
2
|
-
import { Button } from '@object-ui/components';
|
|
3
|
-
import { Eraser } from 'lucide-react';
|
|
4
|
-
import { FieldWidgetComponentProps } from './types';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Signature field widget - provides a signature pad for capturing signatures
|
|
8
|
-
* Outputs signature as a base64 PNG image
|
|
9
|
-
*/
|
|
10
|
-
export function SignatureField({ value, onChange, readonly }: FieldWidgetComponentProps<string>) {
|
|
11
|
-
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
12
|
-
const [isDrawing, setIsDrawing] = React.useState(false);
|
|
13
|
-
const [isEmpty, setIsEmpty] = React.useState(!value);
|
|
14
|
-
|
|
15
|
-
useEffect(() => {
|
|
16
|
-
if (value && canvasRef.current) {
|
|
17
|
-
const canvas = canvasRef.current;
|
|
18
|
-
const ctx = canvas.getContext('2d');
|
|
19
|
-
if (ctx) {
|
|
20
|
-
const img = new Image();
|
|
21
|
-
img.onload = () => {
|
|
22
|
-
ctx.drawImage(img, 0, 0);
|
|
23
|
-
setIsEmpty(false);
|
|
24
|
-
};
|
|
25
|
-
img.src = value;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}, [value]);
|
|
29
|
-
|
|
30
|
-
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
|
31
|
-
if (readonly) return;
|
|
32
|
-
setIsDrawing(true);
|
|
33
|
-
setIsEmpty(false);
|
|
34
|
-
|
|
35
|
-
const canvas = canvasRef.current;
|
|
36
|
-
if (!canvas) return;
|
|
37
|
-
|
|
38
|
-
const ctx = canvas.getContext('2d');
|
|
39
|
-
if (!ctx) return;
|
|
40
|
-
|
|
41
|
-
const rect = canvas.getBoundingClientRect();
|
|
42
|
-
const x = 'touches' in e ? e.touches[0].clientX - rect.left : e.clientX - rect.left;
|
|
43
|
-
const y = 'touches' in e ? e.touches[0].clientY - rect.top : e.clientY - rect.top;
|
|
44
|
-
|
|
45
|
-
ctx.beginPath();
|
|
46
|
-
ctx.moveTo(x, y);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const draw = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
|
50
|
-
if (!isDrawing || readonly) return;
|
|
51
|
-
|
|
52
|
-
const canvas = canvasRef.current;
|
|
53
|
-
if (!canvas) return;
|
|
54
|
-
|
|
55
|
-
const ctx = canvas.getContext('2d');
|
|
56
|
-
if (!ctx) return;
|
|
57
|
-
|
|
58
|
-
const rect = canvas.getBoundingClientRect();
|
|
59
|
-
const x = 'touches' in e ? e.touches[0].clientX - rect.left : e.clientX - rect.left;
|
|
60
|
-
const y = 'touches' in e ? e.touches[0].clientY - rect.top : e.clientY - rect.top;
|
|
61
|
-
|
|
62
|
-
ctx.lineTo(x, y);
|
|
63
|
-
ctx.strokeStyle = '#000';
|
|
64
|
-
ctx.lineWidth = 2;
|
|
65
|
-
ctx.lineCap = 'round';
|
|
66
|
-
ctx.lineJoin = 'round';
|
|
67
|
-
ctx.stroke();
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const stopDrawing = () => {
|
|
71
|
-
if (!isDrawing) return;
|
|
72
|
-
setIsDrawing(false);
|
|
73
|
-
|
|
74
|
-
const canvas = canvasRef.current;
|
|
75
|
-
if (!canvas) return;
|
|
76
|
-
|
|
77
|
-
// Save signature as base64 PNG
|
|
78
|
-
const dataUrl = canvas.toDataURL('image/png');
|
|
79
|
-
onChange(dataUrl);
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
const clearSignature = () => {
|
|
83
|
-
const canvas = canvasRef.current;
|
|
84
|
-
if (!canvas) return;
|
|
85
|
-
|
|
86
|
-
const ctx = canvas.getContext('2d');
|
|
87
|
-
if (!ctx) return;
|
|
88
|
-
|
|
89
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
90
|
-
setIsEmpty(true);
|
|
91
|
-
onChange('');
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
if (readonly && value) {
|
|
95
|
-
return (
|
|
96
|
-
<div className="border rounded p-2 bg-white">
|
|
97
|
-
<img src={value} alt="Signature" loading="lazy" className="max-w-full h-auto" />
|
|
98
|
-
</div>
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (readonly && !value) {
|
|
103
|
-
return <span className="text-sm text-muted-foreground">No signature</span>;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
<div className="space-y-2">
|
|
108
|
-
<div className="border rounded bg-white">
|
|
109
|
-
<canvas
|
|
110
|
-
ref={canvasRef}
|
|
111
|
-
width={400}
|
|
112
|
-
height={200}
|
|
113
|
-
className="w-full touch-none cursor-crosshair"
|
|
114
|
-
onMouseDown={startDrawing}
|
|
115
|
-
onMouseMove={draw}
|
|
116
|
-
onMouseUp={stopDrawing}
|
|
117
|
-
onMouseLeave={stopDrawing}
|
|
118
|
-
onTouchStart={startDrawing}
|
|
119
|
-
onTouchMove={draw}
|
|
120
|
-
onTouchEnd={stopDrawing}
|
|
121
|
-
/>
|
|
122
|
-
</div>
|
|
123
|
-
<div className="flex justify-between items-center">
|
|
124
|
-
<p className="text-xs text-muted-foreground">
|
|
125
|
-
{isEmpty ? 'Sign above' : 'Signature captured'}
|
|
126
|
-
</p>
|
|
127
|
-
<Button
|
|
128
|
-
type="button"
|
|
129
|
-
variant="outline"
|
|
130
|
-
size="sm"
|
|
131
|
-
onClick={clearSignature}
|
|
132
|
-
disabled={readonly || isEmpty}
|
|
133
|
-
>
|
|
134
|
-
<Eraser className="w-4 h-4 mr-2" />
|
|
135
|
-
Clear
|
|
136
|
-
</Button>
|
|
137
|
-
</div>
|
|
138
|
-
</div>
|
|
139
|
-
);
|
|
140
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Slider } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Slider field widget - provides a range slider input
|
|
7
|
-
* Supports numeric values with configurable min, max, and step
|
|
8
|
-
*/
|
|
9
|
-
export function SliderField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
|
|
10
|
-
// Get slider-specific configuration from field metadata
|
|
11
|
-
const sliderField = field as any;
|
|
12
|
-
const min = sliderField?.min ?? 0;
|
|
13
|
-
const max = sliderField?.max ?? 100;
|
|
14
|
-
const step = sliderField?.step ?? 1;
|
|
15
|
-
|
|
16
|
-
if (readonly) {
|
|
17
|
-
return (
|
|
18
|
-
<div className="flex items-center gap-2">
|
|
19
|
-
<span className="text-sm font-medium">{value ?? min}</span>
|
|
20
|
-
<span className="text-xs text-muted-foreground">/ {max}</span>
|
|
21
|
-
</div>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<div className="flex items-center gap-4">
|
|
27
|
-
<Slider
|
|
28
|
-
value={[value ?? min]}
|
|
29
|
-
onValueChange={(values) => onChange(values[0])}
|
|
30
|
-
min={min}
|
|
31
|
-
max={max}
|
|
32
|
-
step={step}
|
|
33
|
-
disabled={readonly || props.disabled}
|
|
34
|
-
className={props.className}
|
|
35
|
-
/>
|
|
36
|
-
<span className="text-sm font-medium w-12 text-right">{value ?? min}</span>
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* SummaryField - Read-only aggregation field
|
|
7
|
-
* Values are aggregated from related records and cannot be edited
|
|
8
|
-
*/
|
|
9
|
-
export function SummaryField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
|
|
10
|
-
const summaryField = field as any;
|
|
11
|
-
const summaryType = summaryField?.summary_type || 'count';
|
|
12
|
-
|
|
13
|
-
if (value == null) {
|
|
14
|
-
return <EmptyValue className={props.className} />;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let displayValue: string;
|
|
18
|
-
if (summaryType === 'count') {
|
|
19
|
-
displayValue = String(value);
|
|
20
|
-
} else if (['sum', 'avg', 'min', 'max'].includes(summaryType)) {
|
|
21
|
-
displayValue = typeof value === 'number' ? value.toFixed(2) : String(value);
|
|
22
|
-
} else {
|
|
23
|
-
displayValue = String(value);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<span className={`text-sm font-medium tabular-nums text-gray-700 ${props.className || ''}`}>
|
|
28
|
-
{displayValue}
|
|
29
|
-
</span>
|
|
30
|
-
);
|
|
31
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
* The tags widget's placeholder with NO `I18nProvider` mounted
|
|
11
|
-
* (objectui#3342) — standalone/embedded usage, and every test in the repo
|
|
12
|
-
* that renders a field widget bare.
|
|
13
|
-
*
|
|
14
|
-
* Routing the copy through `useFieldTranslation()` must not turn it into a
|
|
15
|
-
* raw i18n key when nothing is configured: `createSafeTranslation` probes the
|
|
16
|
-
* instance and falls back to its English defaults map. This is also where the
|
|
17
|
-
* Commandment #-1 pin lives: the copy shipped FROM CODE (the no-provider
|
|
18
|
-
* default) carries no CJK — Chinese exists only in the zh locale pack and can
|
|
19
|
-
* only arrive through a provider.
|
|
20
|
-
*
|
|
21
|
-
* Why a separate FILE rather than another case in
|
|
22
|
-
* `TagsField.placeholder.test.tsx`: mounting `I18nProvider` calls
|
|
23
|
-
* `initReactI18next`, which installs that instance as react-i18next's GLOBAL
|
|
24
|
-
* default. Once any sibling test has done so there is no "no provider" state
|
|
25
|
-
* left to observe in that module graph. Vitest's per-file isolation is what
|
|
26
|
-
* makes this file's observation honest (same split as
|
|
27
|
-
* `OptionsEmptyState.no-provider.test.tsx`).
|
|
28
|
-
*/
|
|
29
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
30
|
-
import React from 'react';
|
|
31
|
-
import { render, screen } from '@testing-library/react';
|
|
32
|
-
import '@testing-library/jest-dom';
|
|
33
|
-
import { TagsField } from './TagsField';
|
|
34
|
-
|
|
35
|
-
function renderTags(field: Record<string, unknown>) {
|
|
36
|
-
return render(
|
|
37
|
-
<TagsField
|
|
38
|
-
value={[]}
|
|
39
|
-
onChange={vi.fn()}
|
|
40
|
-
field={field as any}
|
|
41
|
-
{...({ name: 'labels' } as any)}
|
|
42
|
-
/>,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const input = () => screen.getByRole('textbox');
|
|
47
|
-
|
|
48
|
-
describe('TagsField placeholder — English fallback survives with no i18n configured', () => {
|
|
49
|
-
it('renders the English default, not a raw key', () => {
|
|
50
|
-
renderTags({ name: 'labels', type: 'tags' });
|
|
51
|
-
expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…');
|
|
52
|
-
expect(input().getAttribute('placeholder')).not.toContain('fields.tags');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('the code-shipped default carries no CJK (Commandment #-1)', () => {
|
|
56
|
-
renderTags({ name: 'labels', type: 'tags' });
|
|
57
|
-
// With no provider mounted, whatever renders here came from CODE
|
|
58
|
-
// (FIELD_DEFAULTS) — the commandment pin: no Chinese in the codebase.
|
|
59
|
-
// Escaped ranges on purpose: a literal CJK class would itself violate
|
|
60
|
-
// the commandment this test enforces (same shape as objectui#3321's pin).
|
|
61
|
-
expect(input().getAttribute('placeholder')).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('the author-declared field.placeholder still wins without a provider', () => {
|
|
65
|
-
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' });
|
|
66
|
-
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
|
|
67
|
-
});
|
|
68
|
-
});
|
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
* The tags widget's input placeholder (objectui#3342).
|
|
11
|
-
*
|
|
12
|
-
* It used to be a hardcoded Chinese literal in code — a double violation:
|
|
13
|
-
* Commandment #-1 (English-only codebase) AND a bypass of the i18n channel the
|
|
14
|
-
* sibling widgets already use. The pinned resolution chain is:
|
|
15
|
-
*
|
|
16
|
-
* 1. `field.placeholder` — the field author's declaration always wins;
|
|
17
|
-
* 2. `t('fields.tags.placeholder')` — the widget's own copy, from the
|
|
18
|
-
* locale packs via `useFieldTranslation()`;
|
|
19
|
-
* 3. the English default in FIELD_DEFAULTS when no I18nProvider is mounted
|
|
20
|
-
* (that leg lives in `TagsField.placeholder.no-provider.test.tsx` — see
|
|
21
|
-
* its header for why it must be a separate file).
|
|
22
|
-
*
|
|
23
|
-
* Chinese now lives in the zh locale pack only, which is what the zh-locale
|
|
24
|
-
* case below observes end-to-end.
|
|
25
|
-
*/
|
|
26
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
27
|
-
import React from 'react';
|
|
28
|
-
import { render, screen } from '@testing-library/react';
|
|
29
|
-
import '@testing-library/jest-dom';
|
|
30
|
-
import { I18nProvider } from '@object-ui/i18n';
|
|
31
|
-
import { TagsField } from './TagsField';
|
|
32
|
-
|
|
33
|
-
function renderTags(
|
|
34
|
-
field: Record<string, unknown>,
|
|
35
|
-
{ value = [] as string[], language }: { value?: string[]; language?: string } = {},
|
|
36
|
-
) {
|
|
37
|
-
const element = (
|
|
38
|
-
<TagsField
|
|
39
|
-
value={value}
|
|
40
|
-
onChange={vi.fn()}
|
|
41
|
-
field={field as any}
|
|
42
|
-
{...({ name: 'labels' } as any)}
|
|
43
|
-
/>
|
|
44
|
-
);
|
|
45
|
-
return render(
|
|
46
|
-
language ? (
|
|
47
|
-
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
|
|
48
|
-
{element}
|
|
49
|
-
</I18nProvider>
|
|
50
|
-
) : (
|
|
51
|
-
element
|
|
52
|
-
),
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const input = () => screen.getByRole('textbox');
|
|
57
|
-
|
|
58
|
-
describe('TagsField placeholder — author declaration beats the widget copy (objectui#3342)', () => {
|
|
59
|
-
it('renders the author-declared field.placeholder', () => {
|
|
60
|
-
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'en' });
|
|
61
|
-
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('the author declaration wins even under a non-English locale', () => {
|
|
65
|
-
// The author's copy is theirs verbatim — a locale switch must not
|
|
66
|
-
// overwrite an explicit declaration with the widget's own translation.
|
|
67
|
-
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'zh' });
|
|
68
|
-
expect(input()).toHaveAttribute('placeholder', 'Add product labels');
|
|
69
|
-
expect(input().getAttribute('placeholder')).not.toContain('输入后回车添加');
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('TagsField placeholder — undeclared falls back to the translated key', () => {
|
|
74
|
-
it('renders the en pack copy under the en locale', () => {
|
|
75
|
-
renderTags({ name: 'labels', type: 'tags' }, { language: 'en' });
|
|
76
|
-
expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('renders the zh pack copy under the zh locale — Chinese comes from the pack, not code', () => {
|
|
80
|
-
renderTags({ name: 'labels', type: 'tags' }, { language: 'zh' });
|
|
81
|
-
expect(input()).toHaveAttribute('placeholder', '输入后回车添加…');
|
|
82
|
-
// …and never the raw key.
|
|
83
|
-
expect(input().getAttribute('placeholder')).not.toContain('fields.tags');
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe('TagsField placeholder — shown only while the tag list is empty', () => {
|
|
88
|
-
it('is empty once a tag exists (the input is a continuation strip)', () => {
|
|
89
|
-
renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, {
|
|
90
|
-
value: ['alpha'],
|
|
91
|
-
language: 'en',
|
|
92
|
-
});
|
|
93
|
-
expect(input()).toHaveAttribute('placeholder', '');
|
|
94
|
-
});
|
|
95
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Badge, Input, EmptyValue, cn } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
import { toDomProps } from './toDomProps';
|
|
5
|
-
import { useFieldTranslation } from './useFieldTranslation';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* TagsField - free-form list of string tags. Type a value and press Enter (or
|
|
9
|
-
* comma) to add it; click a tag's × to remove it. The stored value is a
|
|
10
|
-
* string[]. Used for the `tags` field type.
|
|
11
|
-
*/
|
|
12
|
-
export function TagsField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<string[]>) {
|
|
13
|
-
const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
14
|
-
const [draft, setDraft] = React.useState('');
|
|
15
|
-
// Unconditional hook call (rules-of-hooks): before the readonly early return.
|
|
16
|
-
const { t } = useFieldTranslation();
|
|
17
|
-
|
|
18
|
-
if (readonly) {
|
|
19
|
-
if (tags.length === 0) return <EmptyValue />;
|
|
20
|
-
return (
|
|
21
|
-
<div className="flex flex-wrap gap-1">
|
|
22
|
-
{tags.map((t) => <Badge key={t} variant="secondary">{t}</Badge>)}
|
|
23
|
-
</div>
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const addTag = (raw: string) => {
|
|
28
|
-
const t = raw.trim();
|
|
29
|
-
if (!t || tags.includes(t)) return;
|
|
30
|
-
onChange([...tags, t]);
|
|
31
|
-
setDraft('');
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const removeTag = (t: string) => onChange(tags.filter((x) => x !== t));
|
|
35
|
-
|
|
36
|
-
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
37
|
-
if (e.key === 'Enter' || e.key === ',') {
|
|
38
|
-
e.preventDefault();
|
|
39
|
-
addTag(draft);
|
|
40
|
-
} else if (e.key === 'Backspace' && draft === '' && tags.length > 0) {
|
|
41
|
-
removeTag(tags[tags.length - 1]);
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<div className={cn('flex flex-wrap items-center gap-1.5 rounded-md border border-input bg-background p-1.5', className)}>
|
|
47
|
-
{tags.map((t) => (
|
|
48
|
-
<Badge key={t} variant="secondary" className="gap-1">
|
|
49
|
-
{t}
|
|
50
|
-
<button
|
|
51
|
-
type="button"
|
|
52
|
-
onClick={() => removeTag(t)}
|
|
53
|
-
className="ml-0.5 rounded-full text-muted-foreground hover:text-foreground"
|
|
54
|
-
aria-label={`Remove ${t}`}
|
|
55
|
-
>
|
|
56
|
-
×
|
|
57
|
-
</button>
|
|
58
|
-
</Badge>
|
|
59
|
-
))}
|
|
60
|
-
<Input
|
|
61
|
-
// DOM pass-through onto the real focusable control (objectui#3318):
|
|
62
|
-
// the whitelist spread carries the form renderer's id /
|
|
63
|
-
// aria-describedby to the input the user actually types in.
|
|
64
|
-
{...toDomProps(props)}
|
|
65
|
-
value={draft}
|
|
66
|
-
onChange={(e) => setDraft(e.target.value)}
|
|
67
|
-
onKeyDown={onKeyDown}
|
|
68
|
-
onBlur={() => addTag(draft)}
|
|
69
|
-
disabled={props.disabled}
|
|
70
|
-
// Placeholder chain (objectui#3342): the author-declared
|
|
71
|
-
// `field.placeholder` wins; otherwise the widget's own copy arrives
|
|
72
|
-
// via `useFieldTranslation()` — `fields.tags.placeholder` in the
|
|
73
|
-
// locale packs, English default from FIELD_DEFAULTS when no
|
|
74
|
-
// I18nProvider is mounted. This used to be a hardcoded Chinese
|
|
75
|
-
// literal (Commandment #-1); Chinese now lives in the zh pack only.
|
|
76
|
-
// Shown only while the list is empty — once a tag exists the input is
|
|
77
|
-
// a small continuation strip and the hint would be noise.
|
|
78
|
-
placeholder={tags.length === 0 ? field?.placeholder || t('fields.tags.placeholder') : ''}
|
|
79
|
-
className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]"
|
|
80
|
-
// AFTER the spread so this widget's own computation wins (the #3222
|
|
81
|
-
// discipline): `error` is the published validation slot, and
|
|
82
|
-
// `!!undefined` yields an explicit "false" — a valid field SAYS it is
|
|
83
|
-
// valid rather than staying mute.
|
|
84
|
-
aria-invalid={!!error}
|
|
85
|
-
/>
|
|
86
|
-
</div>
|
|
87
|
-
);
|
|
88
|
-
}
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import React, { useId } from 'react';
|
|
2
|
-
import { Textarea, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { CharacterCount } from './CharacterCount';
|
|
4
|
-
import { FullscreenFieldEditor } from './FullscreenFieldEditor';
|
|
5
|
-
import { FieldWidgetComponentProps } from './types';
|
|
6
|
-
import { toDomProps } from './toDomProps';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* TextAreaField - Multi-line text input widget
|
|
10
|
-
* Supports configurable row count and preserves whitespace in readonly mode.
|
|
11
|
-
*
|
|
12
|
-
* Mobile UX (round 3): when the FIELD METADATA carries `mobile_fullscreen:
|
|
13
|
-
* true`, an "expand" affordance opens a fullscreen edit dialog — much easier
|
|
14
|
-
* on phones than tapping a 4-row textarea trapped between other fields.
|
|
15
|
-
*
|
|
16
|
-
* That flag has exactly one producer: `ObjectForm` stamps it onto every
|
|
17
|
-
* long-text field when `ObjectFormSchema.mobile.fullscreenLongText` is set
|
|
18
|
-
* (`plugin-form/src/ObjectForm.tsx`). It reaches this widget on `field` — the
|
|
19
|
-
* single metadata carrier since objectui#3233. A `SchemaRenderer`-hosted node
|
|
20
|
-
* arrives there too: the registry adapter (`withFieldCarrier`) maps the SDUI
|
|
21
|
-
* `schema` node onto `field` before the widget sees it.
|
|
22
|
-
*
|
|
23
|
-
* The affordance, the dialog and the draft/commit semantics live in the shared
|
|
24
|
-
* `FullscreenFieldEditor` — the same producer stamps the same flag on
|
|
25
|
-
* rich-text fields, and `RichTextField` renders it from there too
|
|
26
|
-
* (objectui#3301). Only the EDITOR differs per widget; here it is a
|
|
27
|
-
* full-height `Textarea`.
|
|
28
|
-
*
|
|
29
|
-
* There is deliberately NO widget-prop override. A `mobileFullscreen`
|
|
30
|
-
* (camelCase) prop was read here and written by nobody in the repo, and the
|
|
31
|
-
* snake_case `mobile_fullscreen` prop cannot arrive either: the form renderer
|
|
32
|
-
* strips both `mobile_fullscreen` and `fullscreen` from the props it forwards
|
|
33
|
-
* to registered widgets (`stripRegisteredFieldProps` in
|
|
34
|
-
* `components/src/renderers/form/form.tsx`). Reading keys nobody produces
|
|
35
|
-
* documented a contract that never held and invited the next author to pass a
|
|
36
|
-
* silently-ignored prop, so the reads are gone (objectui#3232). If a host
|
|
37
|
-
* override is ever genuinely needed, declare ONE key on
|
|
38
|
-
* `FieldWidgetComponentProps`, stop stripping it, and have a host pass it.
|
|
39
|
-
*
|
|
40
|
-
* ## The character counter (objectui#3408, shared across surfaces in #3417)
|
|
41
|
-
*
|
|
42
|
-
* Both editing surfaces below — the inline `<Textarea>` and the fullscreen
|
|
43
|
-
* dialog's — render the SAME `CharacterCount`, which owns the three-node
|
|
44
|
-
* GOV.UK shape (decorative digits / accessible description / gated status
|
|
45
|
-
* region) and the debounce and threshold behind it. Until #3417 the fullscreen
|
|
46
|
-
* footer had its own hand-written copy that was digits and nothing else, which
|
|
47
|
-
* is what a second copy of a UI always costs: it drifts, and nothing reports
|
|
48
|
-
* the drift.
|
|
49
|
-
*
|
|
50
|
-
* This widget's remaining job is the part only it can do — mint the two
|
|
51
|
-
* description ids and name each one in the `aria-describedby` of the control
|
|
52
|
-
* it describes. The surfaces differ in exactly one behaviour, and it is
|
|
53
|
-
* declared rather than implied: `announceNearLimit`. Inline announces
|
|
54
|
-
* (unchanged since #3408); the fullscreen dialog does not.
|
|
55
|
-
*/
|
|
56
|
-
|
|
57
|
-
export function TextAreaField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
58
|
-
// `useId` sits above the `readonly` early return because a hook may not sit
|
|
59
|
-
// behind a conditional return: the readonly branch renders no counter, so
|
|
60
|
-
// the ids go unused there, but moving the call down would desync hook order
|
|
61
|
-
// the moment a field toggles readonly. `maxLength` is derived here for the
|
|
62
|
-
// same reason the ids are — proximity to the hook, not because the readonly
|
|
63
|
-
// branch wants it.
|
|
64
|
-
const textareaField = field as any;
|
|
65
|
-
// Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
|
|
66
|
-
// objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
|
|
67
|
-
// spec-authored maxLength gave neither the textarea cap nor the counter.
|
|
68
|
-
const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Two description ids off one `useId()` — one per editing surface.
|
|
72
|
-
*
|
|
73
|
-
* They cannot be a single id. The dialog edits a LOCAL draft (see
|
|
74
|
-
* `FullscreenFieldEditor`), so the moment the user types in it the two
|
|
75
|
-
* surfaces are counting different strings; one shared id would point both
|
|
76
|
-
* textareas at whichever sentence rendered last. Both derive from the widget
|
|
77
|
-
* instance, so two textareas on one form never collide either.
|
|
78
|
-
*/
|
|
79
|
-
const instanceId = useId();
|
|
80
|
-
const descriptionId = `${instanceId}-charcount`;
|
|
81
|
-
const fullscreenDescriptionId = `${instanceId}-fullscreen-charcount`;
|
|
82
|
-
|
|
83
|
-
if (readonly) {
|
|
84
|
-
return (
|
|
85
|
-
<div className="text-sm whitespace-pre-wrap">
|
|
86
|
-
{value || <EmptyValue />}
|
|
87
|
-
</div>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const rows = textareaField?.rows || 4;
|
|
92
|
-
// Mobile fullscreen opt-in travels on the field metadata and nowhere else.
|
|
93
|
-
// That metadata has exactly one carrier (`field`, objectui#3233), so this is
|
|
94
|
-
// a single read — a misspelled flag has no read path to quietly catch it.
|
|
95
|
-
const showFullscreenButton = Boolean(textareaField?.mobile_fullscreen);
|
|
96
|
-
|
|
97
|
-
const domProps = toDomProps(props);
|
|
98
|
-
// Resolved once and given to BOTH editing surfaces (objectui#3402). It used
|
|
99
|
-
// to reach the inline `<Textarea>` alone, so a disabled field greyed out
|
|
100
|
-
// correctly while its expand button stayed live and the dialog wrote the edit
|
|
101
|
-
// straight back through `onCommit`. `disabled` also carries the form's
|
|
102
|
-
// `isSubmitting`, so that hole was open for the duration of every submit.
|
|
103
|
-
const disabled = Boolean(domProps.disabled);
|
|
104
|
-
|
|
105
|
-
// APPENDED, never assigned (objectui#3408). `<FormControl>` is a Radix Slot
|
|
106
|
-
// and already hands the control an `aria-describedby` naming the field's
|
|
107
|
-
// description and error message; it arrives here through `toDomProps`'
|
|
108
|
-
// `aria-*` pass-through. Overwriting it would trade "no cap announced" for
|
|
109
|
-
// "no error announced" — a strictly worse bug, and a silent one.
|
|
110
|
-
const describedBy =
|
|
111
|
-
[domProps['aria-describedby'], maxLength ? descriptionId : undefined]
|
|
112
|
-
.filter(Boolean)
|
|
113
|
-
.join(' ') || undefined;
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<div className="relative">
|
|
117
|
-
<Textarea
|
|
118
|
-
{...domProps}
|
|
119
|
-
value={value || ''}
|
|
120
|
-
onChange={(e) => onChange(e.target.value)}
|
|
121
|
-
placeholder={textareaField?.placeholder}
|
|
122
|
-
disabled={readonly || disabled}
|
|
123
|
-
rows={rows}
|
|
124
|
-
maxLength={maxLength}
|
|
125
|
-
aria-invalid={!!error}
|
|
126
|
-
// After the spread so the composed value wins over the raw host one.
|
|
127
|
-
aria-describedby={describedBy}
|
|
128
|
-
className={domProps.className}
|
|
129
|
-
/>
|
|
130
|
-
{maxLength && (
|
|
131
|
-
/*
|
|
132
|
-
The INLINE surface's counter. `announceNearLimit` because this is the
|
|
133
|
-
surface the user types into with the rest of the form around them —
|
|
134
|
-
the near-limit warning is the one thing worth interrupting for, and
|
|
135
|
-
it is threshold-gated and debounced so it interrupts about five times
|
|
136
|
-
over a run that fills a 500-character cap rather than 52.
|
|
137
|
-
*/
|
|
138
|
-
<CharacterCount
|
|
139
|
-
length={(value || '').length}
|
|
140
|
-
maxLength={maxLength}
|
|
141
|
-
descriptionId={descriptionId}
|
|
142
|
-
announceNearLimit
|
|
143
|
-
className="absolute bottom-2 right-2 text-xs text-gray-400"
|
|
144
|
-
testId="textarea-character-count"
|
|
145
|
-
/>
|
|
146
|
-
)}
|
|
147
|
-
|
|
148
|
-
{showFullscreenButton && (
|
|
149
|
-
<FullscreenFieldEditor
|
|
150
|
-
value={value ?? ''}
|
|
151
|
-
onCommit={onChange}
|
|
152
|
-
label={textareaField?.label}
|
|
153
|
-
testIdPrefix="textarea"
|
|
154
|
-
disabled={disabled}
|
|
155
|
-
/*
|
|
156
|
-
The FULLSCREEN surface's counter (objectui#3417). This slot used to
|
|
157
|
-
render a bare `{n}/{max}` span: no accessible name, no association
|
|
158
|
-
with the dialog's textarea, nothing `aria-live`. A screen reader
|
|
159
|
-
heard "5 slash 500" if browse mode happened to sweep it and not one
|
|
160
|
-
word while the input had focus — the same field, the same cap, and
|
|
161
|
-
the inline surface three nodes richer since objectui#3408.
|
|
162
|
-
|
|
163
|
-
`announceNearLimit={false}` is the ruling on #3417, and it is a
|
|
164
|
-
choice about this SURFACE rather than a saving on effort: a
|
|
165
|
-
fullscreen modal is opened deliberately to write at length, the
|
|
166
|
-
description below already delivers the cap on focus, and the
|
|
167
|
-
dialog's textarea carries the same native `maxLength` stop. Adding
|
|
168
|
-
a second live region inside a modal would also put two of them in
|
|
169
|
-
one document, since the inline one stays mounted behind the overlay.
|
|
170
|
-
*/
|
|
171
|
-
footer={(draft) =>
|
|
172
|
-
maxLength ? (
|
|
173
|
-
<CharacterCount
|
|
174
|
-
length={draft.length}
|
|
175
|
-
maxLength={maxLength}
|
|
176
|
-
descriptionId={fullscreenDescriptionId}
|
|
177
|
-
announceNearLimit={false}
|
|
178
|
-
className="text-xs text-muted-foreground self-center"
|
|
179
|
-
testId="textarea-fullscreen-character-count"
|
|
180
|
-
/>
|
|
181
|
-
) : null
|
|
182
|
-
}
|
|
183
|
-
>
|
|
184
|
-
{(draft, setDraft, editorDisabled) => (
|
|
185
|
-
<Textarea
|
|
186
|
-
autoFocus
|
|
187
|
-
value={draft}
|
|
188
|
-
onChange={(e) => setDraft(e.target.value)}
|
|
189
|
-
disabled={editorDisabled}
|
|
190
|
-
maxLength={maxLength}
|
|
191
|
-
placeholder={textareaField?.placeholder}
|
|
192
|
-
/*
|
|
193
|
-
Assigned, not appended — and that is a statement about THIS
|
|
194
|
-
element, not a relaxation of the rule above. The inline control
|
|
195
|
-
composes because `<FormControl>` hands it an `aria-describedby`
|
|
196
|
-
through `domProps`; this one is constructed here from scratch,
|
|
197
|
-
`domProps` never reaches it, and the ids the host would have
|
|
198
|
-
supplied name nodes OUTSIDE the dialog, which Radix `aria-hidden`s
|
|
199
|
-
while the modal is open. There is nothing to preserve. Should
|
|
200
|
-
this editor ever be given host-supplied ids, compose them the
|
|
201
|
-
way `describedBy` above does rather than adding a second
|
|
202
|
-
assignment.
|
|
203
|
-
*/
|
|
204
|
-
aria-describedby={maxLength ? fullscreenDescriptionId : undefined}
|
|
205
|
-
className="h-full min-h-full resize-none text-base"
|
|
206
|
-
data-testid="textarea-fullscreen-input"
|
|
207
|
-
/>
|
|
208
|
-
)}
|
|
209
|
-
</FullscreenFieldEditor>
|
|
210
|
-
)}
|
|
211
|
-
</div>
|
|
212
|
-
);
|
|
213
|
-
}
|