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