@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.
- package/CHANGELOG.md +941 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -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 +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- 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/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- 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/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- 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(
|
|
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
|
|
17
|
-
|
|
25
|
+
return formatDisplayNumber(value, {
|
|
26
|
+
locale,
|
|
18
27
|
currency,
|
|
19
28
|
minimumFractionDigits: precision,
|
|
20
29
|
maximumFractionDigits: precision,
|
|
21
|
-
})
|
|
30
|
+
});
|
|
22
31
|
} catch {
|
|
23
32
|
return `${currency} ${value.toFixed(precision)}`;
|
|
24
33
|
}
|
|
25
34
|
}
|
|
26
35
|
try {
|
|
27
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
newErrors.push(
|
|
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
|
-
|
|
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
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* ##
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* `disabled`
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
}
|