@object-ui/fields 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +941 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -2,53 +2,84 @@ import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
4
  import { toDomProps } from './toDomProps';
5
- import { useLocalization } from '@object-ui/i18n';
6
- import { resolveFieldCurrency } from '../currency';
5
+ import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n';
6
+ import { resolveFieldCurrency, currencyFractionDigits, currencySymbol } from '../currency';
7
7
 
8
8
  /**
9
9
  * Format currency value for display. When `currency` is undefined the value
10
10
  * is rendered as a plain number with thousands separators (no symbol),
11
11
  * because silently assuming USD is misleading for non-USD businesses.
12
+ *
13
+ * `precision` is a display width the caller resolved — either the field's
14
+ * authored `precision` or, when it declared none, the currency's own ISO 4217
15
+ * minor-unit count (see the derivation at the call site, objectui#4361).
12
16
  */
13
- function formatAmount(value: number, currency: string | undefined, precision: number): string {
17
+ function formatAmount(
18
+ value: number,
19
+ currency: string | undefined,
20
+ precision: number,
21
+ locale?: string,
22
+ ): string {
14
23
  if (currency) {
15
24
  try {
16
- return new Intl.NumberFormat('en-US', {
17
- style: 'currency',
25
+ return formatDisplayNumber(value, {
26
+ locale,
18
27
  currency,
19
28
  minimumFractionDigits: precision,
20
29
  maximumFractionDigits: precision,
21
- }).format(value);
30
+ });
22
31
  } catch {
23
32
  return `${currency} ${value.toFixed(precision)}`;
24
33
  }
25
34
  }
26
35
  try {
27
- return new Intl.NumberFormat('en-US', {
36
+ // No `scale` passed: this is a currency widget whose `precision` is a
37
+ // display width, so a `precision: 0` amount keeps its separators rather
38
+ // than being read as an ordinal (objectui#4033).
39
+ return formatDisplayNumber(value, {
40
+ locale,
28
41
  minimumFractionDigits: precision,
29
42
  maximumFractionDigits: precision,
30
- }).format(value);
43
+ });
31
44
  } catch {
32
45
  return value.toFixed(precision);
33
46
  }
34
47
  }
35
48
 
36
- const CURRENCY_SYMBOLS: Record<string, string> = {
37
- USD: '$',
38
- };
39
-
40
49
  export function CurrencyField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
41
50
  const currencyField = field as any;
42
51
  // Shared precedence: field currency → currencyConfig → tenant default (ADR-0053).
43
52
  const { currency: tenantCurrency } = useLocalization();
53
+ const locale = useDisplayLocale();
44
54
  const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency);
45
- const precision = currencyField?.precision ?? 2;
55
+ // An AUTHORED `precision` wins: it is authored metadata, and this repo's
56
+ // convention is that authored metadata keeps priority. Whether a declared
57
+ // `precision` that contradicts the currency's ISO 4217 digits (say
58
+ // `precision: 2` on a JPY field) should be REJECTED at publish time is a
59
+ // contract question, so it is filed upstream in `@objectstack/spec` rather
60
+ // than answered here by overriding the author (objectui#4361).
61
+ //
62
+ // An ABSENT `precision` derives from the currency instead of defaulting to a
63
+ // literal 2, which rendered `¥1,234.50` for a currency with no minor unit.
64
+ // "Absent" is genuinely distinguishable from "authored 2" here — MEASURED,
65
+ // not assumed: `CurrencyFieldMetadata.precision` is optional in
66
+ // `@object-ui/types` and `z.ZodOptional<z.ZodNumber>` (no `.default()`) in
67
+ // `@objectstack/spec`, so a parsed field carries no materialized 2. The only
68
+ // `.default(2)` on the currency surface is `CurrencyConfigSchema.precision`,
69
+ // a different key on the `currencyConfig` block that this widget never reads.
70
+ //
71
+ // This is the widget's ONE precision, so the derivation also reaches the edit
72
+ // affordances below. Deliberate: leaving `step`/blur-rounding at 2 would give
73
+ // a JPY field that displays whole yen while offering a 0.01 spinner step and
74
+ // rounding typed input to 1234.56 yen.
75
+ const precision =
76
+ currencyField?.precision ?? (currency ? currencyFractionDigits(currency) : 2);
46
77
 
47
78
  if (readonly) {
48
79
  if (value == null) return <EmptyValue />;
49
80
  return (
50
81
  <span className="text-sm font-medium tabular-nums">
51
- {formatAmount(Number(value), currency, precision)}
82
+ {formatAmount(Number(value), currency, precision, locale)}
52
83
  </span>
53
84
  );
54
85
  }
@@ -61,7 +92,16 @@ export function CurrencyField({ value, onChange, field, readonly, error, classNa
61
92
  }
62
93
  };
63
94
 
64
- const symbol = currency ? (currency === 'USD' ? '$' : currency) : '';
95
+ // ONE channel for the symbol (objectui#4414). This used to be a hand-written
96
+ // `currency === 'USD' ? '$' : currency` ternary, with a dead one-entry
97
+ // `CURRENCY_SYMBOLS` map sitting two lines above it restating the same fact —
98
+ // a table that looked like the place to add a currency but that nothing read.
99
+ // Both were hand copies of knowledge `Intl` already carries, and both are
100
+ // gone: `currencySymbol` reads the `currency` part of the SAME format the
101
+ // readonly branch above renders amounts with, so the two modes of this widget
102
+ // can no longer disagree (they did: `JPY` in the adornment, `¥1,235` in the
103
+ // readonly span). USD at the display-locale default is `$` either way.
104
+ const symbol = currency ? currencySymbol(currency, locale) : '';
65
105
 
66
106
  return (
67
107
  <div className="relative">
@@ -1,16 +1,22 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
+ import { useDisplayLocale } from '@object-ui/i18n';
3
4
  import { FieldWidgetComponentProps } from './types';
4
5
  import { toDomProps } from './toDomProps';
5
6
  import { openNativePicker } from './openNativePicker';
7
+ import { toDateInputValue } from './nativeDateValue';
6
8
 
7
9
  /**
8
10
  * DateField - Date picker input widget
9
11
  * Uses native date input and displays locale-formatted date in readonly mode
10
12
  */
11
13
  export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
14
+ // Before the readonly early return: the hook count must not depend on a prop
15
+ // (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
16
+ // which is how a Chinese form ended up with an `8/11/2026` value in it.
17
+ const locale = useDisplayLocale();
12
18
  if (readonly) {
13
- return value ? <span className="text-sm">{new Date(value).toLocaleDateString()}</span> : <EmptyValue />;
19
+ return value ? <span className="text-sm">{new Date(value).toLocaleDateString(locale)}</span> : <EmptyValue />;
14
20
  }
15
21
 
16
22
  const domProps = toDomProps(props);
@@ -19,7 +25,10 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
19
25
  <Input
20
26
  {...domProps}
21
27
  type="date"
22
- value={value || ''}
28
+ // An API that hands back `2026-06-17T00:00:00.000Z` for a `date` field
29
+ // would leave this control empty too (objectui#3127). The written-back
30
+ // shape is unchanged: the control's own plain `YYYY-MM-DD`.
31
+ value={toDateInputValue(value)}
23
32
  onChange={(e) => onChange(e.target.value)}
24
33
  onClick={(e) => {
25
34
  openNativePicker(e.currentTarget);
@@ -1,20 +1,26 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
+ import { useDisplayLocale } from '@object-ui/i18n';
3
4
  import { FieldWidgetComponentProps } from './types';
4
5
  import { toDomProps } from './toDomProps';
5
6
  import { openNativePicker } from './openNativePicker';
7
+ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue';
6
8
 
7
9
  /**
8
10
  * DateTimeField - Combined date and time picker widget
9
11
  * Displays both date and time in locale format when readonly
10
12
  */
11
13
  export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
14
+ // Before the readonly early return — the hook count must not depend on a
15
+ // prop. See DateField for why the bare `toLocale*` calls were wrong
16
+ // (objectui#4468).
17
+ const locale = useDisplayLocale();
12
18
  if (readonly) {
13
19
  if (!value) return <EmptyValue />;
14
20
  const date = new Date(value);
15
21
  return (
16
22
  <span className="text-sm">
17
- {date.toLocaleDateString()} {date.toLocaleTimeString()}
23
+ {date.toLocaleDateString(locale)} {date.toLocaleTimeString(locale)}
18
24
  </span>
19
25
  );
20
26
  }
@@ -25,8 +31,11 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
25
31
  <Input
26
32
  {...domProps}
27
33
  type="datetime-local"
28
- value={value || ''}
29
- onChange={(e) => onChange(e.target.value)}
34
+ // The record's value is ISO-8601 (`…T14:30:00.000Z`), which this control
35
+ // rejects outright — it renders empty and the user reads that as a lost
36
+ // value (objectui#3127). Convert in, and back out on the same basis.
37
+ value={toDateTimeInputValue(value)}
38
+ onChange={(e) => onChange(fromDateTimeInputValue(e.target.value))}
30
39
  onClick={(e) => {
31
40
  openNativePicker(e.currentTarget);
32
41
  domProps.onClick?.(e);
@@ -5,7 +5,9 @@ import { useObjectTranslation } from '@object-ui/i18n';
5
5
  import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
6
6
  import { FieldWidgetComponentProps } from './types';
7
7
  import { toDomProps } from './toDomProps';
8
+ import { toHostGroupProps } from './toHostGroupProps';
8
9
  import { useUploadingSignal } from './useUploadingSignal';
10
+ import { maxSizeError, type TranslateFn } from './file-size-guard';
9
11
  import {
10
12
  fileValueForSubmit,
11
13
  readFileValues,
@@ -47,13 +49,11 @@ function useFileUploads(opts: {
47
49
  if (selectedFiles.length === 0) return;
48
50
  const newErrors: string[] = [];
49
51
 
52
+ // Shared with ImageField — see `file-size-guard`.
50
53
  const validFiles = selectedFiles.filter(file => {
51
- if (maxSize && file.size > maxSize) {
52
- const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
53
- newErrors.push(t('fields.file.exceedsMaxSize', {
54
- defaultValue: `"${file.name}" exceeds max size (${maxMB} MB)`,
55
- name: file.name, max: maxMB,
56
- }));
54
+ const rejection = maxSizeError(t as TranslateFn, file, maxSize);
55
+ if (rejection) {
56
+ newErrors.push(rejection);
57
57
  return false;
58
58
  }
59
59
  return true;
@@ -184,10 +184,18 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
184
184
  }, [accept, processFiles]);
185
185
 
186
186
  if (readonly) {
187
- if (!value) return <EmptyValue />;
187
+ // Readonly there is no dropzone: the field's whole rendered surface is the
188
+ // list of file names, so THAT is what the host's group label names
189
+ // (objectui#3990). The role is therefore `group` here and `button` (the
190
+ // dropzone) while editable — the same widget, two different surfaces. See
191
+ // `toHostGroupProps`; `EmptyValue`'s own `aria-label` ("No value") is
192
+ // outranked by `aria-labelledby` per accname, and on the `generic` role that
193
+ // placeholder span carries it was never exposed as a name anyway.
194
+ const hostGroupProps = toHostGroupProps(props);
195
+ if (!value) return <EmptyValue {...hostGroupProps} />;
188
196
 
189
197
  return (
190
- <div className="flex flex-wrap gap-2">
198
+ <div {...hostGroupProps} className="flex flex-wrap gap-2">
191
199
  {views.map((file, idx) => (
192
200
  <span key={idx} className="text-sm truncate max-w-xs">
193
201
  {file.name}
@@ -240,7 +248,17 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
240
248
  keyboard path to the hidden file input), so the DOM pass-through
241
249
  and the validation state land here (objectui#3318). `name` is
242
250
  withheld: only DOM-legal on form controls, and on this div it is
243
- exactly the leak #3291 sweeps for. */}
251
+ exactly the leak #3291 sweeps for.
252
+
253
+ It also carries the host's label. This widget is declared
254
+ `labelling: 'group'` (objectui#3961) NOT because it is composite — it
255
+ has exactly one control — but because that one control is a
256
+ `div[role="button"]`, which no `<label for>` can reach. So the form
257
+ renderer names it by IDREF instead, and `aria-labelledby` arrives in
258
+ the same whitelist spread (`toDomProps` forwards the whole `aria-`
259
+ family). Deliberately no extra `role="group"` wrapper: there is no
260
+ group here, and adding one would announce a container that holds a
261
+ single button. */}
244
262
  <div
245
263
  {...dropzoneDomProps}
246
264
  onDragOver={handleDragOver}
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { EmptyValue } from '@object-ui/components';
3
+ import { useDisplayLocale } from '@object-ui/i18n';
3
4
  import { FieldWidgetComponentProps } from './types';
4
5
 
5
6
  /**
@@ -7,6 +8,10 @@ import { FieldWidgetComponentProps } from './types';
7
8
  * Values are computed on the backend and cannot be edited
8
9
  */
9
10
  export function FormulaField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
11
+ // Before the empty-value early return — the hook count must not change when
12
+ // a value flips between null and set. A `date` return type used to format
13
+ // through the MACHINE's locale (objectui#4468).
14
+ const locale = useDisplayLocale();
10
15
  const formulaField = field as any;
11
16
  const returnType = formulaField?.return_type || 'text';
12
17
 
@@ -20,7 +25,7 @@ export function FormulaField({ value, field, ...props }: FieldWidgetComponentPro
20
25
  } else if (returnType === 'boolean') {
21
26
  displayValue = value ? 'Yes' : 'No';
22
27
  } else if (returnType === 'date') {
23
- displayValue = new Date(value).toLocaleDateString();
28
+ displayValue = new Date(value).toLocaleDateString(locale);
24
29
  } else {
25
30
  displayValue = String(value);
26
31
  }
@@ -6,266 +6,55 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
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';
9
+ import React from 'react';
10
+ import { FullscreenEditor, type FullscreenEditorProps } from '@object-ui/components';
22
11
 
23
12
  /**
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.
13
+ * The fullscreen-edit affordance shared by every long-text widget in this
14
+ * package (`field:textarea` → `TextAreaField`, `field:markdown` / `field:html`
15
+ * → `RichTextField`).
16
+ *
17
+ * ## This is now a thin wrapper (objectui#3398)
18
+ *
19
+ * The affordance, the dialog, the draft/commit state machine and the
20
+ * `readOnly` / `disabled` semantics all live in `FullscreenEditor`
21
+ * (`@object-ui/components`). They used to live here AND, separately, in the
22
+ * form renderer's built-in `textarea` branch — one form-level promise
23
+ * (`ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text
24
+ * field as `mobile_fullscreen`) answered twice, on the two render paths.
25
+ *
26
+ * Two copies of a state machine drift, and these did: objectui#3400 measured a
27
+ * read-only field editable through the built-in branch's dialog, objectui#3402
28
+ * measured the same hole for `disabled` here, and #3393 (label in the dialog
29
+ * title) and #3272 (i18n) each landed on one side before the other. The
30
+ * maintainer's ruling of 2026-08-10 was one implementation hoisted to the
31
+ * dependency-legal side — and the measured import graph allows exactly that
32
+ * direction: this package depends on `@object-ui/components`, which declares no
33
+ * dependency on `@object-ui/fields` in `dependencies` or `peerDependencies`.
34
+ *
35
+ * ## Why the wrapper survives the merge rather than being deleted
36
+ *
37
+ * It carries the one thing that is genuinely about THIS package: `readOnly` is
38
+ * not part of the contract here. Both hosts early-return a read-only DISPLAY
39
+ * before they compute the affordance (`TextAreaField.tsx`, `RichTextField.tsx`),
40
+ * so a `readOnly` reaching this component would be a prop with no producer on
41
+ * this path — the shape this package keeps deleting (objectui#3232/#3233). The
42
+ * `Omit` below is that statement in the type system, and it is why the hoist did
43
+ * not simply re-export the primitive. A future host that renders an editor for
44
+ * read-only fields must widen this type AND pass the prop together; the
45
+ * primitive already answers it (no affordance at all).
46
+ *
47
+ * Everything else — `value` / `onCommit` / `label` / `testIdPrefix` /
48
+ * `disabled` / `children` / `footer` — passes straight through, so the hosts and
49
+ * their pins are unchanged. `testIdPrefix` still namespaces this package's ids
50
+ * (`textarea-`, `richtext-`), distinct from the built-in branch's
51
+ * `form-textarea-`, so a test can still say which render path it found.
128
52
  */
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>
53
+ export type FullscreenFieldEditorProps = Omit<FullscreenEditorProps, 'readOnly'>;
228
54
 
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
- );
55
+ export function FullscreenFieldEditor(props: FullscreenFieldEditorProps) {
56
+ // `readOnly` is deliberately not forwarded — it is not in the props type, and
57
+ // the primitive defaults it to false, which is the behaviour both hosts rely
58
+ // on: they have already returned a read-only display before reaching here.
59
+ return <FullscreenEditor {...props} />;
271
60
  }