@object-ui/fields 11.3.0 → 11.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/FieldEditWidget.d.ts +8 -0
  3. package/dist/FieldEditWidget.test.d.ts +8 -0
  4. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  5. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  6. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  7. package/dist/index.cjs +4 -4
  8. package/dist/index.d.ts +17 -2
  9. package/dist/index.js +2015 -1346
  10. package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
  11. package/dist/widgets/PeoplePicker.d.ts +63 -0
  12. package/dist/widgets/PeoplePicker.test.d.ts +8 -0
  13. package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
  14. package/dist/widgets/PersonRow.d.ts +23 -0
  15. package/dist/widgets/RecordPickerDialog.d.ts +9 -1
  16. package/dist/widgets/SelectionTray.d.ts +27 -0
  17. package/dist/widgets/UserField.d.ts +0 -13
  18. package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
  19. package/dist/widgets/deriveLookupColumns.d.ts +12 -4
  20. package/dist/widgets/personDisplay.d.ts +37 -0
  21. package/dist/widgets/personDisplay.test.d.ts +8 -0
  22. package/dist/widgets/useRecordQuery.d.ts +87 -0
  23. package/dist/widgets/useRecordQuery.test.d.ts +8 -0
  24. package/package.json +7 -7
  25. package/src/FieldEditWidget.test.ts +61 -0
  26. package/src/FieldEditWidget.tsx +56 -1
  27. package/src/__tests__/DateCellRenderer.test.tsx +50 -0
  28. package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
  29. package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
  30. package/src/complex-widgets.test.tsx +241 -0
  31. package/src/index.tsx +61 -18
  32. package/src/widgets/GridField.test.tsx +14 -0
  33. package/src/widgets/GridField.tsx +7 -8
  34. package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
  35. package/src/widgets/LookupField.tsx +365 -190
  36. package/src/widgets/NumberField.tsx +4 -2
  37. package/src/widgets/PeoplePicker.test.tsx +334 -0
  38. package/src/widgets/PeoplePicker.tsx +552 -0
  39. package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
  40. package/src/widgets/PersonRow.tsx +117 -0
  41. package/src/widgets/RecordPickerDialog.tsx +58 -129
  42. package/src/widgets/SelectionTray.tsx +110 -0
  43. package/src/widgets/UserField.tsx +20 -1
  44. package/src/widgets/UserFieldPicker.test.tsx +78 -0
  45. package/src/widgets/deriveLookupColumns.test.ts +25 -0
  46. package/src/widgets/deriveLookupColumns.ts +24 -10
  47. package/src/widgets/personDisplay.test.ts +113 -0
  48. package/src/widgets/personDisplay.ts +92 -0
  49. package/src/widgets/useRecordQuery.test.ts +174 -0
  50. package/src/widgets/useRecordQuery.ts +264 -0
@@ -0,0 +1,552 @@
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 changes (results replaced).
315
+ useEffect(() => {
316
+ setActiveIndex(-1);
317
+ }, [query.search, query.records]);
318
+ // Keep it in range if the list shrinks.
319
+ useEffect(() => {
320
+ setActiveIndex(i => (i >= navList.length ? navList.length - 1 : i));
321
+ }, [navList.length]);
322
+
323
+ const onSearchKeyDown = useCallback(
324
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
325
+ if (e.key === 'ArrowDown') {
326
+ e.preventDefault();
327
+ setActiveIndex(i => (navList.length ? Math.min(navList.length - 1, i + 1) : -1));
328
+ } else if (e.key === 'ArrowUp') {
329
+ e.preventDefault();
330
+ setActiveIndex(i => Math.max(0, i - 1));
331
+ } else if (e.key === 'Enter') {
332
+ if (activeIndex >= 0 && activeIndex < navList.length) {
333
+ e.preventDefault();
334
+ handleRowSelect(navList[activeIndex]);
335
+ }
336
+ } else if (
337
+ e.key === 'Backspace' &&
338
+ query.search.length === 0 &&
339
+ multiple &&
340
+ selectedRecords.length > 0
341
+ ) {
342
+ handleRemove(getPersonId(selectedRecords[selectedRecords.length - 1], idField));
343
+ }
344
+ },
345
+ [navList, activeIndex, handleRowSelect, query.search, multiple, selectedRecords, handleRemove, idField],
346
+ );
347
+
348
+ const initialLoading =
349
+ query.loading && !query.error && query.records.length === 0 && recentRecords.length === 0;
350
+ const refetching = query.loading && !initialLoading;
351
+ const isEmpty =
352
+ !query.loading &&
353
+ !query.error &&
354
+ resultRecords.length === 0 &&
355
+ recentRecords.length === 0;
356
+
357
+ const renderRow = (record: any, index: number) => {
358
+ const id = getPersonId(record, idField);
359
+ return (
360
+ <PersonRow
361
+ key={String(id)}
362
+ record={record}
363
+ displayField={displayField}
364
+ subtitleFields={subtitleFields}
365
+ avatarField={avatarField}
366
+ selected={selectedIds.has(String(id))}
367
+ active={index === activeIndex}
368
+ highlightQuery={query.search}
369
+ onSelect={handleRowSelect}
370
+ />
371
+ );
372
+ };
373
+
374
+ const titleText = title || t('lookup.selectRecord');
375
+
376
+ // Container-agnostic body — rendered inside a Dialog (desktop) or Sheet (mobile).
377
+ const body = (
378
+ <>
379
+ {/* Search */}
380
+ <div className="relative">
381
+ <Search
382
+ className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
383
+ aria-hidden
384
+ />
385
+ <Input
386
+ autoFocus={!isMobile}
387
+ value={query.search}
388
+ onChange={e => query.setSearch(e.target.value)}
389
+ onKeyDown={onSearchKeyDown}
390
+ placeholder={t('table.search')}
391
+ className="px-8"
392
+ role="combobox"
393
+ aria-expanded
394
+ aria-controls="people-picker-list"
395
+ data-testid="people-picker-search"
396
+ />
397
+ {query.loading && (
398
+ <Loader2
399
+ className="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-muted-foreground"
400
+ aria-hidden
401
+ />
402
+ )}
403
+ </div>
404
+
405
+ {/* Candidate area */}
406
+ <ScrollArea className="min-h-0 flex-1" data-testid="people-picker-list">
407
+ <div
408
+ id="people-picker-list"
409
+ role="listbox"
410
+ aria-busy={query.loading}
411
+ className={cn('flex flex-col gap-0.5 pr-2 transition-opacity', refetching && 'opacity-70')}
412
+ >
413
+ {query.error ? (
414
+ <div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted-foreground">
415
+ <AlertCircle className="size-5 text-destructive" aria-hidden />
416
+ <span className="max-w-xs">{query.error}</span>
417
+ <Button type="button" variant="outline" size="sm" onClick={query.refetch}>
418
+ {t('lookup.retry')}
419
+ </Button>
420
+ </div>
421
+ ) : initialLoading ? (
422
+ Array.from({ length: SKELETON_ROWS }).map((_, i) => (
423
+ <div key={i} className="flex items-center gap-3 px-2 py-1.5">
424
+ <Skeleton className="size-9 shrink-0 rounded-full" />
425
+ <div className="flex-1 space-y-1.5">
426
+ <Skeleton className="h-3 w-1/3" />
427
+ <Skeleton className="h-2.5 w-1/2" />
428
+ </div>
429
+ </div>
430
+ ))
431
+ ) : (
432
+ <>
433
+ {recentRecords.length > 0 && (
434
+ <>
435
+ <div className="px-2 py-1 text-xs font-medium text-muted-foreground">
436
+ {t('lookup.recentlyUsed')}
437
+ </div>
438
+ {recentRecords.map((r, i) => renderRow(r, i))}
439
+ {resultRecords.length > 0 && (
440
+ <div className="mt-1 flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
441
+ <span>{t('lookup.allResults')}</span>
442
+ <span className="font-normal opacity-70">· {query.total}</span>
443
+ </div>
444
+ )}
445
+ </>
446
+ )}
447
+
448
+ {resultRecords.map((r, i) => renderRow(r, recentRecords.length + i))}
449
+
450
+ {isEmpty && (
451
+ <div className="py-8 text-center text-sm text-muted-foreground">
452
+ {t('lookup.noRecords')}
453
+ </div>
454
+ )}
455
+ </>
456
+ )}
457
+ </div>
458
+ </ScrollArea>
459
+
460
+ {/* Multi-select tray + confirm — modal only; inline commits live and
461
+ shows the selection as chips in the caller's field. */}
462
+ {multiple && !inline && (
463
+ <>
464
+ <SelectionTray
465
+ records={selectedRecords}
466
+ onRemove={handleRemove}
467
+ onClear={() => setSelectedRecords([])}
468
+ clearLabel={t('lookup.clear')}
469
+ displayField={displayField}
470
+ avatarField={avatarField}
471
+ idField={idField}
472
+ label={t('table.selected', { count: selectedRecords.length })}
473
+ className={cn('border-t pt-3')}
474
+ />
475
+ <div className="flex justify-end gap-2">
476
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
477
+ {t('common.cancel')}
478
+ </Button>
479
+ <Button type="button" onClick={handleConfirm}>
480
+ {t('common.confirm')}
481
+ </Button>
482
+ </div>
483
+ </>
484
+ )}
485
+ </>
486
+ );
487
+
488
+ // Inline combobox: anchored Popover on desktop, bottom Sheet (opened from the
489
+ // same trigger) on mobile. The trigger is the caller's field control.
490
+ if (inline) {
491
+ if (isMobile) {
492
+ return (
493
+ <Sheet open={open} onOpenChange={onOpenChange}>
494
+ <SheetTrigger asChild>{trigger}</SheetTrigger>
495
+ <SheetContent
496
+ side="bottom"
497
+ className="flex h-[85vh] flex-col gap-3"
498
+ data-testid="people-picker-sheet"
499
+ >
500
+ <SheetHeader className="text-left">
501
+ <SheetTitle>{titleText}</SheetTitle>
502
+ </SheetHeader>
503
+ {body}
504
+ </SheetContent>
505
+ </Sheet>
506
+ );
507
+ }
508
+ return (
509
+ <Popover open={open} onOpenChange={onOpenChange}>
510
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
511
+ <PopoverContent
512
+ align="start"
513
+ className="flex max-h-[min(28rem,60vh)] w-[var(--radix-popover-trigger-width)] min-w-72 flex-col gap-3 p-3"
514
+ data-testid="people-picker-inline"
515
+ >
516
+ {body}
517
+ </PopoverContent>
518
+ </Popover>
519
+ );
520
+ }
521
+
522
+ if (isMobile) {
523
+ return (
524
+ <Sheet open={open} onOpenChange={onOpenChange}>
525
+ <SheetContent
526
+ side="bottom"
527
+ className="flex h-[85vh] flex-col gap-3"
528
+ data-testid="people-picker-sheet"
529
+ >
530
+ <SheetHeader className="text-left">
531
+ <SheetTitle>{titleText}</SheetTitle>
532
+ </SheetHeader>
533
+ {body}
534
+ </SheetContent>
535
+ </Sheet>
536
+ );
537
+ }
538
+
539
+ return (
540
+ <Dialog open={open} onOpenChange={onOpenChange}>
541
+ <DialogContent
542
+ className="flex max-h-[80vh] flex-col gap-3 sm:max-w-lg"
543
+ data-testid="people-picker-dialog"
544
+ >
545
+ <DialogHeader>
546
+ <DialogTitle>{titleText}</DialogTitle>
547
+ </DialogHeader>
548
+ {body}
549
+ </DialogContent>
550
+ </Dialog>
551
+ );
552
+ }
@@ -0,0 +1,104 @@
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
+ });