@object-ui/fields 17.5.0 → 17.6.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 (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. 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
- });