@object-ui/fields 17.2.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 +1533 -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 +5 -5
- package/dist/index.css +633 -0
- package/dist/index.d.ts +52 -12
- package/dist/index.js +5167 -4759
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +59 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +49 -7
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- 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 +111 -0
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +20 -16
- 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__/cell-truncation.test.tsx +96 -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__/field-carrier-sdui.test.tsx +169 -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__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- 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 +558 -268
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +107 -38
- package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +44 -10
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +66 -16
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +60 -19
- package/src/widgets/DateField.tsx +13 -4
- package/src/widgets/DateTimeField.tsx +14 -5
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +39 -11
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +7 -2
- package/src/widgets/FullscreenFieldEditor.tsx +60 -0
- package/src/widgets/GeolocationField.tsx +49 -8
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +115 -11
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +63 -6
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +49 -10
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +86 -16
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +99 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +58 -16
- package/src/widgets/RatingField.tsx +32 -4
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +235 -21
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +84 -22
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +4 -3
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/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 +204 -0
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +46 -4
- package/src/widgets/useRecordQuery.ts +30 -3
- package/src/withFieldCarrier.tsx +75 -0
- 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__/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-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/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.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.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/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/SelectField.cascade.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/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
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
* GATE: no field widget may leak a non-DOM prop onto a DOM element
|
|
11
|
+
* (objectui#3291).
|
|
12
|
+
*
|
|
13
|
+
* Every registered field widget is rendered through BOTH hosts, and every
|
|
14
|
+
* attribute of every element it produced is checked against what HTML actually
|
|
15
|
+
* defines. An attribute nobody can explain is a leak.
|
|
16
|
+
*
|
|
17
|
+
* ## What this catches that a unit test cannot
|
|
18
|
+
*
|
|
19
|
+
* The leak was never in one widget. It was structural: a widget spread
|
|
20
|
+
* `{...props}` onto its control, and the hosts hand a widget more than the DOM
|
|
21
|
+
* can take. Measured on `origin/main`, a real form, a real widget:
|
|
22
|
+
*
|
|
23
|
+
* ```
|
|
24
|
+
* <input placeholder="PH-f" zzcanary="CANARY-STR" zzcanaryobj="[object Object]"
|
|
25
|
+
* zzcanarynum="42" id="…" type="text" value="" name="f">
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* `zzcanaryobj="[object Object]"` is an ordinary key an author wrote on the
|
|
29
|
+
* field config. That is why the fix is a WHITELIST (`toDomProps`) and why this
|
|
30
|
+
* test plants canaries rather than checking a list of known-bad names: a
|
|
31
|
+
* blacklist of today's renderer props would pass all three canaries above.
|
|
32
|
+
*
|
|
33
|
+
* ## Four things that silently defeat a test like this
|
|
34
|
+
*
|
|
35
|
+
* 1. **React warnings prove nothing.** React 19 passes an all-lowercase
|
|
36
|
+
* unknown attribute through in COMPLETE silence. In the audit sweep the
|
|
37
|
+
* only warning came from a camelCase canary — which was written to the DOM
|
|
38
|
+
* anyway. So this walks real DOM attributes and never listens for console
|
|
39
|
+
* output.
|
|
40
|
+
* 2. **Both hosts, or the result is a false pass.** The form renderer strips a
|
|
41
|
+
* known set before forwarding; `SchemaRenderer` spreads the whole authored
|
|
42
|
+
* node with NO strip layer, so it leaks strictly more (`label` is only
|
|
43
|
+
* visible there). On that path the widget's own spread is the only defence.
|
|
44
|
+
* 3. **An error variant that produces no error tests nothing.** `required` +
|
|
45
|
+
* a `false` boolean does NOT fail here — this repo made `required` a
|
|
46
|
+
* presence check, so `false` is a value (cloud#972). The audit's first pass
|
|
47
|
+
* under-reported the `error` leak by one widget for exactly this reason.
|
|
48
|
+
* Every error variant therefore ASSERTS the message rendered before it
|
|
49
|
+
* scans; a variant that silently produces no error fails the test.
|
|
50
|
+
* 4. **This repo runs happy-dom, not jsdom** (`vitest.config.mts`), whose IDL
|
|
51
|
+
* coverage has real gaps — `select[size]`, `option[label]`, `textarea[wrap]`
|
|
52
|
+
* and `col[span]` are all standard HTML that happy-dom does not reflect.
|
|
53
|
+
* See `@object-ui/test-support`'s `dom-leak-judge` for how the judge is
|
|
54
|
+
* built, and its `HAPPY_DOM_IDL_GAPS` for each measured exception and its
|
|
55
|
+
* reason. Every one of those four was found BY a calibration fixture, not
|
|
56
|
+
* by guesswork.
|
|
57
|
+
*
|
|
58
|
+
* ## The judge proves itself — next to the judge, not here
|
|
59
|
+
*
|
|
60
|
+
* `isKnownAttribute` / `findLeaks` / `leakReport` are imported from
|
|
61
|
+
* `@object-ui/test-support`, which is where they now live for BOTH DOM-leak
|
|
62
|
+
* gates (objectui#4434). They used to be defined in this file and copied into
|
|
63
|
+
* `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`, and the
|
|
64
|
+
* two copies had already drifted.
|
|
65
|
+
*
|
|
66
|
+
* The calibration fixtures moved with them: standard markup that must yield
|
|
67
|
+
* ZERO findings, and markup with planted fake attributes that must ALL be
|
|
68
|
+
* found, now unioned from both gates' fixtures and run once in
|
|
69
|
+
* `packages/test-support/src/__tests__/dom-leak-judge.test.tsx`. When a
|
|
70
|
+
* happy-dom upgrade changes IDL coverage, that fails loudly instead of this
|
|
71
|
+
* sweep going quietly blind — and it now fails in ONE place instead of needing
|
|
72
|
+
* to be discovered twice.
|
|
73
|
+
*
|
|
74
|
+
* ## Deliberate coverage boundary
|
|
75
|
+
*
|
|
76
|
+
* Popovers are NOT opened. Radix needs pointer-capture APIs happy-dom does not
|
|
77
|
+
* implement. Every widget's props spread lands on its inline control (the
|
|
78
|
+
* trigger for a picker), which always renders — so the spread site IS covered,
|
|
79
|
+
* but content that exists only inside an open dropdown is NOT scanned by this
|
|
80
|
+
* test.
|
|
81
|
+
*
|
|
82
|
+
* Widgets are registered from STATIC imports wrapped in `withFieldCarrier`,
|
|
83
|
+
* never via `registerAllFields()`, which wraps every loader in `React.lazy` —
|
|
84
|
+
* an unbounded module load inside a bounded `waitFor` is this repo's known
|
|
85
|
+
* flake generator (AGENTS.md 测试纪律 / objectui#3010).
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
import type { ComponentType } from 'react';
|
|
89
|
+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
|
90
|
+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
|
91
|
+
import '@testing-library/jest-dom';
|
|
92
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
93
|
+
// Module scope: pulls in the form renderer's registration side effect.
|
|
94
|
+
import '@object-ui/components';
|
|
95
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
96
|
+
// The attribute judge, shared with `packages/app-shell`'s sweep gate
|
|
97
|
+
// (objectui#4434). It used to be defined in this file and copied into that one;
|
|
98
|
+
// its calibration fixtures now live next to it and prove it for both gates.
|
|
99
|
+
// `@object-ui/test-support` is private and never published — see its README.
|
|
100
|
+
import { findLeaks, leakReport } from '@object-ui/test-support';
|
|
101
|
+
|
|
102
|
+
import { withFieldCarrier } from '../withFieldCarrier';
|
|
103
|
+
import { FORM_FIELD_TYPES } from '../index';
|
|
104
|
+
|
|
105
|
+
import { TextField } from '../widgets/TextField';
|
|
106
|
+
import { TextAreaField } from '../widgets/TextAreaField';
|
|
107
|
+
import { NumberField } from '../widgets/NumberField';
|
|
108
|
+
import { BooleanField } from '../widgets/BooleanField';
|
|
109
|
+
import { SelectField } from '../widgets/SelectField';
|
|
110
|
+
import { DateField } from '../widgets/DateField';
|
|
111
|
+
import { DateTimeField } from '../widgets/DateTimeField';
|
|
112
|
+
import { TimeField } from '../widgets/TimeField';
|
|
113
|
+
import { EmailField } from '../widgets/EmailField';
|
|
114
|
+
import { PhoneField } from '../widgets/PhoneField';
|
|
115
|
+
import { UrlField } from '../widgets/UrlField';
|
|
116
|
+
import { MultiSelectField } from '../widgets/MultiSelectField';
|
|
117
|
+
import { RadioField } from '../widgets/RadioField';
|
|
118
|
+
import { CheckboxesField } from '../widgets/CheckboxesField';
|
|
119
|
+
import { TagsField } from '../widgets/TagsField';
|
|
120
|
+
import { CurrencyField } from '../widgets/CurrencyField';
|
|
121
|
+
import { PercentField } from '../widgets/PercentField';
|
|
122
|
+
import { PasswordField } from '../widgets/PasswordField';
|
|
123
|
+
import { RichTextField } from '../widgets/RichTextField';
|
|
124
|
+
import { LookupField } from '../widgets/LookupField';
|
|
125
|
+
import { FileField } from '../widgets/FileField';
|
|
126
|
+
import { ImageField } from '../widgets/ImageField';
|
|
127
|
+
import { LocationField } from '../widgets/LocationField';
|
|
128
|
+
import { FormulaField } from '../widgets/FormulaField';
|
|
129
|
+
import { SummaryField } from '../widgets/SummaryField';
|
|
130
|
+
import { AutoNumberField } from '../widgets/AutoNumberField';
|
|
131
|
+
import { UserField } from '../widgets/UserField';
|
|
132
|
+
import { ObjectField } from '../widgets/ObjectField';
|
|
133
|
+
import { VectorField } from '../widgets/VectorField';
|
|
134
|
+
import { GridField } from '../widgets/GridField';
|
|
135
|
+
import { ColorField } from '../widgets/ColorField';
|
|
136
|
+
import { SliderField } from '../widgets/SliderField';
|
|
137
|
+
import { RatingField } from '../widgets/RatingField';
|
|
138
|
+
import { CodeField } from '../widgets/CodeField';
|
|
139
|
+
import { AvatarField } from '../widgets/AvatarField';
|
|
140
|
+
import { AddressField } from '../widgets/AddressField';
|
|
141
|
+
import { GeolocationField } from '../widgets/GeolocationField';
|
|
142
|
+
import { SignatureField } from '../widgets/SignatureField';
|
|
143
|
+
import { QRCodeField } from '../widgets/QRCodeField';
|
|
144
|
+
import { ObjectRefField } from '../widgets/ObjectRefField';
|
|
145
|
+
import { FilterConditionField } from '../widgets/FilterConditionField';
|
|
146
|
+
import { RecipientPickerField } from '../widgets/RecipientPickerField';
|
|
147
|
+
|
|
148
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
* Every registered field widget, both hosts
|
|
150
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Static components for the widget map's keys. Kept as one object so the
|
|
154
|
+
* parity assertion below can prove it covers the whole registry: a NEW field
|
|
155
|
+
* type added to `fieldWidgetMap` without a line here fails loudly rather than
|
|
156
|
+
* quietly going unscanned.
|
|
157
|
+
*/
|
|
158
|
+
const WIDGETS: Record<string, ComponentType<any>> = {
|
|
159
|
+
text: TextField,
|
|
160
|
+
textarea: TextAreaField,
|
|
161
|
+
number: NumberField,
|
|
162
|
+
boolean: BooleanField,
|
|
163
|
+
select: SelectField,
|
|
164
|
+
date: DateField,
|
|
165
|
+
datetime: DateTimeField,
|
|
166
|
+
time: TimeField,
|
|
167
|
+
email: EmailField,
|
|
168
|
+
phone: PhoneField,
|
|
169
|
+
url: UrlField,
|
|
170
|
+
multiselect: MultiSelectField,
|
|
171
|
+
radio: RadioField,
|
|
172
|
+
checkboxes: CheckboxesField,
|
|
173
|
+
tags: TagsField,
|
|
174
|
+
currency: CurrencyField,
|
|
175
|
+
percent: PercentField,
|
|
176
|
+
password: PasswordField,
|
|
177
|
+
markdown: RichTextField,
|
|
178
|
+
html: RichTextField,
|
|
179
|
+
richtext: RichTextField,
|
|
180
|
+
lookup: LookupField,
|
|
181
|
+
master_detail: LookupField,
|
|
182
|
+
file: FileField,
|
|
183
|
+
image: ImageField,
|
|
184
|
+
location: LocationField,
|
|
185
|
+
formula: FormulaField,
|
|
186
|
+
summary: SummaryField,
|
|
187
|
+
auto_number: AutoNumberField,
|
|
188
|
+
user: UserField,
|
|
189
|
+
owner: UserField,
|
|
190
|
+
object: ObjectField,
|
|
191
|
+
vector: VectorField,
|
|
192
|
+
grid: GridField,
|
|
193
|
+
color: ColorField,
|
|
194
|
+
slider: SliderField,
|
|
195
|
+
rating: RatingField,
|
|
196
|
+
code: CodeField,
|
|
197
|
+
avatar: AvatarField,
|
|
198
|
+
address: AddressField,
|
|
199
|
+
geolocation: GeolocationField,
|
|
200
|
+
signature: SignatureField,
|
|
201
|
+
qrcode: QRCodeField,
|
|
202
|
+
'object-ref': ObjectRefField,
|
|
203
|
+
'filter-condition': FilterConditionField,
|
|
204
|
+
'recipient-picker': RecipientPickerField,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Option widgets render an "unfillable" placeholder unless offered a list. */
|
|
208
|
+
const OPTION_TYPES = new Set(['select', 'multiselect', 'radio', 'checkboxes', 'tags']);
|
|
209
|
+
const OPTIONS = [
|
|
210
|
+
{ label: 'Alpha', value: 'alpha' },
|
|
211
|
+
{ label: 'Beta', value: 'beta' },
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The value each variant starts from: `null` is MISSING for the required check
|
|
216
|
+
* this repo implements (presence, not truthiness — `false` and `0` are values,
|
|
217
|
+
* cloud#972), so one value drives a real validation failure for every type,
|
|
218
|
+
* including `boolean`. Trap 3 above is why that matters, and why every error
|
|
219
|
+
* variant asserts the message before it scans.
|
|
220
|
+
*/
|
|
221
|
+
const MISSING_VALUE = null;
|
|
222
|
+
|
|
223
|
+
/** Author-written extras — the open tail that a blacklist cannot close. */
|
|
224
|
+
const AUTHORED_EXTRAS = {
|
|
225
|
+
zzcanary: 'CANARY-STR',
|
|
226
|
+
zzcanaryobj: { nested: true },
|
|
227
|
+
zzcanarynum: 42,
|
|
228
|
+
zzcanaryCamel: 'CANARY-CAMEL',
|
|
229
|
+
reference_to: 'contacts',
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function fieldConfig(type: string, extras: Record<string, unknown> = {}) {
|
|
233
|
+
return {
|
|
234
|
+
name: 'f',
|
|
235
|
+
label: 'F',
|
|
236
|
+
type: `field:${type}`,
|
|
237
|
+
...(OPTION_TYPES.has(type) ? { options: OPTIONS } : {}),
|
|
238
|
+
...extras,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderForm(field: Record<string, unknown>, required: boolean) {
|
|
243
|
+
const Form = ComponentRegistry.get('form')!;
|
|
244
|
+
return render(
|
|
245
|
+
<Form
|
|
246
|
+
schema={{
|
|
247
|
+
type: 'form',
|
|
248
|
+
mode: 'create',
|
|
249
|
+
showSubmit: true,
|
|
250
|
+
showCancel: false,
|
|
251
|
+
submitLabel: 'Create',
|
|
252
|
+
defaultValues: { f: MISSING_VALUE },
|
|
253
|
+
fields: [{ ...field, required }],
|
|
254
|
+
onSubmit: () => {},
|
|
255
|
+
}}
|
|
256
|
+
/>,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The form row. Waiting on `[data-field]` (emitted by `FormItem` for EVERY
|
|
262
|
+
* field) rather than widget-specific copy keeps the wait condition identical
|
|
263
|
+
* for all 46 widgets and independent of what any one of them renders.
|
|
264
|
+
*/
|
|
265
|
+
async function formRow(): Promise<Element> {
|
|
266
|
+
return waitFor(() => {
|
|
267
|
+
const row = document.querySelector('[data-field="f"]');
|
|
268
|
+
if (!row) throw new Error('field row never rendered');
|
|
269
|
+
return row;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
beforeAll(() => {
|
|
274
|
+
for (const [type, Widget] of Object.entries(WIDGETS)) {
|
|
275
|
+
// `withFieldCarrier` is the real registration seam (objectui#3233) — going
|
|
276
|
+
// around it would test a widget in a shape no host ever produces.
|
|
277
|
+
ComponentRegistry.register(type, withFieldCarrier(Widget) as any, {
|
|
278
|
+
namespace: 'field',
|
|
279
|
+
skipFallback: true,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}, 60000);
|
|
283
|
+
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
if (!(Element.prototype as any).scrollIntoView) {
|
|
286
|
+
(Element.prototype as any).scrollIntoView = () => {};
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
afterEach(() => {
|
|
291
|
+
cleanup();
|
|
292
|
+
vi.restoreAllMocks();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => {
|
|
296
|
+
it('covers every field type the form can render', () => {
|
|
297
|
+
// The gate's reach. Without this, adding a widget adds an unscanned widget.
|
|
298
|
+
expect(Object.keys(WIDGETS).sort()).toEqual([...FORM_FIELD_TYPES].sort());
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const types = Object.keys(WIDGETS);
|
|
302
|
+
|
|
303
|
+
it.each(types)('field:%s — form path, plain field', async (type) => {
|
|
304
|
+
renderForm(fieldConfig(type), false);
|
|
305
|
+
const row = await formRow();
|
|
306
|
+
expect(leakReport(`field:${type} [form/plain]`, findLeaks(row))).toBe('');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it.each(types)('field:%s — form path, author-written extra keys', async (type) => {
|
|
310
|
+
// The largest measured leak source: the form renderer destructures a fixed set
|
|
311
|
+
// of known keys and forwards the rest verbatim, so anything else an author
|
|
312
|
+
// put on the field config arrives at the widget as a prop.
|
|
313
|
+
renderForm(fieldConfig(type, AUTHORED_EXTRAS), false);
|
|
314
|
+
const row = await formRow();
|
|
315
|
+
expect(leakReport(`field:${type} [form/authored-extras]`, findLeaks(row))).toBe('');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it.each(types)('field:%s — form path, after a real validation failure', async (type) => {
|
|
319
|
+
renderForm(fieldConfig(type), true);
|
|
320
|
+
const row = await formRow();
|
|
321
|
+
|
|
322
|
+
fireEvent.click(screen.getByRole('button', { name: /create/i }));
|
|
323
|
+
|
|
324
|
+
// Trap 3: scan only once the error is PROVEN to exist. A variant that
|
|
325
|
+
// silently produced none would otherwise pass while testing nothing.
|
|
326
|
+
await waitFor(() => {
|
|
327
|
+
expect(document.querySelector('[data-field="f"]')?.textContent ?? '').toContain(
|
|
328
|
+
'is required',
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(
|
|
333
|
+
leakReport(`field:${type} [form/validation-error]`, findLeaks(await formRow())),
|
|
334
|
+
).toBe('');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it.each(types)('field:%s — SDUI path, plain node', async (type) => {
|
|
338
|
+
// Strictly wider than the form path: `SchemaRenderer` spreads the whole
|
|
339
|
+
// authored node as props with no strip layer, so the widget's own spread
|
|
340
|
+
// is the only defence here.
|
|
341
|
+
const { container } = render(
|
|
342
|
+
<SchemaRenderer schema={{ ...fieldConfig(type), type: `field:${type}` } as any} />,
|
|
343
|
+
);
|
|
344
|
+
await waitFor(() => expect(container.firstElementChild).toBeTruthy());
|
|
345
|
+
expect(leakReport(`field:${type} [sdui/plain]`, findLeaks(container))).toBe('');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it.each(types)('field:%s — SDUI path, author-written extra keys', async (type) => {
|
|
349
|
+
const { container } = render(
|
|
350
|
+
<SchemaRenderer
|
|
351
|
+
schema={{ ...fieldConfig(type, AUTHORED_EXTRAS), type: `field:${type}` } as any}
|
|
352
|
+
/>,
|
|
353
|
+
);
|
|
354
|
+
await waitFor(() => expect(container.firstElementChild).toBeTruthy());
|
|
355
|
+
expect(leakReport(`field:${type} [sdui/authored-extras]`, findLeaks(container))).toBe('');
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* one (objectstack#4075). Three consequences, one test each below:
|
|
15
15
|
*
|
|
16
16
|
* 1. a key the type does not declare is a compile error, not a silent `any`
|
|
17
|
-
* — which is what
|
|
18
|
-
*
|
|
17
|
+
* — which is what let objectui#3222 rename the validation slot to the
|
|
18
|
+
* spec's `error` with the compiler, not grep, proving no call site was
|
|
19
|
+
* missed;
|
|
19
20
|
* 2. a MISSPELLED prop (`readOnly` for `readonly`) is rejected;
|
|
20
21
|
* 3. the pass-through keys hosts genuinely forward still type-check, and
|
|
21
22
|
* still reach the rendered control at runtime — closing the type must not
|
|
@@ -52,13 +53,15 @@ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
|
|
|
52
53
|
it('rejects keys it does not declare', () => {
|
|
53
54
|
const props = {} as FieldWidgetComponentProps<string>;
|
|
54
55
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
56
|
+
// `required` stays out on purpose (objectui#3222): the required marker has
|
|
57
|
+
// exactly one author, the form renderer's `<FormLabel>`, and handing the
|
|
58
|
+
// flag to widgets invites a second one. Its sibling `error` went the other
|
|
59
|
+
// way — the spec declares it, so the slot below adopted the spec's name and
|
|
60
|
+
// the form renderer now produces it.
|
|
58
61
|
// @ts-expect-error `required` is not part of this contract
|
|
59
62
|
void props.required;
|
|
60
|
-
|
|
61
|
-
void
|
|
63
|
+
const message: string | undefined = props.error;
|
|
64
|
+
void message;
|
|
62
65
|
|
|
63
66
|
// …and the class of bug the index signature hid best: a typo.
|
|
64
67
|
// @ts-expect-error the prop is `readonly`, not React's DOM `readOnly`
|
|
@@ -66,6 +69,14 @@ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
|
|
|
66
69
|
// @ts-expect-error the prop is `onChange`
|
|
67
70
|
void props.onchange;
|
|
68
71
|
|
|
72
|
+
// The second metadata carrier is gone (objectui#3233). `schema` was a
|
|
73
|
+
// DECLARED key here through #3221 — one concept under two spellings, which
|
|
74
|
+
// is how ~30 widgets ended up resolving `field || schema`. v17 converged it
|
|
75
|
+
// at the producers, so reading it is now a compile error rather than a
|
|
76
|
+
// silent second contract for the next widget author to discover.
|
|
77
|
+
// @ts-expect-error `schema` was retired from the widget contract in v17
|
|
78
|
+
void props.schema;
|
|
79
|
+
|
|
69
80
|
expect(true).toBe(true);
|
|
70
81
|
});
|
|
71
82
|
|
|
@@ -76,9 +87,11 @@ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
|
|
|
76
87
|
const passThrough: FieldWidgetComponentProps<string> = {
|
|
77
88
|
value: '',
|
|
78
89
|
onChange: () => {},
|
|
90
|
+
// The single metadata carrier (objectui#3233) — every host produces this
|
|
91
|
+
// one key: the form renderer (`renderFieldComponent`), the inline-edit
|
|
92
|
+
// hosts, and the registry adapter that maps `SchemaRenderer`'s SDUI node
|
|
93
|
+
// onto it.
|
|
79
94
|
field,
|
|
80
|
-
// form renderer (`renderFieldComponent`)
|
|
81
|
-
schema: field,
|
|
82
95
|
dataSource: {},
|
|
83
96
|
dependentValues: { country: 'cn' },
|
|
84
97
|
dependsOn: ['country'],
|
|
@@ -191,9 +191,18 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
191
191
|
expect(mockDataSource.find).toHaveBeenCalledTimes(1);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
// Type in search
|
|
194
|
+
// Type in search. U+2026 now, and it comes from a PACK VALUE:
|
|
195
|
+
// objectui#4375 retired the `common.search` + '...' concatenation
|
|
196
|
+
// this used to pin, so the placeholder is `table.search`
|
|
197
|
+
// — read through `useFieldTranslation`, whose no-provider defaults map
|
|
198
|
+
// serves the `en` byte `Search…` in this test. It therefore moves with
|
|
199
|
+
// the pack from here on, and is covered by objectui#3878's glyph pin.
|
|
200
|
+
// `common.search` itself is retired from the ten packs and from this
|
|
201
|
+
// package's defaults map (objectui#4392); naming it bare rather than
|
|
202
|
+
// spelling a t() call around it is what keeps that retirement's
|
|
203
|
+
// revival pin readable — see common-search-retired-4392.test.ts.
|
|
195
204
|
await act(async () => {
|
|
196
|
-
fireEvent.change(screen.getByPlaceholderText('Search
|
|
205
|
+
fireEvent.change(screen.getByPlaceholderText('Search…'), {
|
|
197
206
|
target: { value: 'acme' },
|
|
198
207
|
});
|
|
199
208
|
});
|
|
@@ -462,7 +471,9 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
462
471
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
463
472
|
});
|
|
464
473
|
|
|
465
|
-
|
|
474
|
+
// U+2026 from the `table.search` pack value — see the note on the
|
|
475
|
+
// `$search` test above (objectui#4375).
|
|
476
|
+
const searchInput = screen.getByPlaceholderText('Search…');
|
|
466
477
|
|
|
467
478
|
// Arrow down twice: -1 → 0 (Alpha) → 1 (Beta)
|
|
468
479
|
await act(async () => {
|
|
@@ -480,9 +491,14 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
480
491
|
expect(onChange).toHaveBeenCalledWith('2');
|
|
481
492
|
});
|
|
482
493
|
|
|
483
|
-
it('resolves reference_to from nested field.field (
|
|
484
|
-
// Simulates
|
|
485
|
-
//
|
|
494
|
+
it('resolves reference_to from nested field.field (form metadata slot)', async () => {
|
|
495
|
+
// Simulates the `field.field` nesting the FORM renderer hands widgets:
|
|
496
|
+
// `renderFieldComponent` passes `field: field.field || field`, where
|
|
497
|
+
// `.field` is the declared metadata slot (objectui#3090) holding the
|
|
498
|
+
// real metadata (reference_to, reference_field, …). The test used to
|
|
499
|
+
// credit this shape to the docs-demo `createFieldRenderer` wrapper,
|
|
500
|
+
// which never produced it and no longer exists (objectui#3910); the
|
|
501
|
+
// live producer is the form path, so the coverage stands.
|
|
486
502
|
const onChange = vi.fn();
|
|
487
503
|
mockDataSource.find.mockResolvedValue({
|
|
488
504
|
data: [
|
package/src/currency.ts
CHANGED
|
@@ -12,3 +12,133 @@
|
|
|
12
12
|
* every field/cell renderer that already depends on it.
|
|
13
13
|
*/
|
|
14
14
|
export { resolveFieldCurrency } from '@object-ui/i18n';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fallback width for a currency whose digit count cannot be resolved: the
|
|
18
|
+
* literal both call sites used unconditionally before objectui#4361. It is
|
|
19
|
+
* reached only when there is no ISO code to ask about (an invalid code, or a
|
|
20
|
+
* runtime with no currency data at all), never as a policy.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_FRACTION_DIGITS = 2;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* ISO 4217 minor-unit width, memoized per currency code.
|
|
26
|
+
*
|
|
27
|
+
* The probe is deliberately made with NO locale. Measured across
|
|
28
|
+
* en-US / de-DE / ja-JP / ar-KW / zh-CN / fr-FR / pl-PL / es-ES and the runtime
|
|
29
|
+
* default, every locale answers the same digit count for the same code (USD 2,
|
|
30
|
+
* JPY 0, KWD 3, BHD 3, CLP 0, ISK 0) — the count comes from CLDR's
|
|
31
|
+
* `currencyData`, which is keyed by the currency, not by who is reading it.
|
|
32
|
+
* Dropping the locale from the probe therefore loses nothing and removes a
|
|
33
|
+
* failure mode: a malformed locale tag makes `Intl.NumberFormat` throw
|
|
34
|
+
* `RangeError: Incorrect locale information provided`, which would turn a bad
|
|
35
|
+
* LOCALE into a wrong CURRENCY width. It also makes the code the whole cache
|
|
36
|
+
* key.
|
|
37
|
+
*/
|
|
38
|
+
const fractionDigitsByCurrency = new Map<string, number>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The number of fraction digits ISO 4217 gives `currency` — 0 for JPY/KRW/CLP,
|
|
42
|
+
* 2 for USD/EUR/CNY, 3 for KWD/BHD/OMR/TND — as ICU reports it.
|
|
43
|
+
*
|
|
44
|
+
* Returns {@link DEFAULT_FRACTION_DIGITS} when the code is not a currency code
|
|
45
|
+
* at all: `Intl` throws `RangeError: Invalid currency code` for those, and the
|
|
46
|
+
* throw must not escape, or a bad code would change the width used by the
|
|
47
|
+
* callers' own bad-currency fallbacks (objectui#4332 pinned those strings). A
|
|
48
|
+
* code that is well-formed but unknown to CLDR (e.g. `ZZZ`) does not throw —
|
|
49
|
+
* ICU answers 2 for anything outside its table, which is the same default.
|
|
50
|
+
*
|
|
51
|
+
* Memoized because the currency cell renderer runs once per grid cell: measured
|
|
52
|
+
* at 24.3us per uncached probe against 0.02us cached (200k iterations, node 22),
|
|
53
|
+
* so a 500-row grid pays ~12ms per render pass for a value that cannot change.
|
|
54
|
+
*/
|
|
55
|
+
export function currencyFractionDigits(currency: string): number {
|
|
56
|
+
const cached = fractionDigitsByCurrency.get(currency);
|
|
57
|
+
if (cached !== undefined) return cached;
|
|
58
|
+
let digits = DEFAULT_FRACTION_DIGITS;
|
|
59
|
+
try {
|
|
60
|
+
// `maximumFractionDigits` is typed `number | undefined` on
|
|
61
|
+
// `ResolvedNumberFormatOptions` — it is absent when a format resolves with
|
|
62
|
+
// significant-digit rounding instead of fraction-digit rounding, which a
|
|
63
|
+
// bare `style: 'currency'` never does. Coalesced rather than asserted so
|
|
64
|
+
// the value is a number by construction and not by claim.
|
|
65
|
+
digits =
|
|
66
|
+
new Intl.NumberFormat(undefined, { style: 'currency', currency }).resolvedOptions()
|
|
67
|
+
.maximumFractionDigits ?? DEFAULT_FRACTION_DIGITS;
|
|
68
|
+
} catch {
|
|
69
|
+
// Not a currency code — keep the historical width and let the caller's own
|
|
70
|
+
// formatting attempt surface the bad code.
|
|
71
|
+
}
|
|
72
|
+
fractionDigitsByCurrency.set(currency, digits);
|
|
73
|
+
return digits;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Display symbols, memoized per **(locale, currency)** pair.
|
|
78
|
+
*
|
|
79
|
+
* The pair, not the code — and this is the one place this helper deliberately
|
|
80
|
+
* parts company with {@link currencyFractionDigits} above. The digit count is
|
|
81
|
+
* locale-independent (measured across eight locales, see that map's note), so
|
|
82
|
+
* its probe drops the locale and keys the cache on the code alone. The SYMBOL
|
|
83
|
+
* is genuinely locale-dependent, measured on the same runtime:
|
|
84
|
+
*
|
|
85
|
+
* - `USD` → `$` at `en` / `en-US` / `de-DE` / `ja-JP`, `US$` at `en-GB` and
|
|
86
|
+
* `zh-CN`, `$US` at `fr-FR`;
|
|
87
|
+
* - `JPY` → U+00A5 at `en`, the FULLWIDTH U+FFE5 at `ja-JP`, `JP¥` at
|
|
88
|
+
* `en-GB`, and the bare code `JPY` at `fr-FR`.
|
|
89
|
+
*
|
|
90
|
+
* Copying the code-only cache key would therefore let whichever locale asked
|
|
91
|
+
* first answer for every locale after it, for the life of the process — a
|
|
92
|
+
* silent wrong answer rather than a crash, so it is pinned by test.
|
|
93
|
+
*
|
|
94
|
+
* `|` is safe as the key separator: it appears in neither a BCP-47 tag nor an
|
|
95
|
+
* ISO 4217 code, so no two distinct pairs can collide on one key.
|
|
96
|
+
*/
|
|
97
|
+
const symbolByLocaleAndCurrency = new Map<string, string>();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The string ICU renders in place of `currency` for a reader in `locale` — `$`
|
|
101
|
+
* for USD at `en`, `€` for EUR, U+00A5 for JPY — taken from the `currency` part
|
|
102
|
+
* of the very format the display path already uses.
|
|
103
|
+
*
|
|
104
|
+
* This is the ONE channel for the symbol (objectui#4414). It is not an
|
|
105
|
+
* independent opinion about how a currency should look: it is the same
|
|
106
|
+
* `Intl.NumberFormat(locale, { style: 'currency', currency })` that
|
|
107
|
+
* `formatDisplayNumber` formats amounts with, read one part at a time. So a
|
|
108
|
+
* widget that renders an amount in one mode and a bare symbol in another cannot
|
|
109
|
+
* disagree with itself — which is exactly what the hand-written
|
|
110
|
+
* `currency === 'USD' ? '$' : currency` ternary did, showing `JPY` beside a
|
|
111
|
+
* readonly `¥1,235`.
|
|
112
|
+
*
|
|
113
|
+
* Falls back to `currency` itself — the code as given — in the three cases
|
|
114
|
+
* where ICU cannot answer, because a bare code is a legitimate adornment (it is
|
|
115
|
+
* what ICU itself returns for KWD/BHD/CHF/ISK, and what
|
|
116
|
+
* `formatAmount`'s own bad-code branch renders beside it):
|
|
117
|
+
*
|
|
118
|
+
* 1. an invalid currency code — `Intl` throws `RangeError: Invalid currency
|
|
119
|
+
* code`;
|
|
120
|
+
* 2. a malformed locale tag (`en_US` with an underscore, say) — `Intl` throws
|
|
121
|
+
* `RangeError: Incorrect locale information provided`. `currencyFractionDigits`
|
|
122
|
+
* dodged this failure mode by dropping the locale from its probe; this
|
|
123
|
+
* helper needs the locale, so it catches instead. Neither throw may escape:
|
|
124
|
+
* this runs during render, so an escaped `RangeError` is a blank widget.
|
|
125
|
+
* 3. no `currency` part in the output at all — not observed for
|
|
126
|
+
* `style: 'currency'`, but coalesced rather than asserted.
|
|
127
|
+
*/
|
|
128
|
+
export function currencySymbol(currency: string, locale: string): string {
|
|
129
|
+
const key = `${locale}|${currency}`;
|
|
130
|
+
const cached = symbolByLocaleAndCurrency.get(key);
|
|
131
|
+
if (cached !== undefined) return cached;
|
|
132
|
+
let symbol = currency;
|
|
133
|
+
try {
|
|
134
|
+
// The formatted value is irrelevant — only the `currency` part is read — so
|
|
135
|
+
// the cheapest deterministic input is used.
|
|
136
|
+
const parts = new Intl.NumberFormat(locale, { style: 'currency', currency }).formatToParts(0);
|
|
137
|
+
symbol = parts.find((part) => part.type === 'currency')?.value ?? currency;
|
|
138
|
+
} catch {
|
|
139
|
+
// Bad code or bad locale — keep the code, and let the caller's own
|
|
140
|
+
// formatting attempt surface the real problem.
|
|
141
|
+
}
|
|
142
|
+
symbolByLocaleAndCurrency.set(key, symbol);
|
|
143
|
+
return symbol;
|
|
144
|
+
}
|