@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,579 +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, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
- import {
11
- Button,
12
- cn,
13
- Dialog,
14
- DialogContent,
15
- DialogHeader,
16
- DialogTitle,
17
- Input,
18
- Popover,
19
- PopoverContent,
20
- PopoverTrigger,
21
- ScrollArea,
22
- Sheet,
23
- SheetContent,
24
- SheetHeader,
25
- SheetTitle,
26
- SheetTrigger,
27
- Skeleton,
28
- useIsMobile,
29
- } from '@object-ui/components';
30
- import { Search, Loader2, AlertCircle } from 'lucide-react';
31
- import type { DataSource, LookupFilterDef } from '@object-ui/types';
32
- import { useRecordQuery } from './useRecordQuery';
33
- import { lookupFiltersToRecord } from './RecordPickerDialog';
34
- import { getPersonId } from './personDisplay';
35
- import { PersonRow } from './PersonRow';
36
- import { SelectionTray } from './SelectionTray';
37
- import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
38
- import { useFieldTranslation } from './useFieldTranslation';
39
-
40
- /**
41
- * PeoplePicker — the Tier 0, search-first user picker (issue #2112).
42
- *
43
- * A single-column picker: search box → recent contacts → rich candidate rows
44
- * (avatar + name + department·email) → a live SelectionTray for multi-select.
45
- * Composed from the reusable {@link useRecordQuery} kernel and
46
- * {@link SelectionTray}; a future org-tree tier reuses both beside a left tree.
47
- *
48
- * Container is responsive: a centered Dialog on desktop, a bottom Sheet on
49
- * mobile (<768px). Interaction: full keyboard model (↑/↓ move the cursor, ↵
50
- * toggles/commits, Backspace on an empty search removes the last chip, Esc
51
- * closes), matched-term highlighting in rows, skeletons on first load with no
52
- * flash-to-empty on refetch, and friendly empty / error+retry states.
53
- *
54
- * Candidate hygiene (e.g. `banned != true`), the department `$expand`, and the
55
- * avatar/subtitle field config are supplied by the caller (UserField). Pinyin /
56
- * employee-id search is server-side and transparent — the client only sends the
57
- * term.
58
- */
59
- export interface PeoplePickerProps {
60
- open: boolean;
61
- onOpenChange: (open: boolean) => void;
62
- title?: string;
63
- multiple?: boolean;
64
-
65
- dataSource: DataSource;
66
- /** Object to query — `sys_user` for user fields. */
67
- objectName: string;
68
-
69
- displayField?: string;
70
- idField?: string;
71
- /** Dotted field paths for the row subtitle, e.g. `['primary_business_unit_id.name','email']`. */
72
- subtitleFields?: string[];
73
- avatarField?: string;
74
- /** Related entities to expand (e.g. `['primary_business_unit_id']` for the department name). */
75
- expand?: string[];
76
- /** Narrow the server searchable set (ADR-0061). */
77
- searchFields?: string[];
78
- pageSize?: number;
79
- /** Base candidate filters (e.g. exclude banned users). */
80
- lookupFilters?: LookupFilterDef[];
81
- /**
82
- * Hard filter constraints in QueryParams.$filter record form — the
83
- * dependent (cascading) lookup chain (#2215). Always ANDed into the
84
- * candidate query; spread after `lookupFilters` so it wins on conflicts.
85
- */
86
- baseFilter?: Record<string, any>;
87
-
88
- /** Current selection (id, or id[] when `multiple`). */
89
- value?: any;
90
- onSelect: (value: any) => void;
91
- onSelectRecords?: (records: any[]) => void;
92
-
93
- /**
94
- * Render as an inline combobox anchored to {@link trigger} — a Popover
95
- * dropdown on desktop, a bottom Sheet on mobile — instead of a centered
96
- * modal Dialog. In inline mode multi-select commits live on each toggle
97
- * (the caller's field chips are the selection), so there's no staging tray
98
- * or confirm button.
99
- */
100
- inline?: boolean;
101
- /** The element the inline dropdown/sheet anchors to (the field trigger). */
102
- trigger?: React.ReactNode;
103
- }
104
-
105
- const DEFAULT_PAGE_SIZE = 25;
106
- const SKELETON_ROWS = 6;
107
-
108
- export function PeoplePicker({
109
- open,
110
- onOpenChange,
111
- title,
112
- multiple = false,
113
- dataSource,
114
- objectName,
115
- displayField = 'name',
116
- idField = 'id',
117
- subtitleFields,
118
- avatarField = 'image',
119
- expand,
120
- searchFields,
121
- pageSize = DEFAULT_PAGE_SIZE,
122
- lookupFilters,
123
- baseFilter: baseFilterProp,
124
- value,
125
- onSelect,
126
- onSelectRecords,
127
- inline = false,
128
- trigger,
129
- }: PeoplePickerProps) {
130
- const { t } = useFieldTranslation();
131
- const isMobile = useIsMobile();
132
-
133
- const baseFilter = useMemo<Record<string, any> | undefined>(() => {
134
- const combined = {
135
- ...(lookupFilters?.length ? lookupFiltersToRecord(lookupFilters) : {}),
136
- ...(baseFilterProp ?? {}),
137
- };
138
- return Object.keys(combined).length > 0 ? combined : undefined;
139
- }, [lookupFilters, baseFilterProp]);
140
-
141
- // Auto-expand relation subtitles (e.g. `primary_business_unit_id.name` needs
142
- // `$expand: ['primary_business_unit_id']`) unless the caller passed `expand`.
143
- const effectiveExpand = useMemo<string[] | undefined>(() => {
144
- if (expand && expand.length) return expand;
145
- const rels = new Set<string>();
146
- (subtitleFields ?? []).forEach(f => {
147
- if (f.includes('.')) rels.add(f.split('.')[0]);
148
- });
149
- return rels.size ? Array.from(rels) : undefined;
150
- }, [expand, subtitleFields]);
151
-
152
- // Main candidate query (search + candidate hygiene).
153
- const query = useRecordQuery({
154
- dataSource,
155
- objectName,
156
- enabled: open,
157
- pageSize,
158
- filter: baseFilter,
159
- expand: effectiveExpand,
160
- searchFields,
161
- });
162
-
163
- // Recent ids captured once per open.
164
- const recentIds = useMemo(
165
- () => (open ? getRecentLookupIds(objectName) : []),
166
- [open, objectName],
167
- );
168
-
169
- // The current value's ids, for hydrating the SelectionTray on edit.
170
- const valueIds = useMemo<any[]>(() => {
171
- if (multiple) return Array.isArray(value) ? value : [];
172
- return value != null && value !== '' ? [value] : [];
173
- }, [multiple, value]);
174
-
175
- // One extra query resolves both recents and the current selection to records.
176
- const seedIds = useMemo(
177
- () => Array.from(new Set([...valueIds, ...recentIds].map(v => v))),
178
- [valueIds, recentIds],
179
- );
180
- const seedQuery = useRecordQuery({
181
- dataSource,
182
- objectName,
183
- enabled: open && seedIds.length > 0,
184
- pageSize: Math.max(1, seedIds.length),
185
- filter: { [idField]: { $in: seedIds } },
186
- expand: effectiveExpand,
187
- });
188
-
189
- const recordsById = useMemo(() => {
190
- const m = new Map<string, any>();
191
- for (const r of seedQuery.records) m.set(String(getPersonId(r, idField)), r);
192
- return m;
193
- }, [seedQuery.records, idField]);
194
-
195
- // --- selection state (full records so the tray can show avatar + name) ---
196
- const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
197
- const seededRef = useRef(false);
198
- // On open, seedQuery.loading is still false for one render (its fetch is
199
- // kicked off in an effect that runs after this one), so an eager seed would
200
- // read an empty recordsById, seed nothing, and lock — wiping an existing
201
- // selection on confirm. Only seed after we've observed the fetch start.
202
- const sawSeedLoadingRef = useRef(false);
203
-
204
- useEffect(() => {
205
- if (!open) {
206
- seededRef.current = false;
207
- sawSeedLoadingRef.current = false;
208
- setSelectedRecords([]);
209
- }
210
- }, [open]);
211
-
212
- // Seed the tray from the current value once its records resolve (once per open).
213
- useEffect(() => {
214
- if (!open || seededRef.current) return;
215
- if (valueIds.length === 0) {
216
- seededRef.current = true;
217
- return;
218
- }
219
- if (seedQuery.loading) {
220
- sawSeedLoadingRef.current = true;
221
- return;
222
- }
223
- // The seed fetch hasn't started yet this open — wait for it before locking.
224
- if (!sawSeedLoadingRef.current) return;
225
- const seeded = valueIds.map(id => recordsById.get(String(id))).filter(Boolean);
226
- setSelectedRecords(seeded);
227
- seededRef.current = true;
228
- }, [open, valueIds, seedQuery.loading, recordsById]);
229
-
230
- const selectedIds = useMemo(
231
- () => new Set(selectedRecords.map(r => String(getPersonId(r, idField)))),
232
- [selectedRecords, idField],
233
- );
234
-
235
- const commit = useCallback(
236
- (ids: any[], records: any[]) => {
237
- onSelect(multiple ? ids : (ids[0] ?? null));
238
- onSelectRecords?.(records);
239
- ids.forEach(id => pushRecentLookupId(objectName, id));
240
- onOpenChange(false);
241
- },
242
- [multiple, objectName, onSelect, onSelectRecords, onOpenChange],
243
- );
244
-
245
- // Inline mode has no confirm step — push the value out on every change.
246
- const commitLive = useCallback(
247
- (records: any[]) => {
248
- onSelect(records.map(r => getPersonId(r, idField)));
249
- onSelectRecords?.(records);
250
- },
251
- [onSelect, onSelectRecords, idField],
252
- );
253
-
254
- const handleRowSelect = useCallback(
255
- (record: any) => {
256
- const id = getPersonId(record, idField);
257
- if (!multiple) {
258
- commit([id], [record]);
259
- return;
260
- }
261
- const key = String(id);
262
- const exists = selectedRecords.some(r => String(getPersonId(r, idField)) === key);
263
- const next = exists
264
- ? selectedRecords.filter(r => String(getPersonId(r, idField)) !== key)
265
- : [...selectedRecords, record];
266
- setSelectedRecords(next);
267
- // Inline: commit live (chips live in the field), stay open for more.
268
- // Modal: stage in the tray until Confirm.
269
- if (inline) {
270
- if (!exists) pushRecentLookupId(objectName, id);
271
- commitLive(next);
272
- }
273
- },
274
- [multiple, idField, commit, selectedRecords, inline, objectName, commitLive],
275
- );
276
-
277
- const handleRemove = useCallback(
278
- (id: any) => {
279
- const key = String(id);
280
- const next = selectedRecords.filter(r => String(getPersonId(r, idField)) !== key);
281
- setSelectedRecords(next);
282
- if (inline) commitLive(next);
283
- },
284
- [idField, selectedRecords, inline, commitLive],
285
- );
286
-
287
- const handleConfirm = useCallback(() => {
288
- commit(selectedRecords.map(r => getPersonId(r, idField)), selectedRecords);
289
- }, [commit, selectedRecords, idField]);
290
-
291
- const hasSearch = query.search.trim().length > 0;
292
-
293
- // Recent contacts (only when not searching), in MRU order.
294
- const recentRecords = useMemo(() => {
295
- if (hasSearch) return [];
296
- return recentIds.map(id => recordsById.get(String(id))).filter(Boolean);
297
- }, [hasSearch, recentIds, recordsById]);
298
-
299
- // Candidate list; drop recents when idle to avoid showing them twice.
300
- const resultRecords = useMemo(() => {
301
- if (hasSearch) return query.records;
302
- const recentSet = new Set(recentIds.map(String));
303
- return query.records.filter(r => !recentSet.has(String(getPersonId(r, idField))));
304
- }, [hasSearch, query.records, recentIds, idField]);
305
-
306
- // Flat, in-display-order list the keyboard cursor walks.
307
- const navList = useMemo(
308
- () => [...recentRecords, ...resultRecords],
309
- [recentRecords, resultRecords],
310
- );
311
-
312
- // --- keyboard cursor ---
313
- const [activeIndex, setActiveIndex] = useState(-1);
314
- // Reset the cursor when the query results are actually *replaced* — keyed on
315
- // the record-id signature, not the array identity. A background refetch that
316
- // returns the same records (StrictMode double-effect, refetch-on-focus)
317
- // re-emits a new array; resetting on identity alone yanked the cursor to -1
318
- // mid-navigation (flaky ArrowDown→Enter, and a real UX nit).
319
- //
320
- // The id separator is the escaped U+0000 spelling, not the byte itself
321
- // (objectstack#5450): one raw NUL made this whole file binary to grep and
322
- // ripgrep — a content search printed `binary file matches` and no line. The
323
- // runtime value is unchanged, and it is the same separator the `cursorEpoch`
324
- // key below already spelled this way.
325
- const recordsSignature = useMemo(
326
- () => query.records.map(r => String(getPersonId(r, idField))).join('\u0000'),
327
- [query.records, idField],
328
- );
329
- // The reset runs in the RENDER PHASE (the "adjusting state during render"
330
- // pattern), NOT in an effect. An effect flushes asynchronously after the
331
- // render that delivered the records — so a reset queued by their arrival
332
- // could land AFTER a subsequent ArrowDown and eat the just-set cursor.
333
- // That is the residual ArrowDown→Enter flake the signature key above did
334
- // not close (CI reproduced it under load), and a real fast-fingers UX bug:
335
- // rows appear, the user presses ArrowDown, the highlight vanishes.
336
- // Resetting during the same render that shows the new rows makes the
337
- // ordering deterministic — by the time the rows are visible, the reset
338
- // has already happened.
339
- const cursorEpoch = `${query.search}\u0000${recordsSignature}`;
340
- const [seenCursorEpoch, setSeenCursorEpoch] = useState(cursorEpoch);
341
- if (seenCursorEpoch !== cursorEpoch) {
342
- setSeenCursorEpoch(cursorEpoch);
343
- setActiveIndex(-1);
344
- }
345
- // Keep it in range if the list shrinks.
346
- useEffect(() => {
347
- setActiveIndex(i => (i >= navList.length ? navList.length - 1 : i));
348
- }, [navList.length]);
349
-
350
- const onSearchKeyDown = useCallback(
351
- (e: React.KeyboardEvent<HTMLInputElement>) => {
352
- if (e.key === 'ArrowDown') {
353
- e.preventDefault();
354
- setActiveIndex(i => (navList.length ? Math.min(navList.length - 1, i + 1) : -1));
355
- } else if (e.key === 'ArrowUp') {
356
- e.preventDefault();
357
- setActiveIndex(i => Math.max(0, i - 1));
358
- } else if (e.key === 'Enter') {
359
- if (activeIndex >= 0 && activeIndex < navList.length) {
360
- e.preventDefault();
361
- handleRowSelect(navList[activeIndex]);
362
- }
363
- } else if (
364
- e.key === 'Backspace' &&
365
- query.search.length === 0 &&
366
- multiple &&
367
- selectedRecords.length > 0
368
- ) {
369
- handleRemove(getPersonId(selectedRecords[selectedRecords.length - 1], idField));
370
- }
371
- },
372
- [navList, activeIndex, handleRowSelect, query.search, multiple, selectedRecords, handleRemove, idField],
373
- );
374
-
375
- const initialLoading =
376
- query.loading && !query.error && query.records.length === 0 && recentRecords.length === 0;
377
- const refetching = query.loading && !initialLoading;
378
- const isEmpty =
379
- !query.loading &&
380
- !query.error &&
381
- resultRecords.length === 0 &&
382
- recentRecords.length === 0;
383
-
384
- const renderRow = (record: any, index: number) => {
385
- const id = getPersonId(record, idField);
386
- return (
387
- <PersonRow
388
- key={String(id)}
389
- record={record}
390
- displayField={displayField}
391
- subtitleFields={subtitleFields}
392
- avatarField={avatarField}
393
- selected={selectedIds.has(String(id))}
394
- active={index === activeIndex}
395
- highlightQuery={query.search}
396
- onSelect={handleRowSelect}
397
- />
398
- );
399
- };
400
-
401
- const titleText = title || t('lookup.selectRecord');
402
-
403
- // Container-agnostic body — rendered inside a Dialog (desktop) or Sheet (mobile).
404
- const body = (
405
- <>
406
- {/* Search */}
407
- <div className="relative">
408
- <Search
409
- className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
410
- aria-hidden
411
- />
412
- <Input
413
- autoFocus={!isMobile}
414
- value={query.search}
415
- onChange={e => query.setSearch(e.target.value)}
416
- onKeyDown={onSearchKeyDown}
417
- placeholder={t('table.search')}
418
- className="px-8"
419
- role="combobox"
420
- aria-expanded
421
- aria-controls="people-picker-list"
422
- data-testid="people-picker-search"
423
- />
424
- {query.loading && (
425
- <Loader2
426
- className="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-muted-foreground"
427
- aria-hidden
428
- />
429
- )}
430
- </div>
431
-
432
- {/* Candidate area */}
433
- <ScrollArea className="min-h-0 flex-1" data-testid="people-picker-list">
434
- <div
435
- id="people-picker-list"
436
- role="listbox"
437
- aria-busy={query.loading}
438
- className={cn('flex flex-col gap-0.5 pr-2 transition-opacity', refetching && 'opacity-70')}
439
- >
440
- {query.error ? (
441
- <div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted-foreground">
442
- <AlertCircle className="size-5 text-destructive" aria-hidden />
443
- <span className="max-w-xs">{query.error}</span>
444
- <Button type="button" variant="outline" size="sm" onClick={query.refetch}>
445
- {t('lookup.retry')}
446
- </Button>
447
- </div>
448
- ) : initialLoading ? (
449
- Array.from({ length: SKELETON_ROWS }).map((_, i) => (
450
- <div key={i} className="flex items-center gap-3 px-2 py-1.5">
451
- <Skeleton className="size-9 shrink-0 rounded-full" />
452
- <div className="flex-1 space-y-1.5">
453
- <Skeleton className="h-3 w-1/3" />
454
- <Skeleton className="h-2.5 w-1/2" />
455
- </div>
456
- </div>
457
- ))
458
- ) : (
459
- <>
460
- {recentRecords.length > 0 && (
461
- <>
462
- <div className="px-2 py-1 text-xs font-medium text-muted-foreground">
463
- {t('lookup.recentlyUsed')}
464
- </div>
465
- {recentRecords.map((r, i) => renderRow(r, i))}
466
- {resultRecords.length > 0 && (
467
- <div className="mt-1 flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
468
- <span>{t('lookup.allResults')}</span>
469
- <span className="font-normal opacity-70">· {query.total}</span>
470
- </div>
471
- )}
472
- </>
473
- )}
474
-
475
- {resultRecords.map((r, i) => renderRow(r, recentRecords.length + i))}
476
-
477
- {isEmpty && (
478
- <div className="py-8 text-center text-sm text-muted-foreground">
479
- {t('lookup.noRecords')}
480
- </div>
481
- )}
482
- </>
483
- )}
484
- </div>
485
- </ScrollArea>
486
-
487
- {/* Multi-select tray + confirm — modal only; inline commits live and
488
- shows the selection as chips in the caller's field. */}
489
- {multiple && !inline && (
490
- <>
491
- <SelectionTray
492
- records={selectedRecords}
493
- onRemove={handleRemove}
494
- onClear={() => setSelectedRecords([])}
495
- clearLabel={t('lookup.clear')}
496
- displayField={displayField}
497
- avatarField={avatarField}
498
- idField={idField}
499
- label={t('table.selected', { count: selectedRecords.length })}
500
- className={cn('border-t pt-3')}
501
- />
502
- <div className="flex justify-end gap-2">
503
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
504
- {t('common.cancel')}
505
- </Button>
506
- <Button type="button" onClick={handleConfirm}>
507
- {t('common.confirm')}
508
- </Button>
509
- </div>
510
- </>
511
- )}
512
- </>
513
- );
514
-
515
- // Inline combobox: anchored Popover on desktop, bottom Sheet (opened from the
516
- // same trigger) on mobile. The trigger is the caller's field control.
517
- if (inline) {
518
- if (isMobile) {
519
- return (
520
- <Sheet open={open} onOpenChange={onOpenChange}>
521
- <SheetTrigger asChild>{trigger}</SheetTrigger>
522
- <SheetContent
523
- side="bottom"
524
- className="flex h-[85vh] flex-col gap-3"
525
- data-testid="people-picker-sheet"
526
- >
527
- <SheetHeader className="text-left">
528
- <SheetTitle>{titleText}</SheetTitle>
529
- </SheetHeader>
530
- {body}
531
- </SheetContent>
532
- </Sheet>
533
- );
534
- }
535
- return (
536
- <Popover open={open} onOpenChange={onOpenChange}>
537
- <PopoverTrigger asChild>{trigger}</PopoverTrigger>
538
- <PopoverContent
539
- align="start"
540
- className="flex max-h-[min(28rem,60vh)] w-[var(--radix-popover-trigger-width)] min-w-72 flex-col gap-3 p-3"
541
- data-testid="people-picker-inline"
542
- >
543
- {body}
544
- </PopoverContent>
545
- </Popover>
546
- );
547
- }
548
-
549
- if (isMobile) {
550
- return (
551
- <Sheet open={open} onOpenChange={onOpenChange}>
552
- <SheetContent
553
- side="bottom"
554
- className="flex h-[85vh] flex-col gap-3"
555
- data-testid="people-picker-sheet"
556
- >
557
- <SheetHeader className="text-left">
558
- <SheetTitle>{titleText}</SheetTitle>
559
- </SheetHeader>
560
- {body}
561
- </SheetContent>
562
- </Sheet>
563
- );
564
- }
565
-
566
- return (
567
- <Dialog open={open} onOpenChange={onOpenChange}>
568
- <DialogContent
569
- className="flex max-h-[80vh] flex-col gap-3 sm:max-w-lg"
570
- data-testid="people-picker-dialog"
571
- >
572
- <DialogHeader>
573
- <DialogTitle>{titleText}</DialogTitle>
574
- </DialogHeader>
575
- {body}
576
- </DialogContent>
577
- </Dialog>
578
- );
579
- }
@@ -1,104 +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 { render, screen, fireEvent } from '@testing-library/react';
10
- import { describe, it, expect, vi } from 'vitest';
11
- import { PersonRow } from './PersonRow';
12
- import { SelectionTray } from './SelectionTray';
13
-
14
- const amy = {
15
- id: 'u1',
16
- name: 'Amy Lin',
17
- email: 'amy@x.io',
18
- image: 'http://x/amy.png',
19
- primary_business_unit_id: { name: 'Sales' },
20
- };
21
- const bob = { id: 'u2', name: 'Bob Wu', email: 'bob@x.io' };
22
-
23
- describe('PersonRow', () => {
24
- it('renders name, subtitle (dept · email) and initials fallback', () => {
25
- render(
26
- <PersonRow record={amy} subtitleFields={['primary_business_unit_id.name', 'email']} />,
27
- );
28
- expect(screen.getByText('Amy Lin')).toBeTruthy();
29
- expect(screen.getByText('Sales · amy@x.io')).toBeTruthy();
30
- // Avatar fallback initials render (image does not load in jsdom).
31
- expect(screen.getByText('AL')).toBeTruthy();
32
- });
33
-
34
- it('fires onSelect with the record on click', () => {
35
- const onSelect = vi.fn();
36
- render(<PersonRow record={amy} onSelect={onSelect} />);
37
- fireEvent.click(screen.getByTestId('person-row'));
38
- expect(onSelect).toHaveBeenCalledWith(amy);
39
- });
40
-
41
- it('reflects selected state via aria-pressed', () => {
42
- const { rerender } = render(<PersonRow record={amy} selected={false} />);
43
- expect(screen.getByTestId('person-row').getAttribute('aria-pressed')).toBe('false');
44
- rerender(<PersonRow record={amy} selected />);
45
- expect(screen.getByTestId('person-row').getAttribute('aria-pressed')).toBe('true');
46
- });
47
- });
48
-
49
- describe('SelectionTray', () => {
50
- it('shows a chip per selected record with a label', () => {
51
- render(<SelectionTray records={[amy, bob]} onRemove={vi.fn()} label="Selected (2)" />);
52
- expect(screen.getByText('Selected (2)')).toBeTruthy();
53
- expect(screen.getAllByTestId('selection-chip')).toHaveLength(2);
54
- expect(screen.getByText('Amy Lin')).toBeTruthy();
55
- expect(screen.getByText('Bob Wu')).toBeTruthy();
56
- });
57
-
58
- it('renders emptyText when nothing is selected', () => {
59
- render(<SelectionTray records={[]} onRemove={vi.fn()} emptyText="No one selected" />);
60
- expect(screen.getByText('No one selected')).toBeTruthy();
61
- expect(screen.queryByTestId('selection-chip')).toBeNull();
62
- });
63
-
64
- it('calls onRemove with the record id when the ✕ is clicked', () => {
65
- const onRemove = vi.fn();
66
- render(<SelectionTray records={[amy, bob]} onRemove={onRemove} />);
67
- fireEvent.click(screen.getByLabelText('Remove Amy Lin'));
68
- expect(onRemove).toHaveBeenCalledWith('u1');
69
- });
70
-
71
- it('shows Clear all only when non-empty and fires onClear', () => {
72
- const onClear = vi.fn();
73
- const { rerender } = render(
74
- <SelectionTray records={[]} onRemove={vi.fn()} onClear={onClear} clearLabel="Clear all" />,
75
- );
76
- expect(screen.queryByTestId('selection-clear')).toBeNull();
77
- rerender(
78
- <SelectionTray records={[amy, bob]} onRemove={vi.fn()} onClear={onClear} clearLabel="Clear all" />,
79
- );
80
- fireEvent.click(screen.getByTestId('selection-clear'));
81
- expect(onClear).toHaveBeenCalledTimes(1);
82
- });
83
- });
84
-
85
- describe('PersonRow — highlight + active', () => {
86
- it('wraps the matched substring in a <mark>', () => {
87
- const { container } = render(<PersonRow record={amy} highlightQuery="am" />);
88
- const mark = container.querySelector('mark');
89
- expect(mark?.textContent).toBe('Am');
90
- });
91
-
92
- it('renders plain text (no mark) when there is no query', () => {
93
- const { container } = render(<PersonRow record={amy} />);
94
- expect(container.querySelector('mark')).toBeNull();
95
- expect(screen.getByText('Amy Lin')).toBeTruthy();
96
- });
97
-
98
- it('exposes the keyboard-active state via data-active', () => {
99
- const { rerender } = render(<PersonRow record={amy} active={false} />);
100
- expect(screen.getByTestId('person-row').getAttribute('data-active')).toBeNull();
101
- rerender(<PersonRow record={amy} active />);
102
- expect(screen.getByTestId('person-row').getAttribute('data-active')).toBe('true');
103
- });
104
- });