@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
@@ -0,0 +1,147 @@
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
+ * objectui#4553 phase 2 — `formatPercent` was the last formatter outside the
11
+ * display-locale channel, and it was outside it in a different way from its
12
+ * siblings.
13
+ *
14
+ * PR #4557 threaded the gantt tooltip's number and currency rows and measured
15
+ * that percent could not follow: `formatPercent(value, precision)` took no
16
+ * locale parameter, and its whole body was
17
+ * `${percentDisplayValue(value).toFixed(precision)}%`. It built no
18
+ * `Intl.NumberFormat` and never reached `formatDisplayNumber`, so its output
19
+ * was not the MACHINE's locale (the defect its siblings had) but NO locale at
20
+ * all: an ASCII decimal mark, never a grouping separator, byte-identical on
21
+ * every machine on earth.
22
+ *
23
+ * That is why grouping and locale land together here. `1235%` is not merely
24
+ * un-German — it is wrong in en-US too, where the number is `1,235%`. So the
25
+ * English output MOVES, and that move is the fix, not a regression.
26
+ *
27
+ * ── Directions, predicted in writing BEFORE the run, then measured ───────
28
+ * Runner: node v22.22.2 / ICU 78.2 / machine locale en-US.
29
+ *
30
+ * en 1234.5 p0 `1235%` → `1,235%` RED — the grouping move
31
+ * de 1234.5 p0 `1235%` → `1.235 %` RED — separators inverted + NBSP
32
+ * de 80 p0 `80%` → `80 %` RED — de writes a no-break space
33
+ * before the sign; en does not
34
+ * de 12.5 p1 `12.5%` → `12,5 %` RED — the ruling's named form
35
+ * en 80 p0 `80%` → `80%` PIN, green both sides
36
+ * en 12.5 p1 `12.5%` → `12.5%` PIN, green both sides
37
+ * fraction scaling (0.8 → 80%) PIN, green both sides
38
+ *
39
+ * The `en` pins are byte-identical because `en` and the runner's `en-US` agree
40
+ * and because grouping only shows from four digits up — they are NOT evidence
41
+ * the fix works; the `de` cases and the en GROUPING case carry that.
42
+ *
43
+ * Provider-less by construction (objectui#4514): these are pure-function cases
44
+ * that touch no hook. The provider-mounted half of this card lives in
45
+ * `PercentCellRenderer.locale.test.tsx`.
46
+ */
47
+
48
+ import { describe, it, expect } from 'vitest';
49
+ import { formatPercent } from '../index';
50
+
51
+ /**
52
+ * German puts a NO-BREAK SPACE (U+00A0) between the number and the percent
53
+ * sign. Written as an escape rather than pasted, so the expectation is
54
+ * readable and greppable in both spellings.
55
+ */
56
+ const NBSP = '\u00a0';
57
+
58
+ describe('formatPercent groups its output (objectui#4553)', () => {
59
+ /**
60
+ * THE en RED CASE, and the one that justifies calling this a defect rather
61
+ * than a localization nicety: four digits went out ungrouped in English too.
62
+ */
63
+ it('en groups from four digits up — the output MOVES, and that is the fix', () => {
64
+ expect(formatPercent(1234.5, 0, 'en')).toBe('1,235%');
65
+ expect(formatPercent(1000000, 0, 'en')).toBe('1,000,000%');
66
+ });
67
+
68
+ /** PIN, green both sides: below the grouping threshold nothing changes. */
69
+ it('en output below the grouping threshold is byte-identical (must-not-change)', () => {
70
+ expect(formatPercent(80, 0, 'en')).toBe('80%');
71
+ expect(formatPercent(12.5, 1, 'en')).toBe('12.5%');
72
+ expect(formatPercent(33.33, 2, 'en')).toBe('33.33%');
73
+ expect(formatPercent(0, 0, 'en')).toBe('0%');
74
+ });
75
+ });
76
+
77
+ describe('formatPercent follows the display locale (objectui#4553)', () => {
78
+ /**
79
+ * The separators invert AND a no-break space appears before the sign, so the
80
+ * German form cannot coincide with the machine's on this runner.
81
+ */
82
+ it('de inverts the separators and spaces the percent sign', () => {
83
+ expect(formatPercent(1234.5, 0, 'de')).toBe(`1.235${NBSP}%`);
84
+ expect(formatPercent(12.5, 1, 'de')).toBe(`12,5${NBSP}%`);
85
+ });
86
+
87
+ /**
88
+ * Even with no separator in play, German still differs from English: the
89
+ * space before the sign is part of the locale's percent CONVENTION, which is
90
+ * what routing through `Intl` buys over appending a literal '%'.
91
+ */
92
+ it('de spaces the sign even for a value with no separators', () => {
93
+ expect(formatPercent(80, 0, 'de')).toBe(`80${NBSP}%`);
94
+ });
95
+
96
+ /** `Intl` accepts `'zh'` verbatim — no mapping table anywhere. */
97
+ it('zh renders its own convention', () => {
98
+ expect(formatPercent(1234.5, 0, 'zh')).toBe('1,235%');
99
+ });
100
+
101
+ /**
102
+ * A malformed tag from a tenant config must never take a cell down —
103
+ * `formatDisplayNumber` catches it and retries without the locale.
104
+ */
105
+ it('a malformed locale tag falls back instead of throwing', () => {
106
+ expect(() => formatPercent(80, 0, 'not a locale')).not.toThrow();
107
+ expect(formatPercent(80, 0, 'not a locale')).toContain('80');
108
+ });
109
+ });
110
+
111
+ describe('formatPercent keeps its existing contract (objectui#4553 must-not-change)', () => {
112
+ /**
113
+ * PIN: the fraction/whole disambiguation is `percentDisplayValue`'s, shared
114
+ * with the dashboard measure formatter. The locale parameter must not have
115
+ * moved it.
116
+ */
117
+ it('still scales a fraction-stored percent and passes a whole one through', () => {
118
+ expect(formatPercent(0.8, 0, 'en')).toBe('80%');
119
+ expect(formatPercent(0.5, 0, 'en')).toBe('50%');
120
+ expect(formatPercent(0.075, 1, 'en')).toBe('7.5%');
121
+ // >= 1 is already in display magnitude and is NOT scaled again.
122
+ expect(formatPercent(80, 0, 'en')).toBe('80%');
123
+ expect(formatPercent(100, 0, 'en')).toBe('100%');
124
+ });
125
+
126
+ it('still honors the precision it is given', () => {
127
+ expect(formatPercent(33.333, 0, 'en')).toBe('33%');
128
+ expect(formatPercent(33.333, 1, 'en')).toBe('33.3%');
129
+ expect(formatPercent(33.333, 2, 'en')).toBe('33.33%');
130
+ });
131
+
132
+ it('still handles negatives', () => {
133
+ expect(formatPercent(-45.5, 1, 'en')).toBe('-45.5%');
134
+ });
135
+
136
+ /**
137
+ * The parameter is OPTIONAL and third, matching `formatNumber(value,
138
+ * decimals, locale)` and `formatCurrency(value, currency, locale)`. An
139
+ * existing caller passing nothing still gets the runtime default locale —
140
+ * though it now also gets grouping, which is the deliberate output move.
141
+ */
142
+ it('is callable with no locale, and with no precision either', () => {
143
+ expect(() => formatPercent(80)).not.toThrow();
144
+ expect(formatPercent(80)).toBe('80%');
145
+ expect(formatPercent(80, 0)).toBe('80%');
146
+ });
147
+ });
@@ -0,0 +1,224 @@
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
+ * objectui#4590 — `formatPercent` rounds a tie the wrong way, because it renders
11
+ * a value that is ALREADY in percentage points through `Intl`'s
12
+ * `style: 'percent'`, which wants a FRACTION.
13
+ *
14
+ * The old body divided by 100 so `Intl` could multiply straight back:
15
+ *
16
+ * formatDisplayNumber(displayValue / 100, { style: 'percent', … })
17
+ *
18
+ * That round trip is not value-preserving. `Intl` formats from the SHORTEST
19
+ * decimal representation of the double it is handed, and `displayValue / 100`
20
+ * has a different shortest representation from `displayValue`: `1.005` is
21
+ * `1.005`, but `1.005 / 100` is `0.010049999999999999`, which percent-scales to
22
+ * `1.0049999999999999` and rounds DOWN. The DIVISION loses the digit, not the
23
+ * rounding. It is therefore a numeral defect and not a convention one — it
24
+ * reproduces identically in every locale, in Arabic-Indic digits too.
25
+ *
26
+ * The fix is the option #4576 / PR #4589 put in `@object-ui/core` for exactly
27
+ * this: `style: 'percentPoints'` formats the points DIRECTLY (`Intl`'s
28
+ * `style: 'unit'` / `unit: 'percent'` / `unitDisplay: 'narrow'`), which was
29
+ * measured to give a byte-identical percent affix to `style: 'percent'` across
30
+ * all 171 locale tags tested.
31
+ *
32
+ * ── PREDICTIONS, written before the run ─────────────────────────────────────
33
+ * Runner measured: node v22.22.2, ICU 78.2, machine locale en-US.
34
+ *
35
+ * RED before the fix — the numerals:
36
+ * formatPercent(1.005, 2, 'en-US') `1.00%` → `1.01%`
37
+ * formatPercent(1.025, 2, 'en-US') `1.02%` → `1.03%`
38
+ * formatPercent(1.45, 1, 'en-US') `1.4%` → `1.5%`
39
+ * formatPercent(1.055, 2, 'en-US') `1.05%` → `1.06%`
40
+ * formatPercent(99999.995, 2, 'en') `99,999.99%` → `100,000.00%`
41
+ * MAX_SAFE_INTEGER p0 `9,007,199,254,740,990%` → `…991%`
42
+ * 1e23 p0 `99,999,999,999,999,990,000,000%`
43
+ * → `100,000,000,000,000,000,000,000%`
44
+ * the same four ties in de-DE / tr-TR / ar-EG — the locale-independence
45
+ * claim, red in every one of them
46
+ *
47
+ * GREEN ON BOTH SIDES — the must-not-change set, and the STOP condition if
48
+ * any of it moves (this card is NUMERAL-only; a convention move is a defect
49
+ * in the fix, not a pin to update):
50
+ * the percent CONVENTION of all ten locales at 1234.5 p1 — de/fr/ru/sv's
51
+ * no-break space, tr's PREFIX sign, ar's U+066A + U+061C, en/ja/zh's bare
52
+ * suffix, fr's U+202F group separator, bn's Bengali digits
53
+ * the sign position and glyph on negatives (sv-SE's U+2212 included)
54
+ * the affix parity check against `Intl`'s own `style: 'percent'`
55
+ * `percentDisplayValue`'s fraction/whole scaling — upstream of the render
56
+ * and deliberately untouched by this card
57
+ *
58
+ * MEASURED, this call shape, old route vs new: 10 locales x 18 values x 4
59
+ * precisions = 720 combinations — 0 convention diffs, 130 numeral diffs (13 per
60
+ * locale, the SAME 13 in every locale). On the wide en-US grid the issue
61
+ * quantified (0.005 steps to 2000, precisions 0/1/2) 27,577 of 1,200,003 forms
62
+ * move. Every one is a last-digit-off-by-one.
63
+ *
64
+ * Every non-ASCII byte below is written as a `\u` escape, never pasted.
65
+ */
66
+
67
+ import { describe, it, expect } from 'vitest';
68
+ import { formatPercent } from '../index';
69
+
70
+ /** U+00A0 NO-BREAK SPACE — de/fr/ru/sv put one before the percent sign. */
71
+ const NBSP = '\u00a0';
72
+ /** U+202F NARROW NO-BREAK SPACE — fr's group separator. */
73
+ const NNBSP = '\u202f';
74
+ /** U+066A ARABIC PERCENT SIGN, U+061C ARABIC LETTER MARK. */
75
+ const ARABIC_PERCENT = '\u066a';
76
+ const ALM = '\u061c';
77
+
78
+ describe('formatPercent rounds a tie half-up on the authored decimal (#4590)', () => {
79
+ /**
80
+ * MOVING PINS — the issue's measured table, verbatim. Each was the artefact
81
+ * of the `/ 100` round trip; the right-hand column is half-up on the decimal
82
+ * the author actually wrote.
83
+ */
84
+ it.each([
85
+ [1.005, 2, '1.01%', '1.00%'],
86
+ [1.025, 2, '1.03%', '1.02%'],
87
+ [1.45, 1, '1.5%', '1.4%'],
88
+ [1.055, 2, '1.06%', '1.05%'],
89
+ ])('formatPercent(%s, %s) is %s (was %s)', (value, precision, expected) => {
90
+ expect(formatPercent(value, precision, 'en-US')).toBe(expected);
91
+ });
92
+
93
+ /**
94
+ * A tie that carries into a new digit COLUMN rather than just flipping the
95
+ * last one — the round trip lost the carry as well as the digit.
96
+ */
97
+ it('carries across the grouping boundary: 99999.995 to 2 decimals', () => {
98
+ // was `99,999.99%`
99
+ expect(formatPercent(99999.995, 2, 'en-US')).toBe('100,000.00%');
100
+ });
101
+
102
+ /**
103
+ * The defect is in the VALUE, not the convention, so it must reproduce in
104
+ * every locale — including one that writes the sign in front and one that
105
+ * uses neither ASCII digits nor an ASCII sign.
106
+ */
107
+ it('the same ties move in every locale — this is a numeral defect', () => {
108
+ expect(formatPercent(1.005, 2, 'de-DE')).toBe(`1,01${NBSP}%`); // was `1,00 %`
109
+ expect(formatPercent(1.45, 1, 'de-DE')).toBe(`1,5${NBSP}%`); // was `1,4 %`
110
+ expect(formatPercent(1.005, 2, 'tr-TR')).toBe('%1,01'); // was `%1,00`
111
+ // U+0661 U+066B U+0660 U+0661 — Arabic-Indic `1`, decimal separator, `0`, `1`
112
+ expect(formatPercent(1.005, 2, 'ar-EG')).toBe(
113
+ `\u0661\u066b\u0660\u0661${ARABIC_PERCENT}${ALM}`,
114
+ ); // was `\u0661\u066b\u0660\u0660` + the same affix
115
+ });
116
+ });
117
+
118
+ describe('formatPercent keeps every digit at the top of the double range (#4590)', () => {
119
+ /**
120
+ * MOVING PINS, before/after verbatim. `Number.MAX_SAFE_INTEGER` percentage
121
+ * points used to render with its last digit replaced by a zero, and 1e23 used
122
+ * to render as a number that is not 1e23 at all. Both are the same artefact
123
+ * as the ties, at the other end of the range: divide by 100 and the shortest
124
+ * representation of the quotient no longer round-trips.
125
+ */
126
+ it('MAX_SAFE_INTEGER keeps its last digit', () => {
127
+ // was `9,007,199,254,740,990%` — a 1 turned into a 0
128
+ expect(formatPercent(Number.MAX_SAFE_INTEGER, 0, 'en-US')).toBe('9,007,199,254,740,991%');
129
+ });
130
+
131
+ it('1e23 renders as 1e23', () => {
132
+ // was `99,999,999,999,999,990,000,000%`
133
+ expect(formatPercent(1e23, 0, 'en-US')).toBe('100,000,000,000,000,000,000,000%');
134
+ });
135
+ });
136
+
137
+ describe('MUST NOT CHANGE: the locale percent CONVENTION (#4590 is numeral-only)', () => {
138
+ /**
139
+ * The affix is what `style: 'percentPoints'` promises to preserve, so this is
140
+ * the half that proves the fix did not become a convention change. Byte-exact
141
+ * strings, chosen at a magnitude and precision where no tie is in play — they
142
+ * are GREEN on both sides of the fix, and a diff here is a STOP condition.
143
+ */
144
+ it.each([
145
+ ['en-US', '1,234.5%'],
146
+ ['de-DE', `1.234,5${NBSP}%`],
147
+ ['fr-FR', `1${NNBSP}234,5${NBSP}%`],
148
+ ['tr-TR', '%1.234,5'], // sign in FRONT
149
+ ['ar-EG', `\u0661\u066c\u0662\u0663\u0664\u066b\u0665${ARABIC_PERCENT}${ALM}`],
150
+ ['ja-JP', '1,234.5%'],
151
+ ['zh-CN', '1,234.5%'],
152
+ ['ru-RU', `1${NBSP}234,5${NBSP}%`],
153
+ ['sv-SE', `1${NBSP}234,5${NBSP}%`],
154
+ ['bn-IN', `\u09e7,\u09e8\u09e9\u09ea.\u09eb%`],
155
+ ])('%s renders its own percent convention, unchanged', (locale, expected) => {
156
+ expect(formatPercent(1234.5, 1, locale)).toBe(expected);
157
+ });
158
+
159
+ /**
160
+ * Sign POSITION and sign GLYPH are convention too — Turkish puts the minus
161
+ * outside its prefixed percent sign, Arabic leads with U+061C, and Swedish
162
+ * uses U+2212 MINUS SIGN rather than ASCII hyphen-minus. None of it moves.
163
+ */
164
+ it.each([
165
+ ['en-US', '-45.5%'],
166
+ ['de-DE', `-45,5${NBSP}%`],
167
+ ['tr-TR', '-%45,5'],
168
+ ['ar-EG', `${ALM}-\u0664\u0665\u066b\u0665${ARABIC_PERCENT}${ALM}`],
169
+ ['sv-SE', `\u221245,5${NBSP}%`],
170
+ ])('%s keeps its negative-sign convention', (locale, expected) => {
171
+ expect(formatPercent(-45.5, 1, locale)).toBe(expected);
172
+ });
173
+
174
+ /**
175
+ * The parity claim asserted rather than trusted: the affix `formatPercent`
176
+ * now produces must be the one `Intl`'s OWN `style: 'percent'` produces for
177
+ * the same locale. Comparing the affix alone (digits stripped) is deliberate
178
+ * — the numerals are exactly what this card moves, so a whole-string compare
179
+ * would re-assert the defect.
180
+ */
181
+ it('the affix is byte-identical to Intl style:percent, for every locale here', () => {
182
+ const affix = (rendered: string) => rendered.replace(/[\p{Nd}]/gu, '');
183
+ for (const locale of ['en-US', 'de-DE', 'fr-FR', 'tr-TR', 'ar-EG', 'ja-JP',
184
+ 'zh-CN', 'ru-RU', 'sv-SE', 'bn-IN']) {
185
+ const cell = formatPercent(1234.5, 1, locale);
186
+ const intl = new Intl.NumberFormat(locale, {
187
+ style: 'percent',
188
+ minimumFractionDigits: 1,
189
+ maximumFractionDigits: 1,
190
+ }).format(12.345);
191
+ expect(affix(cell), `percent affix for ${locale}`).toBe(affix(intl));
192
+ }
193
+ });
194
+ });
195
+
196
+ describe('MUST NOT CHANGE: percent SCALING is upstream of the render (#4590)', () => {
197
+ /**
198
+ * `percentDisplayValue` (a stored fraction below 1 scales by 100, a value at
199
+ * or above 1 passes through) decides WHICH number gets rendered; this card
200
+ * changes only HOW that number is rendered. Pinned unmoved so a future reader
201
+ * cannot mistake the two halves for one.
202
+ */
203
+ it('a stored fraction still scales, a stored whole number still does not', () => {
204
+ expect(formatPercent(0.8, 0, 'en-US')).toBe('80%');
205
+ expect(formatPercent(0.5, 0, 'en-US')).toBe('50%');
206
+ expect(formatPercent(0.075, 1, 'en-US')).toBe('7.5%');
207
+ expect(formatPercent(80, 0, 'en-US')).toBe('80%');
208
+ expect(formatPercent(100, 0, 'en-US')).toBe('100%');
209
+ // The boundary itself: 1 is NOT a fraction, so it stays 1%.
210
+ expect(formatPercent(1, 0, 'en-US')).toBe('1%');
211
+ // …which is why the tie cases above are authored just above 1.
212
+ expect(formatPercent(0.9999, 0, 'en-US')).toBe('100%');
213
+ });
214
+
215
+ it('a malformed locale tag still falls back instead of throwing', () => {
216
+ expect(() => formatPercent(80, 0, 'not a locale')).not.toThrow();
217
+ expect(formatPercent(80, 0, 'not a locale')).toContain('80');
218
+ });
219
+
220
+ it('is still callable with no locale and no precision', () => {
221
+ expect(formatPercent(80)).toBe('80%');
222
+ expect(formatPercent(80, 0)).toBe('80%');
223
+ });
224
+ });
@@ -0,0 +1,199 @@
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
+ * End-to-end: BOTH select paths associate the form's visible label with the
11
+ * control the user operates (objectui#3976, the built-in half of #3306).
12
+ *
13
+ * There are two `select` paths through the form renderer and they do not share
14
+ * an implementation:
15
+ *
16
+ * - **registered widget** — `type: 'field:select'` (what `mapFieldTypeToFormType`
17
+ * emits for object-driven forms) resolves to this package's `SelectField`.
18
+ * objectui#3306 moved its DOM pass-through from Radix's `Select.Root` — which
19
+ * renders no element and silently drops what it does not recognise — onto
20
+ * `SelectTrigger`, the focusable `<button role="combobox">`.
21
+ * - **built-in branch** — a hand-written `type: 'select'` never reaches the
22
+ * registry at all (`'select'` is a `BUILTIN_FIELD_TYPES` member), so the form
23
+ * renderer's own `Select` branch renders it. That branch kept spreading onto
24
+ * Root until objectui#3976: the visible label pointed at an id no element
25
+ * carried, `getByLabelText` found nothing, and `aria-describedby` /
26
+ * `aria-invalid` / `aria-required` never reached the DOM either.
27
+ *
28
+ * The paths are pinned side by side, in one file, precisely because the fork is
29
+ * what caused the bug: #3306 fixed one half and the other silently kept the
30
+ * defect for as long as nobody wrote a bare `type: 'select'` into a test. The
31
+ * registered half here is a POSITIVE CONTROL — it must stay green through any
32
+ * change to the built-in branch.
33
+ *
34
+ * The widget is registered raw rather than through `registerAllFields()`, which
35
+ * wraps every loader in `React.lazy`: an unbounded module load inside a bounded
36
+ * `findBy`/`waitFor` window is the repo's known flake generator (AGENTS.md
37
+ * 测试纪律, objectui#3010). Same component, no Suspense boundary.
38
+ */
39
+
40
+ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
41
+ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
42
+ import '@testing-library/jest-dom';
43
+ import { ComponentRegistry } from '@object-ui/core';
44
+ // Module scope: pulls in the form renderer's registration side effect.
45
+ import '@object-ui/components';
46
+
47
+ import { SelectField } from '../widgets/SelectField';
48
+
49
+ const OPTIONS = [
50
+ { label: 'Alpha', value: 'alpha' },
51
+ { label: 'Beta', value: 'beta' },
52
+ ];
53
+
54
+ beforeAll(() => {
55
+ ComponentRegistry.register('select', SelectField as any, {
56
+ namespace: 'field',
57
+ skipFallback: true,
58
+ });
59
+ }, 30000);
60
+
61
+ beforeEach(() => {
62
+ if (!(Element.prototype as any).scrollIntoView) {
63
+ (Element.prototype as any).scrollIntoView = () => {};
64
+ }
65
+ });
66
+
67
+ afterEach(() => {
68
+ cleanup();
69
+ vi.restoreAllMocks();
70
+ });
71
+
72
+ /** The real form renderer hosting one field. */
73
+ function renderForm(field: any, defaultValue: unknown = undefined) {
74
+ const Form = ComponentRegistry.get('form')!;
75
+ return render(
76
+ <Form
77
+ schema={{
78
+ type: 'form',
79
+ mode: 'create',
80
+ showSubmit: true,
81
+ showCancel: false,
82
+ submitLabel: 'Create',
83
+ defaultValues: { [field.name]: defaultValue },
84
+ fields: [field],
85
+ onSubmit: () => {},
86
+ }}
87
+ />,
88
+ );
89
+ }
90
+
91
+ /** Every `<label for=…>` in the document, paired with its resolved target. */
92
+ function labelTargets(): Array<{ text: string; forId: string; resolves: boolean }> {
93
+ return Array.from(document.querySelectorAll('label'))
94
+ .map((el) => ({
95
+ text: (el.textContent ?? '').trim(),
96
+ forId: el.getAttribute('for') ?? '',
97
+ resolves: !!el.getAttribute('for') && !!document.getElementById(el.getAttribute('for')!),
98
+ }))
99
+ .filter((l) => l.forId !== '');
100
+ }
101
+
102
+ /** The trigger button, by role — fails loudly if it stops being a combobox. */
103
+ function trigger(name: string): Element {
104
+ const el = document.querySelector(`[data-field="${name}"] [role="combobox"]`);
105
+ if (!el) throw new Error(`no select trigger rendered for field "${name}"`);
106
+ return el;
107
+ }
108
+
109
+ const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
110
+
111
+ /**
112
+ * The guarantees a select must give whichever path rendered it. Run twice, so
113
+ * neither path can drift away from the other again.
114
+ */
115
+ const PATHS = [
116
+ ['built-in branch (bare `type: select`, objectui#3976)', 'select'],
117
+ ['registered widget (`field:select`, objectui#3306 — positive control)', 'field:select'],
118
+ ] as const;
119
+
120
+ describe.each(PATHS)('%s', (_label, type) => {
121
+ const field = { name: 'choice', label: 'Choice', type, options: OPTIONS };
122
+
123
+ it('emits no label pointing at an id nothing carries', () => {
124
+ renderForm(field);
125
+
126
+ const labels = labelTargets();
127
+ expect(labels.length).toBeGreaterThan(0);
128
+ expect(labels.filter((l) => !l.resolves)).toEqual([]);
129
+ });
130
+
131
+ it('resolves the visible label to the trigger button', () => {
132
+ renderForm(field);
133
+
134
+ // `getAllByLabelText` walks `for`→id, so it guards the association itself:
135
+ // on the built-in path this used to find NOTHING at all.
136
+ const labelled = screen.getAllByLabelText('Choice');
137
+ expect(labelled).toHaveLength(1);
138
+ expect(labelled[0]).toBe(trigger('choice'));
139
+ expect(labelled[0].tagName).toBe('BUTTON');
140
+ });
141
+
142
+ it('gives the combobox the label as its accessible name', () => {
143
+ renderForm(field);
144
+
145
+ expect(screen.getByRole('combobox', { name: 'Choice' })).toBe(trigger('choice'));
146
+ });
147
+
148
+ it('carries the required state on the trigger', () => {
149
+ renderForm({ ...field, required: true });
150
+
151
+ expect(trigger('choice')).toHaveAttribute('aria-required', 'true');
152
+ });
153
+
154
+ it('announces invalid on the trigger and links the message', async () => {
155
+ renderForm({ ...field, required: true }, null);
156
+
157
+ // A valid field SAYS it is valid rather than staying mute (#3222/#3306).
158
+ expect(trigger('choice')).toHaveAttribute('aria-invalid', 'false');
159
+
160
+ submit();
161
+
162
+ const message = await screen.findByText('Choice is required');
163
+ await waitFor(() => expect(trigger('choice')).toHaveAttribute('aria-invalid', 'true'));
164
+ expect(message.id).not.toBe('');
165
+ const describedBy = trigger('choice').getAttribute('aria-describedby') ?? '';
166
+ expect(describedBy.split(/\s+/)).toContain(message.id);
167
+ });
168
+
169
+ it('keeps `name` on Root, where the submitted hidden select is', () => {
170
+ // The whitelist both paths share: `name` is the ONE key Root consumes — it
171
+ // forwards it to the hidden native `<select>` that takes part in form
172
+ // submission. On the trigger it would sit uselessly on a non-submitter.
173
+ renderForm(field);
174
+
175
+ expect(document.querySelector('[data-field="choice"] select[aria-hidden="true"]')).toHaveAttribute(
176
+ 'name',
177
+ 'choice',
178
+ );
179
+ expect(trigger('choice')).not.toHaveAttribute('name');
180
+ });
181
+ });
182
+
183
+ describe('the fork itself (objectui#3976 / #3231)', () => {
184
+ it('a bare `type: select` renders the BUILT-IN branch even though field:select resolves', () => {
185
+ // Measured, not inferred — this is the fact that made #3976 a built-in-side
186
+ // bug rather than a duplicate of #3306: `renderFieldComponent` skips the
187
+ // registry entirely for a `BUILTIN_FIELD_TYPES` member, so `SelectField` is
188
+ // NOT what renders a hand-written `type: 'select'`, even here where the real
189
+ // widget is registered under `field:select`.
190
+ expect(ComponentRegistry.get('field:select')).toBe(SelectField);
191
+
192
+ renderForm({ name: 'choice', label: 'Choice', type: 'select', options: OPTIONS });
193
+
194
+ // `SelectField` stamps this locator on the trigger it renders; the built-in
195
+ // branch does not. Same role, different implementation.
196
+ expect(document.querySelector('[data-testid="select-trigger-choice"]')).toBeNull();
197
+ expect(trigger('choice')).toBeInTheDocument();
198
+ });
199
+ });