@object-ui/fields 17.5.0 → 17.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +6150 -0
  2. package/README.md +108 -8
  3. package/dist/FieldEditWidget.d.ts +70 -8
  4. package/dist/coerceToSafeValue.d.ts +54 -0
  5. package/dist/currency.d.ts +1 -1
  6. package/dist/field-type-alias.d.ts +16 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.css +53 -11
  9. package/dist/index.d.ts +653 -128
  10. package/dist/index.js +4275 -3171
  11. package/dist/widgets/AddressField.d.ts +3 -3
  12. package/dist/widgets/AutoNumberField.d.ts +1 -1
  13. package/dist/widgets/AvatarField.d.ts +20 -4
  14. package/dist/widgets/BooleanField.d.ts +2 -2
  15. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  16. package/dist/widgets/CheckboxesField.d.ts +1 -1
  17. package/dist/widgets/CodeField.d.ts +1 -1
  18. package/dist/widgets/ColorField.d.ts +1 -1
  19. package/dist/widgets/CurrencyField.d.ts +1 -1
  20. package/dist/widgets/DateField.d.ts +2 -2
  21. package/dist/widgets/DateTimeField.d.ts +2 -2
  22. package/dist/widgets/EmailField.d.ts +1 -1
  23. package/dist/widgets/FileField.d.ts +18 -2
  24. package/dist/widgets/FilterConditionField.d.ts +43 -2
  25. package/dist/widgets/FormulaField.d.ts +1 -1
  26. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  27. package/dist/widgets/GeolocationField.d.ts +10 -4
  28. package/dist/widgets/GridField.d.ts +102 -86
  29. package/dist/widgets/ImageField.d.ts +1 -1
  30. package/dist/widgets/LocationField.d.ts +31 -4
  31. package/dist/widgets/LookupField.d.ts +9 -1
  32. package/dist/widgets/MasterDetailField.d.ts +1 -1
  33. package/dist/widgets/MultiSelectField.d.ts +1 -1
  34. package/dist/widgets/NumberField.d.ts +2 -2
  35. package/dist/widgets/ObjectField.d.ts +10 -3
  36. package/dist/widgets/ObjectRefField.d.ts +1 -1
  37. package/dist/widgets/OptionsEmptyState.d.ts +38 -8
  38. package/dist/widgets/PasswordField.d.ts +15 -1
  39. package/dist/widgets/PeoplePicker.d.ts +17 -2
  40. package/dist/widgets/PercentField.d.ts +3 -2
  41. package/dist/widgets/PersonRow.d.ts +13 -2
  42. package/dist/widgets/PhoneField.d.ts +1 -1
  43. package/dist/widgets/QRCodeField.d.ts +1 -1
  44. package/dist/widgets/RadioField.d.ts +1 -1
  45. package/dist/widgets/RatingField.d.ts +1 -1
  46. package/dist/widgets/RecipientPickerField.d.ts +44 -1
  47. package/dist/widgets/RecordPickerDialog.d.ts +33 -16
  48. package/dist/widgets/RichTextField.d.ts +37 -1
  49. package/dist/widgets/SelectField.d.ts +1 -1
  50. package/dist/widgets/SelectionTray.d.ts +13 -2
  51. package/dist/widgets/SignatureField.d.ts +32 -2
  52. package/dist/widgets/SliderField.d.ts +37 -2
  53. package/dist/widgets/SummaryField.d.ts +1 -1
  54. package/dist/widgets/TagsField.d.ts +1 -1
  55. package/dist/widgets/TextAreaField.d.ts +6 -2
  56. package/dist/widgets/TextField.d.ts +2 -2
  57. package/dist/widgets/TimeField.d.ts +2 -2
  58. package/dist/widgets/UrlField.d.ts +1 -1
  59. package/dist/widgets/UserField.d.ts +1 -1
  60. package/dist/widgets/VectorField.d.ts +1 -1
  61. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  62. package/dist/widgets/address-format.d.ts +27 -7
  63. package/dist/widgets/file-affordance.d.ts +33 -0
  64. package/dist/widgets/file-size-guard.d.ts +5 -23
  65. package/dist/widgets/file-value.d.ts +38 -9
  66. package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
  67. package/dist/widgets/nativeDateValue.d.ts +9 -27
  68. package/dist/widgets/number-format.d.ts +137 -0
  69. package/dist/widgets/numberBadInput.d.ts +135 -0
  70. package/dist/widgets/personDisplay.d.ts +22 -4
  71. package/dist/widgets/richTextDisplay.d.ts +116 -0
  72. package/dist/widgets/toDomProps.d.ts +10 -1
  73. package/dist/widgets/toHostGroupProps.d.ts +172 -21
  74. package/dist/widgets/toHostProps.d.ts +105 -0
  75. package/dist/widgets/types.d.ts +19 -6
  76. package/dist/widgets/upload-error-message.d.ts +6 -0
  77. package/dist/widgets/uploadingScope.d.ts +142 -0
  78. package/dist/widgets/useCascadingOptions.d.ts +3 -2
  79. package/dist/widgets/useRecordQuery.d.ts +4 -2
  80. package/dist/widgets/useUploadingSignal.d.ts +8 -0
  81. package/package.json +11 -13
  82. package/dist/widgets/CharacterCount.d.ts +0 -56
  83. package/src/FieldEditWidget.test.ts +0 -133
  84. package/src/FieldEditWidget.tsx +0 -207
  85. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  86. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  87. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  88. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  89. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  90. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  91. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  92. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  93. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  94. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  95. package/src/__tests__/cell-truncation.test.tsx +0 -96
  96. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  97. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  98. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  99. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  100. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  101. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  102. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  103. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  104. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  105. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  106. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  107. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  108. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  109. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  110. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  111. package/src/__tests__/registry-collision.test.ts +0 -91
  112. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  113. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  114. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  115. package/src/__tests__/validation-feedback.test.tsx +0 -361
  116. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  117. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  118. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  119. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  120. package/src/coerce-safe-value.test.ts +0 -34
  121. package/src/complex-widgets.test.tsx +0 -924
  122. package/src/currency.ts +0 -144
  123. package/src/datetime-widgets.test.tsx +0 -185
  124. package/src/field-type-alias.multiple.test.ts +0 -81
  125. package/src/field-type-alias.ts +0 -156
  126. package/src/field-type-coverage.test.ts +0 -67
  127. package/src/index.css +0 -44
  128. package/src/index.tsx +0 -2745
  129. package/src/standard-widgets.test.tsx +0 -364
  130. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  131. package/src/widgets/AddressField.tsx +0 -182
  132. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  133. package/src/widgets/AutoNumberField.tsx +0 -18
  134. package/src/widgets/AvatarField.tsx +0 -117
  135. package/src/widgets/BooleanField.tsx +0 -86
  136. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  137. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  138. package/src/widgets/CharacterCount.tsx +0 -263
  139. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  140. package/src/widgets/CheckboxesField.tsx +0 -169
  141. package/src/widgets/CodeField.tsx +0 -41
  142. package/src/widgets/ColorField.tsx +0 -53
  143. package/src/widgets/CurrencyField.tsx +0 -135
  144. package/src/widgets/DateField.tsx +0 -40
  145. package/src/widgets/DateTimeField.tsx +0 -46
  146. package/src/widgets/EmailField.tsx +0 -37
  147. package/src/widgets/FileField.tsx +0 -512
  148. package/src/widgets/FilterConditionField.tsx +0 -454
  149. package/src/widgets/FormulaField.tsx +0 -38
  150. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  151. package/src/widgets/GeolocationField.tsx +0 -193
  152. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  153. package/src/widgets/GridField.test.tsx +0 -632
  154. package/src/widgets/GridField.tsx +0 -1070
  155. package/src/widgets/ImageCropperDialog.tsx +0 -245
  156. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  157. package/src/widgets/ImageField.tsx +0 -305
  158. package/src/widgets/ImageLightbox.test.tsx +0 -52
  159. package/src/widgets/ImageLightbox.tsx +0 -108
  160. package/src/widgets/LocationField.tsx +0 -57
  161. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  162. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  163. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  164. package/src/widgets/LookupField.idField.test.tsx +0 -61
  165. package/src/widgets/LookupField.tsx +0 -1295
  166. package/src/widgets/MarkdownContent.tsx +0 -19
  167. package/src/widgets/MasterDetailField.tsx +0 -131
  168. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  169. package/src/widgets/MultiSelectField.tsx +0 -196
  170. package/src/widgets/NumberField.tsx +0 -49
  171. package/src/widgets/ObjectField.tsx +0 -94
  172. package/src/widgets/ObjectRefField.tsx +0 -121
  173. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  174. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  175. package/src/widgets/OptionsEmptyState.tsx +0 -99
  176. package/src/widgets/PasswordField.tsx +0 -49
  177. package/src/widgets/PeoplePicker.test.tsx +0 -360
  178. package/src/widgets/PeoplePicker.tsx +0 -579
  179. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  180. package/src/widgets/PercentField.tsx +0 -95
  181. package/src/widgets/PersonRow.tsx +0 -117
  182. package/src/widgets/PhoneField.tsx +0 -37
  183. package/src/widgets/QRCodeField.tsx +0 -103
  184. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  185. package/src/widgets/RadioField.tsx +0 -143
  186. package/src/widgets/RatingField.tsx +0 -94
  187. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  188. package/src/widgets/RecipientPickerField.tsx +0 -188
  189. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  190. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  191. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  192. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  193. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  194. package/src/widgets/RichTextField.tsx +0 -176
  195. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  196. package/src/widgets/SelectField.tsx +0 -193
  197. package/src/widgets/SelectionTray.tsx +0 -110
  198. package/src/widgets/SignatureField.tsx +0 -140
  199. package/src/widgets/SliderField.tsx +0 -39
  200. package/src/widgets/SummaryField.tsx +0 -31
  201. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  202. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  203. package/src/widgets/TagsField.tsx +0 -88
  204. package/src/widgets/TextAreaField.tsx +0 -213
  205. package/src/widgets/TextField.tsx +0 -45
  206. package/src/widgets/TimeField.tsx +0 -31
  207. package/src/widgets/UrlField.tsx +0 -46
  208. package/src/widgets/UserField.tsx +0 -59
  209. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  210. package/src/widgets/VectorField.tsx +0 -29
  211. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  212. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  213. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  214. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  215. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  216. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  217. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  218. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  219. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  220. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  221. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  222. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  223. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  224. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  225. package/src/widgets/address-format.ts +0 -120
  226. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  227. package/src/widgets/deriveLookupColumns.ts +0 -108
  228. package/src/widgets/file-size-guard.ts +0 -55
  229. package/src/widgets/file-value.test.ts +0 -230
  230. package/src/widgets/file-value.ts +0 -246
  231. package/src/widgets/nativeDateValue.test.ts +0 -115
  232. package/src/widgets/nativeDateValue.ts +0 -112
  233. package/src/widgets/openNativePicker.ts +0 -18
  234. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  235. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  236. package/src/widgets/personDisplay.test.ts +0 -113
  237. package/src/widgets/personDisplay.ts +0 -92
  238. package/src/widgets/recentLookups.test.ts +0 -45
  239. package/src/widgets/recentLookups.ts +0 -47
  240. package/src/widgets/toDomProps.ts +0 -204
  241. package/src/widgets/toHostGroupProps.ts +0 -102
  242. package/src/widgets/types.ts +0 -242
  243. package/src/widgets/useCascadingOptions.ts +0 -46
  244. package/src/widgets/useFieldTranslation.ts +0 -132
  245. package/src/widgets/useRecordQuery.test.ts +0 -174
  246. package/src/widgets/useRecordQuery.ts +0 -291
  247. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  248. package/src/widgets/useUploadingSignal.ts +0 -20
  249. package/src/withFieldCarrier.tsx +0 -75
@@ -1,95 +0,0 @@
1
- import React from 'react';
2
- import { Input, Slider, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
- import { toDomProps } from './toDomProps';
5
-
6
- /**
7
- * PercentField - Percentage input with configurable decimal precision
8
- * Stores values as decimals (0-1) and displays as percentages (0-100%)
9
- * Includes a slider for interactive control.
10
- */
11
- export function PercentField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
12
- const percentField = field as any;
13
- const precision = percentField?.precision ?? 2;
14
-
15
- // Convention detection. A field declaring `max > 1` (e.g. `max: 100`) stores
16
- // WHOLE-NUMBER percents (0–100); otherwise values are FRACTIONS (0–1) shown
17
- // as 0–100%. This matches the read-side formatter so the edit widget agrees
18
- // with display — and, crucially, keeps the rendered <input> within its `max`
19
- // (a whole-number 50 must show "50", not "5000", or HTML5 constraint
20
- // validation marks the field `:invalid` and blocks the whole form's submit).
21
- const maxAttr = typeof percentField?.max === 'number' ? (percentField.max as number) : undefined;
22
- const whole = maxAttr != null && maxAttr > 1;
23
- const toDisplay = (v: number) => (whole ? v : v * 100);
24
- const fromDisplay = (n: number) => (whole ? n : n / 100);
25
- const sliderMax = whole ? maxAttr! : 100;
26
-
27
- if (readonly) {
28
- if (value == null) return <EmptyValue />;
29
- return (
30
- <span className="text-sm font-medium tabular-nums">
31
- {toDisplay(value).toFixed(precision)}%
32
- </span>
33
- );
34
- }
35
-
36
- // Convert between stored value and 0–100 display value
37
- const displayValue = value != null ? toDisplay(value) : '';
38
- const sliderValue = value != null ? toDisplay(value) : 0;
39
-
40
- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
41
- if (e.target.value === '') {
42
- onChange(null as any);
43
- return;
44
- }
45
- const parsed = parseFloat(e.target.value);
46
- const val = isNaN(parsed) ? null : fromDisplay(parsed);
47
- onChange(val as any);
48
- };
49
-
50
- const handleSliderChange = (values: number[]) => {
51
- if (readonly || props.disabled) return;
52
- if (!Array.isArray(values) || values.length === 0) {
53
- onChange(null as any);
54
- return;
55
- }
56
- const raw = values[0];
57
- const nextValue = typeof raw === 'number' ? fromDisplay(raw) : null;
58
- onChange(nextValue as any);
59
- };
60
-
61
- // Derive slider step from precision so slider granularity matches the input
62
- const sliderStep = Math.pow(10, -precision);
63
-
64
- return (
65
- <div className="space-y-2">
66
- <div className="relative">
67
- <Input
68
- {...toDomProps(props)}
69
- type="number"
70
- value={displayValue}
71
- onChange={handleChange}
72
- placeholder={percentField?.placeholder || '0'}
73
- disabled={readonly || props.disabled}
74
- className={`pr-8 ${className || ''}`}
75
- step={Math.pow(10, -precision).toFixed(precision)}
76
- aria-invalid={!!error}
77
- />
78
- <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
79
- %
80
- </span>
81
- </div>
82
- <Slider
83
- value={[sliderValue]}
84
- onValueChange={handleSliderChange}
85
- min={0}
86
- max={sliderMax}
87
- step={sliderStep}
88
- disabled={readonly || props.disabled}
89
- className="w-full"
90
- aria-label="Percentage"
91
- data-testid="percent-slider"
92
- />
93
- </div>
94
- );
95
- }
@@ -1,117 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import React, { useEffect, useRef } from 'react';
10
- import { Avatar, AvatarFallback, AvatarImage, cn } from '@object-ui/components';
11
- import { Check } from 'lucide-react';
12
- import {
13
- getPersonName,
14
- getPersonInitials,
15
- getPersonSubtitle,
16
- getPersonAvatarUrl,
17
- matchRanges,
18
- } from './personDisplay';
19
-
20
- /**
21
- * A rich, single-line candidate row for the search-first PeoplePicker:
22
- * avatar (image with initials fallback) + name + subtitle (department · email).
23
- * The subtitle is what lets search stand in for an org tree — it disambiguates
24
- * same-named people inline. The typed query is highlighted in both lines.
25
- * Selectable/toggleable; a Check marks the selected state, and `active` reflects
26
- * the keyboard cursor (scrolls itself into view).
27
- */
28
- export interface PersonRowProps {
29
- record: any;
30
- displayField?: string;
31
- subtitleFields?: string[];
32
- avatarField?: string;
33
- selected?: boolean;
34
- /** Keyboard cursor is on this row — highlight + scroll into view. */
35
- active?: boolean;
36
- /** Typed search term; matches are highlighted in name + subtitle. */
37
- highlightQuery?: string;
38
- onSelect?: (record: any) => void;
39
- className?: string;
40
- }
41
-
42
- /** Wrap literal matches of `query` in a subtle highlight. */
43
- function Highlighted({ text, query }: { text: string; query?: string }): React.ReactElement {
44
- const ranges = query ? matchRanges(text, query) : [];
45
- if (ranges.length === 0) return <>{text}</>;
46
- const out: React.ReactNode[] = [];
47
- let cursor = 0;
48
- ranges.forEach(([start, end], i) => {
49
- if (start > cursor) out.push(text.slice(cursor, start));
50
- out.push(
51
- <mark key={i} className="rounded-[2px] bg-primary/20 px-px text-inherit">
52
- {text.slice(start, end)}
53
- </mark>,
54
- );
55
- cursor = end;
56
- });
57
- if (cursor < text.length) out.push(text.slice(cursor));
58
- return <>{out}</>;
59
- }
60
-
61
- export function PersonRow({
62
- record,
63
- displayField = 'name',
64
- subtitleFields,
65
- avatarField = 'image',
66
- selected = false,
67
- active = false,
68
- highlightQuery,
69
- onSelect,
70
- className,
71
- }: PersonRowProps) {
72
- const name = getPersonName(record, displayField);
73
- const subtitle = getPersonSubtitle(record, subtitleFields);
74
- const avatarUrl = getPersonAvatarUrl(record, avatarField);
75
- const initials = getPersonInitials(name);
76
-
77
- const ref = useRef<HTMLButtonElement>(null);
78
- useEffect(() => {
79
- // Optional-call the method too — jsdom doesn't implement scrollIntoView.
80
- if (active) ref.current?.scrollIntoView?.({ block: 'nearest' });
81
- }, [active]);
82
-
83
- return (
84
- <button
85
- ref={ref}
86
- type="button"
87
- tabIndex={-1}
88
- onClick={() => onSelect?.(record)}
89
- aria-pressed={selected}
90
- data-testid="person-row"
91
- data-active={active || undefined}
92
- className={cn(
93
- 'flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left transition-colors',
94
- 'hover:bg-accent focus-visible:outline-none',
95
- selected && 'bg-accent',
96
- active && 'bg-accent ring-1 ring-inset ring-ring',
97
- className,
98
- )}
99
- >
100
- <Avatar className="size-9 shrink-0">
101
- {avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
102
- <AvatarFallback className="text-xs">{initials}</AvatarFallback>
103
- </Avatar>
104
- <div className="min-w-0 flex-1">
105
- <div className="truncate text-sm font-medium">
106
- {name ? <Highlighted text={name} query={highlightQuery} /> : '—'}
107
- </div>
108
- {subtitle && (
109
- <div className="truncate text-xs text-muted-foreground">
110
- <Highlighted text={subtitle} query={highlightQuery} />
111
- </div>
112
- )}
113
- </div>
114
- {selected && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
115
- </button>
116
- );
117
- }
@@ -1,37 +0,0 @@
1
- import React from 'react';
2
- import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
- import { toDomProps } from './toDomProps';
5
-
6
- /**
7
- * PhoneField - Telephone number input with tel link in readonly mode
8
- * Renders as a clickable tel link when readonly, standard phone input otherwise
9
- */
10
- export function PhoneField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
11
- const config = field;
12
- if (readonly) {
13
- if (!value) return <EmptyValue />;
14
- return (
15
- <a
16
- href={`tel:${value}`}
17
- className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
18
- >
19
- {value}
20
- </a>
21
- );
22
- }
23
-
24
- const domProps = toDomProps(props);
25
-
26
- return (
27
- <Input
28
- {...domProps}
29
- type="tel"
30
- value={value || ''}
31
- onChange={(e) => onChange(e.target.value)}
32
- placeholder={config?.placeholder || '(555) 123-4567'}
33
- disabled={readonly || domProps.disabled}
34
- aria-invalid={!!error}
35
- />
36
- );
37
- }
@@ -1,103 +0,0 @@
1
- import React from 'react';
2
- import { Input, Button, EmptyValue } from '@object-ui/components';
3
- import { QrCode, Copy } from 'lucide-react';
4
- import { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
-
7
- /**
8
- * QR Code field widget - generates QR codes from text
9
- * Uses a simple SVG-based QR code generator
10
- */
11
- export function QRCodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
12
- const [showQR, setShowQR] = React.useState(false);
13
- const config = field;
14
-
15
- // Simple QR code generation using an external library would be ideal
16
- // For now, we'll use a service API approach or placeholder
17
- const getQRCodeUrl = (text: string): string => {
18
- // Using a simple QR code API service (you might want to replace this with a library)
19
- return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(text)}`;
20
- };
21
-
22
- const copyToClipboard = () => {
23
- if (value) {
24
- navigator.clipboard.writeText(value);
25
- }
26
- };
27
-
28
- if (readonly) {
29
- return (
30
- <div className="flex items-center gap-3">
31
- <span className="text-sm flex-1 truncate">{value || <EmptyValue />}</span>
32
- {value && (
33
- <Button
34
- type="button"
35
- variant="outline"
36
- size="sm"
37
- onClick={() => setShowQR(!showQR)}
38
- >
39
- <QrCode className="w-4 h-4 mr-2" />
40
- {showQR ? 'Hide' : 'Show'} QR
41
- </Button>
42
- )}
43
- </div>
44
- );
45
- }
46
-
47
- return (
48
- <div className="space-y-3">
49
- <div className="flex items-center gap-2">
50
- <Input
51
- // DOM pass-through onto the real focusable control (objectui#3318).
52
- {...toDomProps(props)}
53
- type="text"
54
- value={value || ''}
55
- onChange={(e) => onChange(e.target.value)}
56
- placeholder={config?.placeholder || 'Enter text for QR code'}
57
- disabled={readonly || props.disabled}
58
- className={props.className}
59
- // AFTER the spread so this widget's own computation wins: `error`
60
- // is the published validation slot (#3222), `!!undefined` →
61
- // explicit "false".
62
- aria-invalid={!!error}
63
- />
64
- {value && (
65
- <>
66
- <Button
67
- type="button"
68
- variant="outline"
69
- size="sm"
70
- onClick={copyToClipboard}
71
- >
72
- <Copy className="w-4 h-4" />
73
- </Button>
74
- <Button
75
- type="button"
76
- variant="outline"
77
- size="sm"
78
- onClick={() => setShowQR(!showQR)}
79
- >
80
- <QrCode className="w-4 h-4" />
81
- </Button>
82
- </>
83
- )}
84
- </div>
85
-
86
- {showQR && value && (
87
- <div className="border rounded p-4 bg-white flex justify-center">
88
- <img
89
- src={getQRCodeUrl(value)}
90
- alt="QR Code"
91
- className="w-48 h-48"
92
- />
93
- </div>
94
- )}
95
-
96
- {value && (
97
- <p className="text-xs text-muted-foreground">
98
- QR code contains: {value.length} characters
99
- </p>
100
- )}
101
- </div>
102
- );
103
- }
@@ -1,141 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- /**
10
- * Cascading / role-gated `radio` options (#2715) — parity with the single
11
- * `SelectField` (ADR-0058 / #2284).
12
- *
13
- * Exercises the observable widget behaviour: the dependency gate (a "select the
14
- * parent first" empty-state) and the scalar cascade clear (a value dropped when
15
- * the offered set no longer includes it). The per-option filtering itself is
16
- * unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
17
- * radio group wires `dependentValues` + predicate scope into that resolver via
18
- * the shared `useCascadingOptions` hook.
19
- */
20
- import { describe, it, expect, vi, beforeEach } from 'vitest';
21
- import React from 'react';
22
- import { render, screen } from '@testing-library/react';
23
- import '@testing-library/jest-dom';
24
- import { PredicateScopeProvider } from '@object-ui/react';
25
- import { RadioField } from './RadioField';
26
-
27
- const provinceField = {
28
- name: 'province',
29
- type: 'radio',
30
- dependsOn: 'country',
31
- options: [
32
- { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
33
- { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
34
- ],
35
- } as any;
36
-
37
- beforeEach(() => {
38
- vi.clearAllMocks();
39
- });
40
-
41
- describe('RadioField — dependency gating (#2715)', () => {
42
- it('gates with a "select parent first" hint while the controlling field is empty', () => {
43
- render(
44
- <RadioField
45
- value={undefined}
46
- onChange={vi.fn()}
47
- field={provinceField}
48
- {...({ name: 'province', dependentValues: {} } as any)}
49
- />,
50
- );
51
- const gate = screen.getByTestId('radio-empty-province');
52
- expect(gate).toHaveTextContent(/select country first/i);
53
- // Gated → no radios offered.
54
- expect(screen.queryAllByRole('radio')).toHaveLength(0);
55
- });
56
-
57
- it('unlocks the radios once the controlling field is set', () => {
58
- render(
59
- <RadioField
60
- value={undefined}
61
- onChange={vi.fn()}
62
- field={provinceField}
63
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
64
- />,
65
- );
66
- expect(screen.queryByTestId('radio-empty-province')).not.toBeInTheDocument();
67
- // Only the `cn` option is offered.
68
- expect(screen.getByRole('radio', { name: 'Zhejiang' })).toBeInTheDocument();
69
- expect(screen.queryByRole('radio', { name: 'California' })).not.toBeInTheDocument();
70
- });
71
- });
72
-
73
- describe('RadioField — cascade clear (#2715)', () => {
74
- it('clears a value the parent change no longer offers', () => {
75
- const onChange = vi.fn();
76
- render(
77
- <RadioField
78
- value={'ca'}
79
- onChange={onChange}
80
- field={provinceField}
81
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
82
- />,
83
- );
84
- // 'ca' is a US province — under country=cn it is not offered, so it is dropped.
85
- expect(onChange).toHaveBeenCalledWith(undefined);
86
- });
87
-
88
- it('keeps a value that is still offered', () => {
89
- const onChange = vi.fn();
90
- render(
91
- <RadioField
92
- value={'zj'}
93
- onChange={onChange}
94
- field={provinceField}
95
- {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
96
- />,
97
- );
98
- expect(onChange).not.toHaveBeenCalled();
99
- });
100
- });
101
-
102
- describe('RadioField — role / context gating (#2715)', () => {
103
- const tierField = {
104
- name: 'tier',
105
- type: 'radio',
106
- options: [
107
- { label: 'Standard', value: 'standard' },
108
- { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
109
- ],
110
- } as any;
111
-
112
- it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
113
- const onChange = vi.fn();
114
- render(
115
- <PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
116
- <RadioField
117
- value={'admin_only'}
118
- onChange={onChange}
119
- field={tierField}
120
- {...({ name: 'tier', dependentValues: {} } as any)}
121
- />
122
- </PredicateScopeProvider>,
123
- );
124
- expect(onChange).toHaveBeenCalledWith(undefined);
125
- });
126
-
127
- it('keeps an admin-only value for an admin', () => {
128
- const onChange = vi.fn();
129
- render(
130
- <PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
131
- <RadioField
132
- value={'admin_only'}
133
- onChange={onChange}
134
- field={tierField}
135
- {...({ name: 'tier', dependentValues: {} } as any)}
136
- />
137
- </PredicateScopeProvider>,
138
- );
139
- expect(onChange).not.toHaveBeenCalled();
140
- });
141
- });
@@ -1,143 +0,0 @@
1
- import React, { useId, useEffect } from 'react';
2
- import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
3
- import { isValueStillOffered, type OptionLike } from '@object-ui/core';
4
- import { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
- import { toHostGroupProps } from './toHostGroupProps';
7
- import { OptionsEmptyState } from './OptionsEmptyState';
8
- import { useCascadingOptions } from './useCascadingOptions';
9
-
10
- type Option = OptionLike;
11
-
12
- /**
13
- * RadioField - choose exactly one value from a fixed option set, rendered as a
14
- * radio group. The stored value is the selected option value (string). Used for
15
- * the `radio` field type.
16
- *
17
- * Like the single `SelectField`, options support per-option `visibleWhen`
18
- * cascading + `dependsOn` gating (ADR-0058 / #2715), resolved through the shared
19
- * {@link useCascadingOptions} hook: the offered radios narrow against the live
20
- * form record + `current_user`, the control is gated behind a "select the parent
21
- * first" hint while a dependency is empty, and a value no longer offered (parent
22
- * changed / predicate flipped) is cleared.
23
- */
24
- export function RadioField({
25
- value,
26
- onChange,
27
- field,
28
- readonly,
29
- className,
30
- dependentValues,
31
- dependsOn: dependsOnProp,
32
- emptyHint,
33
- dataSource: _dataSource,
34
- error,
35
- ...props
36
- }: FieldWidgetComponentProps<string>) {
37
- const config = field as any;
38
- const rawOptions: Option[] = config?.options || [];
39
- const groupId = useId();
40
- const fieldName = props.name || config?.name || props.id || '';
41
-
42
- const dependsOn = config?.dependsOn ?? dependsOnProp;
43
- const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
44
- rawOptions,
45
- dependsOn,
46
- dependentValues,
47
- );
48
-
49
- // Cascade clear: once the offered set no longer includes the current value
50
- // (parent changed / predicate flipped), drop it so no stale pair persists.
51
- useEffect(() => {
52
- if (readonly) return;
53
- // Never configured → nothing to prune against; see `MultiSelectField`'s
54
- // copy of this guard for the measured failure (objectui#4220).
55
- if (rawOptions.length === 0) return;
56
- // Gated → the authored list is withheld until the `dependsOn` parent is
57
- // chosen, so the empty offered set is missing information, not a verdict on
58
- // the stored value; clearing here fired on mount (objectui#4247). Same
59
- // reasoning, at length, in `MultiSelectField`.
60
- if (gated) return;
61
- if (value === undefined || value === null || (value as unknown) === '') return;
62
- if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
63
- // eslint-disable-next-line react-hooks/exhaustive-deps
64
- }, [options, gated]);
65
-
66
- // The host's group label has to be consumed on EVERY surface this widget can
67
- // render, not only the `RadioGroup` (objectui#3990): both branches below return
68
- // before that spread, which is how a field-level `readonly: true` and a
69
- // zero-option list ended up with a published label id and no consumer at all.
70
- // See `toHostGroupProps` — note the role it emits is `group`, not the
71
- // `radiogroup` the editable branch keeps: there is not one radio left in
72
- // either of these surfaces.
73
- const hostGroupProps = toHostGroupProps(props);
74
-
75
- if (readonly) {
76
- // `EmptyValue`'s own `aria-label` ("No value") is outranked by
77
- // `aria-labelledby` per accname, and on the `generic` role that placeholder
78
- // span carries it was never exposed as a name anyway.
79
- if (value == null || value === '') return <EmptyValue {...hostGroupProps} />;
80
- // Label from the raw set so a stored value hidden by `visibleWhen` still
81
- // renders its label rather than a bare id.
82
- const opt = rawOptions.find((o) => o.value === value);
83
- return <span {...hostGroupProps} className="text-sm">{opt?.label || String(value)}</span>;
84
- }
85
-
86
- // No offered options is unfillable — surface a legible state instead of an
87
- // empty radio group: the host's `emptyHint` when it computed one, else this
88
- // widget's own translated copy. Shared with the select / multiselect /
89
- // checkboxes so the four cannot drift again (objectui#3231).
90
- if (options.length === 0) {
91
- return (
92
- <OptionsEmptyState
93
- emptyHint={emptyHint}
94
- gated={gated}
95
- dependsOnFields={dependsOnFields}
96
- testId={fieldName ? `radio-empty-${fieldName}` : undefined}
97
- className="min-h-9"
98
- // This box IS the field in this state, so it is what the host label
99
- // names (objectui#3990).
100
- hostGroupProps={hostGroupProps}
101
- />
102
- );
103
- }
104
-
105
- return (
106
- // DOM pass-through onto the radiogroup (objectui#3318). It also carries the
107
- // host's group label: when the form renderer associates its `<FormLabel>` by
108
- // IDREF (`aria-labelledby`, objectui#3961) that key rides in this same
109
- // whitelist spread — `toDomProps` forwards the whole `aria-` family — so this
110
- // widget needs no branch of its own. `radiogroup` is already a member of the
111
- // group role family, so nothing here overrides it with `role="group"`: it is
112
- // the more specific role AND the correct carrier of `aria-invalid` below.
113
- //
114
- // Unlike Radix
115
- // `Select.Root` (#3306) this Root IS a real DOM element — a
116
- // `<div role="radiogroup">` — and `radiogroup` is exactly the role
117
- // WAI-ARIA designates to carry `aria-invalid` for a set of radios
118
- // (`radio` itself does not support it): the group's state is announced
119
- // when focus lands on any radio inside it.
120
- <RadioGroup
121
- {...toDomProps(props)}
122
- value={value ?? ''}
123
- onValueChange={onChange}
124
- disabled={props.disabled}
125
- className={className}
126
- // AFTER the spread so this widget's own computation wins (#3222).
127
- aria-invalid={!!error}
128
- >
129
- {options.map((opt) => {
130
- // Radix speaks strings — stringify the (possibly numeric,
131
- // #3090-widened) authored value at this boundary.
132
- const value = String(opt.value);
133
- const id = `${groupId}-${value}`;
134
- return (
135
- <div key={value} className="flex items-center space-x-2">
136
- <RadioGroupItem value={value} id={id} data-testid={`radio-option-${value}`} />
137
- <Label htmlFor={id} className="font-normal">{opt.label}</Label>
138
- </div>
139
- );
140
- })}
141
- </RadioGroup>
142
- );
143
- }