@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.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 (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +19 -6
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/DataTable.js +62 -10
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/RowActions.js +54 -5
  7. package/dist/backend/RowActions.js.map +2 -2
  8. package/dist/backend/charts/KpiCard.js +2 -1
  9. package/dist/backend/charts/KpiCard.js.map +2 -2
  10. package/dist/backend/detail/AttachmentsSection.js +17 -5
  11. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +38 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
  15. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
  16. package/dist/backend/icons/lucideRegistry.generated.js +26 -0
  17. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  18. package/dist/backend/injection/eventBridge.js +33 -0
  19. package/dist/backend/injection/eventBridge.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +20 -3
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/inputs/PhoneNumberField.js +1 -1
  23. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  24. package/dist/backend/inputs/TagsInput.js +20 -2
  25. package/dist/backend/inputs/TagsInput.js.map +2 -2
  26. package/dist/backend/messages/EmailThreadsPanel.js +2 -1
  27. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  28. package/dist/backend/messages/MessageComposer.js.map +2 -2
  29. package/dist/backend/messages/index.js.map +2 -2
  30. package/dist/backend/messages/message-compose-form-groups.js +24 -0
  31. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  32. package/dist/backend/messages/useMessageCompose.js +27 -0
  33. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  34. package/dist/backend/messages/useMessageComposeOperations.js +1 -0
  35. package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
  36. package/dist/backend/notifications/NotificationItem.js +8 -1
  37. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  38. package/dist/portal/PortalShell.js +2 -2
  39. package/dist/portal/PortalShell.js.map +2 -2
  40. package/dist/primitives/calendar.js +69 -14
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/package.json +4 -5
  43. package/src/backend/CrudForm.tsx +30 -8
  44. package/src/backend/DataTable.tsx +95 -20
  45. package/src/backend/RowActions.tsx +114 -6
  46. package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
  47. package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
  48. package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
  49. package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
  50. package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
  51. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
  52. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
  53. package/src/backend/__tests__/NotesSection.test.tsx +138 -0
  54. package/src/backend/__tests__/RowActions.test.tsx +202 -0
  55. package/src/backend/charts/KpiCard.tsx +1 -0
  56. package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
  57. package/src/backend/detail/AttachmentsSection.tsx +17 -5
  58. package/src/backend/detail/NotesSection.tsx +122 -66
  59. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
  60. package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
  61. package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
  62. package/src/backend/injection/eventBridge.ts +35 -0
  63. package/src/backend/inputs/LookupSelect.tsx +32 -3
  64. package/src/backend/inputs/PhoneNumberField.tsx +1 -1
  65. package/src/backend/inputs/TagsInput.tsx +24 -3
  66. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
  67. package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
  68. package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
  69. package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
  70. package/src/backend/messages/MessageComposer.tsx +1 -0
  71. package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
  72. package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
  73. package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
  74. package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
  75. package/src/backend/messages/index.ts +1 -0
  76. package/src/backend/messages/message-compose-form-groups.tsx +41 -0
  77. package/src/backend/messages/message-composer.types.ts +26 -0
  78. package/src/backend/messages/useMessageCompose.ts +46 -0
  79. package/src/backend/messages/useMessageComposeOperations.ts +8 -0
  80. package/src/backend/notifications/NotificationItem.tsx +7 -0
  81. package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
  82. package/src/portal/PortalShell.tsx +7 -4
  83. package/src/portal/__tests__/PortalShell.test.tsx +29 -1
  84. package/src/primitives/__tests__/calendar.test.tsx +174 -1
  85. package/src/primitives/calendar.tsx +168 -96
@@ -9,6 +9,7 @@ import {
9
9
  AlertCircle,
10
10
  AlertOctagon,
11
11
  AlertTriangle,
12
+ AlignLeft,
12
13
  Apple,
13
14
  Archive,
14
15
  AtSign,
@@ -73,9 +74,12 @@ import {
73
74
  GitPullRequestArrow,
74
75
  Globe,
75
76
  GraduationCap,
77
+ Grid3x3,
76
78
  Hammer,
77
79
  Hand,
78
80
  Handshake,
81
+ Hash,
82
+ Heading,
79
83
  Headphones,
80
84
  Heart,
81
85
  HelpCircle,
@@ -90,6 +94,7 @@ import {
90
94
  Link,
91
95
  List,
92
96
  ListChecks,
97
+ ListOrdered,
93
98
  ListX,
94
99
  Loader,
95
100
  Loader2,
@@ -110,9 +115,11 @@ import {
110
115
  PackagePlus,
111
116
  PackageSearch,
112
117
  PackageX,
118
+ Paperclip,
113
119
  PauseCircle,
114
120
  PencilLine,
115
121
  PenLine,
122
+ PenTool,
116
123
  Percent,
117
124
  Phone,
118
125
  PhoneCall,
@@ -126,7 +133,9 @@ import {
126
133
  Repeat2,
127
134
  Reply,
128
135
  RotateCcw,
136
+ Rows,
129
137
  Ruler,
138
+ Search,
130
139
  Send,
131
140
  Settings,
132
141
  Shapes,
@@ -138,6 +147,7 @@ import {
138
147
  ShoppingCart,
139
148
  Shuffle,
140
149
  Sliders,
150
+ SlidersHorizontal,
141
151
  Smartphone,
142
152
  Snowflake,
143
153
  Sparkles,
@@ -149,11 +159,14 @@ import {
149
159
  ThumbsUp,
150
160
  Ticket,
151
161
  Timer,
162
+ ToggleLeft,
163
+ ToggleRight,
152
164
  Trash2,
153
165
  TrendingUp,
154
166
  TriangleAlert,
155
167
  Trophy,
156
168
  Truck,
169
+ Type,
157
170
  Undo,
158
171
  Undo2,
159
172
  Unlock,
@@ -181,6 +194,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
181
194
  'alert-circle': AlertCircle,
182
195
  'alert-octagon': AlertOctagon,
183
196
  'alert-triangle': AlertTriangle,
197
+ 'align-left': AlignLeft,
184
198
  'apple': Apple,
185
199
  'archive': Archive,
186
200
  'at-sign': AtSign,
@@ -245,9 +259,12 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
245
259
  'git-pull-request-arrow': GitPullRequestArrow,
246
260
  'globe': Globe,
247
261
  'graduation-cap': GraduationCap,
262
+ 'grid-3x3': Grid3x3,
248
263
  'hammer': Hammer,
249
264
  'hand': Hand,
250
265
  'handshake': Handshake,
266
+ 'hash': Hash,
267
+ 'heading': Heading,
251
268
  'headphones': Headphones,
252
269
  'heart': Heart,
253
270
  'help-circle': HelpCircle,
@@ -262,6 +279,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
262
279
  'link': Link,
263
280
  'list': List,
264
281
  'list-checks': ListChecks,
282
+ 'list-ordered': ListOrdered,
265
283
  'list-x': ListX,
266
284
  'loader': Loader,
267
285
  'loader-2': Loader2,
@@ -282,8 +300,10 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
282
300
  'package-plus': PackagePlus,
283
301
  'package-search': PackageSearch,
284
302
  'package-x': PackageX,
303
+ 'paperclip': Paperclip,
285
304
  'pause-circle': PauseCircle,
286
305
  'pen-line': PenLine,
306
+ 'pen-tool': PenTool,
287
307
  'pencil-line': PencilLine,
288
308
  'percent': Percent,
289
309
  'phone': Phone,
@@ -298,7 +318,9 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
298
318
  'repeat-2': Repeat2,
299
319
  'reply': Reply,
300
320
  'rotate-ccw': RotateCcw,
321
+ 'rows': Rows,
301
322
  'ruler': Ruler,
323
+ 'search': Search,
302
324
  'send': Send,
303
325
  'settings': Settings,
304
326
  'shapes': Shapes,
@@ -310,6 +332,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
310
332
  'shopping-cart': ShoppingCart,
311
333
  'shuffle': Shuffle,
312
334
  'sliders': Sliders,
335
+ 'sliders-horizontal': SlidersHorizontal,
313
336
  'smartphone': Smartphone,
314
337
  'snowflake': Snowflake,
315
338
  'sparkles': Sparkles,
@@ -321,11 +344,14 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
321
344
  'thumbs-up': ThumbsUp,
322
345
  'ticket': Ticket,
323
346
  'timer': Timer,
347
+ 'toggle-left': ToggleLeft,
348
+ 'toggle-right': ToggleRight,
324
349
  'trash-2': Trash2,
325
350
  'trending-up': TrendingUp,
326
351
  'triangle-alert': TriangleAlert,
327
352
  'trophy': Trophy,
328
353
  'truck': Truck,
354
+ 'type': Type,
329
355
  'undo': Undo,
330
356
  'undo-2': Undo2,
331
357
  'unlock': Unlock,
@@ -1,5 +1,6 @@
1
1
  /** @jest-environment jsdom */
2
2
  import { act, renderHook } from '@testing-library/react'
3
+ import { emitOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
3
4
  import { useEventBridge } from '../eventBridge'
4
5
 
5
6
  class EventSourceMock {
@@ -9,8 +10,14 @@ class EventSourceMock {
9
10
  onmessage: ((event: MessageEvent) => void) | null = null
10
11
  onerror: ((event: Event) => void) | null = null
11
12
  close = jest.fn()
13
+ readonly url: string
14
+ readonly options: EventSourceInit | undefined
15
+ readonly cookiesAtCreation: string
12
16
 
13
- constructor() {
17
+ constructor(url: string | URL, options?: EventSourceInit) {
18
+ this.url = String(url)
19
+ this.options = options
20
+ this.cookiesAtCreation = document.cookie
14
21
  EventSourceMock.instances.push(this)
15
22
  }
16
23
  }
@@ -81,4 +88,43 @@ describe('useEventBridge connection lifecycle', () => {
81
88
  expect(EventSourceMock.instances).toHaveLength(2)
82
89
  unmount()
83
90
  })
91
+
92
+ it('reconnects immediately with current cookies when organization scope changes', () => {
93
+ document.cookie = 'om_selected_org=org-before; path=/'
94
+ const { unmount } = renderHook(() => useEventBridge())
95
+ const firstSource = EventSourceMock.instances[0]
96
+ act(() => firstSource.onopen?.(new Event('open')))
97
+
98
+ document.cookie = 'om_selected_org=org-after; path=/'
99
+ act(() => {
100
+ emitOrganizationScopeChanged({ organizationId: 'org-after', tenantId: 'tenant-1' })
101
+ })
102
+
103
+ expect(firstSource.close).toHaveBeenCalledTimes(1)
104
+ expect(EventSourceMock.instances).toHaveLength(2)
105
+ const replacementSource = EventSourceMock.instances[1]
106
+ expect(replacementSource.url).toBe('/api/events/stream')
107
+ expect(replacementSource.options).toEqual({ withCredentials: true })
108
+ expect(replacementSource.cookiesAtCreation).toContain('om_selected_org=org-after')
109
+ act(() => firstSource.onerror?.(new Event('error')))
110
+ expect(replacementSource.close).not.toHaveBeenCalled()
111
+ expect(EventSourceMock.instances).toHaveLength(2)
112
+ unmount()
113
+ })
114
+
115
+ it('keeps the open stream when the scope signal does not change the scope cookies', () => {
116
+ document.cookie = 'om_selected_org=org-same; path=/'
117
+ document.cookie = 'om_selected_tenant=tenant-1; path=/'
118
+ const { unmount } = renderHook(() => useEventBridge())
119
+ const source = EventSourceMock.instances[0]
120
+ act(() => source.onopen?.(new Event('open')))
121
+
122
+ act(() => {
123
+ emitOrganizationScopeChanged({ organizationId: 'org-same', tenantId: 'tenant-1' })
124
+ })
125
+
126
+ expect(source.close).not.toHaveBeenCalled()
127
+ expect(EventSourceMock.instances).toHaveLength(1)
128
+ unmount()
129
+ })
84
130
  })
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
  import { useEffect, useRef } from 'react'
3
3
  import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
4
+ import { subscribeOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
4
5
  import { APP_EVENT_DOM_NAME } from './useAppEvent'
5
6
  import { createLogger } from '@open-mercato/shared/lib/logger'
6
7
 
@@ -12,6 +13,21 @@ const RECONNECT_BASE_MS = 1_000
12
13
  const RECONNECT_MAX_MS = 30_000
13
14
  const DEDUP_WINDOW_MS = 500
14
15
  const BRIDGE_RECONNECTED_EVENT_ID = 'om:bridge:reconnected'
16
+ const SCOPE_COOKIE_NAMES = ['om_selected_org', 'om_selected_tenant'] as const
17
+
18
+ function readScopeCookieSignature(): string {
19
+ if (typeof document === 'undefined') return ''
20
+ const values = new Map<string, string>()
21
+ for (const part of document.cookie.split(';')) {
22
+ const separatorIndex = part.indexOf('=')
23
+ if (separatorIndex < 0) continue
24
+ const name = part.slice(0, separatorIndex).trim()
25
+ if ((SCOPE_COOKIE_NAMES as readonly string[]).includes(name)) {
26
+ values.set(name, part.slice(separatorIndex + 1).trim())
27
+ }
28
+ }
29
+ return SCOPE_COOKIE_NAMES.map((name) => `${name}=${values.get(name) ?? ''}`).join(';')
30
+ }
15
31
 
16
32
  type EventBridgeReadyWindow = { __omEventBridgeReady?: boolean }
17
33
 
@@ -41,6 +57,7 @@ export function useEventBridge(): void {
41
57
  const recentEvents = useRef<Map<string, number>>(new Map())
42
58
  const hasEverConnected = useRef(false)
43
59
  const reconnectPending = useRef(false)
60
+ const connectedScopeSignature = useRef<string | null>(null)
44
61
 
45
62
  useEffect(() => {
46
63
  let mounted = true
@@ -80,8 +97,10 @@ export function useEventBridge(): void {
80
97
  try {
81
98
  const source = new EventSource(SSE_ENDPOINT, { withCredentials: true })
82
99
  sourceRef.current = source
100
+ connectedScopeSignature.current = readScopeCookieSignature()
83
101
 
84
102
  source.onopen = () => {
103
+ if (sourceRef.current !== source) return
85
104
  const shouldEmitReconnect = hasEverConnected.current && reconnectPending.current
86
105
  hasEverConnected.current = true
87
106
  reconnectPending.current = false
@@ -103,6 +122,7 @@ export function useEventBridge(): void {
103
122
  }
104
123
 
105
124
  source.onmessage = (event) => {
125
+ if (sourceRef.current !== source) return
106
126
  resetHeartbeatTimer()
107
127
  if (!event.data || event.data === ':heartbeat') return
108
128
 
@@ -121,6 +141,7 @@ export function useEventBridge(): void {
121
141
  }
122
142
 
123
143
  source.onerror = () => {
144
+ if (sourceRef.current !== source) return
124
145
  if (hasEverConnected.current) {
125
146
  reconnectPending.current = true
126
147
  }
@@ -173,12 +194,26 @@ export function useEventBridge(): void {
173
194
  connect()
174
195
  }
175
196
 
197
+ function handleOrganizationScopeChange() {
198
+ if (sourceRef.current && connectedScopeSignature.current === readScopeCookieSignature()) return
199
+ if (hasEverConnected.current) reconnectPending.current = true
200
+ disconnect()
201
+ if (reconnectTimer.current) {
202
+ clearTimeout(reconnectTimer.current)
203
+ reconnectTimer.current = null
204
+ }
205
+ reconnectAttempts.current = 0
206
+ connect()
207
+ }
208
+
176
209
  document.addEventListener('visibilitychange', handleVisibilityChange)
210
+ const unsubscribeOrganizationScope = subscribeOrganizationScopeChanged(handleOrganizationScopeChange)
177
211
  connect()
178
212
 
179
213
  return () => {
180
214
  mounted = false
181
215
  document.removeEventListener('visibilitychange', handleVisibilityChange)
216
+ unsubscribeOrganizationScope()
182
217
  disconnect()
183
218
  if (reconnectTimer.current) {
184
219
  clearTimeout(reconnectTimer.current)
@@ -44,6 +44,16 @@ type LookupSelectProps = {
44
44
  defaultOpen?: boolean
45
45
  }
46
46
 
47
+ function keepSelectedItem(
48
+ result: LookupSelectItem[],
49
+ selected: LookupSelectItem | null,
50
+ value: string | null,
51
+ ): LookupSelectItem[] {
52
+ if (!value || !selected || selected.id !== value) return result
53
+ if (result.some((item) => item.id === value)) return result
54
+ return [selected, ...result]
55
+ }
56
+
47
57
  export function LookupSelect({
48
58
  value,
49
59
  onChange,
@@ -97,11 +107,17 @@ export function LookupSelect({
97
107
  const setQueryRef = React.useRef(setQuery)
98
108
  const onReadyRef = React.useRef(onReady)
99
109
  const optionsWasArrayRef = React.useRef(Array.isArray(options))
110
+ const valueRef = React.useRef(value)
111
+ const selectedItemRef = React.useRef<LookupSelectItem | null>(null)
100
112
 
101
113
  React.useEffect(() => {
102
114
  fetchItemsRef.current = fetchItems ?? fetchOptions
103
115
  }, [fetchItems, fetchOptions])
104
116
 
117
+ React.useEffect(() => {
118
+ valueRef.current = value
119
+ }, [value])
120
+
105
121
  React.useEffect(() => {
106
122
  onReadyRef.current = onReady
107
123
  }, [onReady])
@@ -215,10 +231,19 @@ export function LookupSelect({
215
231
  const requestId = Date.now()
216
232
  const fetcher = fetchItemsRef.current
217
233
  const loader = fetcher ?? (() => Promise.resolve(options ?? []))
218
- loader(query.trim())
234
+ const trimmedQuery = query.trim()
235
+ loader(trimmedQuery)
219
236
  .then((result) => {
220
237
  if (cancelled) return
221
- setItems(result)
238
+ // A set `value` opens the list with an empty query, and that browse
239
+ // fetch returns an arbitrary first page. Without this the stored
240
+ // selection vanished from the list whenever it fell outside that page,
241
+ // while the form still held its id. A typed query stays a pure search.
242
+ setItems(
243
+ trimmedQuery
244
+ ? result
245
+ : keepSelectedItem(result, selectedItemRef.current, valueRef.current),
246
+ )
222
247
  })
223
248
  .catch((err) => {
224
249
  if (cancelled) return
@@ -240,11 +265,15 @@ export function LookupSelect({
240
265
 
241
266
  React.useEffect(() => {
242
267
  if (!value) {
268
+ selectedItemRef.current = null
243
269
  setSelectedItem(null)
244
270
  return
245
271
  }
246
272
  const match = items.find((item) => item.id === value)
247
- if (match) setSelectedItem(match)
273
+ if (match) {
274
+ selectedItemRef.current = match
275
+ setSelectedItem(match)
276
+ }
248
277
  }, [items, value])
249
278
 
250
279
  /*
@@ -725,7 +725,7 @@ export function PhoneNumberField({
725
725
  </SelectTrigger>
726
726
  <SelectContent align="start">
727
727
  {countryOptions.map(({ country: c, label }) => (
728
- <SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2}>
728
+ <SelectItem key={`${c.iso2}-${c.dialCode}`} value={c.iso2} textValue={label}>
729
729
  <SelectItemLeading>
730
730
  <span className="text-base leading-none">{c.flag}</span>
731
731
  </SelectItemLeading>
@@ -67,6 +67,8 @@ export function TagsInput({
67
67
  const [asyncOptions, setAsyncOptions] = React.useState<TagsInputOption[]>([])
68
68
  const [loading, setLoading] = React.useState(false)
69
69
  const [touched, setTouched] = React.useState(false)
70
+ const [suggestionsDismissed, setSuggestionsDismissed] = React.useState(false)
71
+ const inputRef = React.useRef<HTMLInputElement>(null)
70
72
  const suppressBlurCommitRef = React.useRef(false)
71
73
  const suppressSuggestionsOnFocusRef = React.useRef(Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled))
72
74
  const valueRef = React.useRef(value)
@@ -141,6 +143,21 @@ export function TagsInput({
141
143
  }
142
144
  }, [disabled, input, loadSuggestions, touched])
143
145
 
146
+ const suggestionsOpen = !suggestionsDismissed
147
+ && ((loading && touched) || (!loading && filteredSuggestions.length > 0))
148
+
149
+ React.useEffect(() => {
150
+ if (!suggestionsOpen) return
151
+ const handleEscape = (event: KeyboardEvent) => {
152
+ if (event.key !== 'Escape' || event.target !== inputRef.current) return
153
+ event.preventDefault()
154
+ event.stopPropagation()
155
+ setSuggestionsDismissed(true)
156
+ }
157
+ window.addEventListener('keydown', handleEscape, { capture: true })
158
+ return () => window.removeEventListener('keydown', handleEscape, { capture: true })
159
+ }, [suggestionsOpen])
160
+
144
161
  const addValue = React.useCallback(
145
162
  (nextValue: string) => {
146
163
  if (disabled) return
@@ -226,6 +243,7 @@ export function TagsInput({
226
243
  )
227
244
  })}
228
245
  <input
246
+ ref={inputRef}
229
247
  className="flex-1 min-w-[80px] sm:min-w-[120px] border-0 py-1 text-sm outline-none disabled:bg-transparent"
230
248
  value={input}
231
249
  placeholder={placeholder || t('ui.inputs.tagsInput.placeholder', 'Add tag and press Enter')}
@@ -239,13 +257,16 @@ export function TagsInput({
239
257
  }
240
258
  if (showSuggestionsOnFocus) {
241
259
  setTouched(true)
260
+ setSuggestionsDismissed(false)
242
261
  }
243
262
  }}
244
263
  onMouseDown={() => {
245
264
  setTouched(true)
265
+ setSuggestionsDismissed(false)
246
266
  }}
247
267
  onChange={(event) => {
248
268
  setTouched(true)
269
+ setSuggestionsDismissed(false)
249
270
  setInput(event.target.value)
250
271
  }}
251
272
  onKeyDown={(event) => {
@@ -269,10 +290,10 @@ export function TagsInput({
269
290
  setInput('')
270
291
  }}
271
292
  />
272
- {loading && touched ? (
273
- <div className="basis-full mt-1 text-xs text-muted-foreground">Loading suggestions…</div>
293
+ {suggestionsOpen && loading && touched ? (
294
+ <div className="basis-full mt-1 text-xs text-muted-foreground">{t('ui.inputs.tagsInput.loading', 'Loading suggestions…')}</div>
274
295
  ) : null}
275
- {!loading && filteredSuggestions.length ? (
296
+ {suggestionsOpen && !loading && filteredSuggestions.length ? (
276
297
  <div className="basis-full mt-1 flex flex-col gap-1">
277
298
  {filteredSuggestions.map((option) => (
278
299
  <Button
@@ -5,7 +5,7 @@ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
5
5
  }))
6
6
 
7
7
  import * as React from 'react'
8
- import { fireEvent, render, screen } from '@testing-library/react'
8
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
9
9
  import { LookupSelect } from '../LookupSelect'
10
10
 
11
11
  function getInput(container: HTMLElement): HTMLInputElement {
@@ -308,6 +308,171 @@ describe('LookupSelect selected value display', () => {
308
308
  })
309
309
  })
310
310
 
311
+ // A set `value` opens the list and fires a browse fetch with an empty query. Its
312
+ // result used to replace the items outright, so an edit form hydrated with
313
+ // `value={storedId}` + `options={[storedOption]}` lost the stored selection from
314
+ // the list whenever the record fell outside that arbitrary first page — the form
315
+ // still held the id, but nothing on screen was marked as chosen.
316
+ describe('LookupSelect keeps the selection across browse fetches', () => {
317
+ const STORED = { id: 'contractor-99', title: 'Stored Contractor' }
318
+ const FIRST_PAGE = [
319
+ { id: 'contractor-1', title: 'Alpha' },
320
+ { id: 'contractor-2', title: 'Beta' },
321
+ ]
322
+
323
+ it('keeps a hydrated selection that is missing from the first page', async () => {
324
+ const fetchItems = jest.fn(async () => FIRST_PAGE)
325
+ render(
326
+ <LookupSelect
327
+ value={STORED.id}
328
+ onChange={() => {}}
329
+ options={[STORED]}
330
+ fetchItems={fetchItems}
331
+ />,
332
+ )
333
+
334
+ await screen.findByText('Alpha')
335
+ expect(fetchItems).toHaveBeenCalledWith('')
336
+ expect(screen.getByRole('option', { selected: true })).toHaveTextContent(STORED.title)
337
+ expect(screen.getAllByRole('option')).toHaveLength(3)
338
+ })
339
+
340
+ it('uses the fetched copy and adds no duplicate when the page contains the selection', async () => {
341
+ const fetchItems = jest.fn(async () => [
342
+ ...FIRST_PAGE,
343
+ { id: STORED.id, title: 'Stored Contractor (renamed)' },
344
+ ])
345
+ render(
346
+ <LookupSelect
347
+ value={STORED.id}
348
+ onChange={() => {}}
349
+ options={[STORED]}
350
+ fetchItems={fetchItems}
351
+ />,
352
+ )
353
+
354
+ await screen.findByText('Alpha')
355
+ expect(screen.getAllByRole('option')).toHaveLength(3)
356
+ expect(screen.getByRole('option', { selected: true })).toHaveTextContent('Stored Contractor (renamed)')
357
+ })
358
+
359
+ it('does not inject the selection into the results of a typed search', async () => {
360
+ const fetchItems = jest.fn(async (query: string) => (query ? [FIRST_PAGE[0]] : FIRST_PAGE))
361
+ const { container } = render(
362
+ <LookupSelect
363
+ value={STORED.id}
364
+ onChange={() => {}}
365
+ options={[STORED]}
366
+ fetchItems={fetchItems}
367
+ />,
368
+ )
369
+ await screen.findByText('Beta')
370
+
371
+ fireEvent.change(getInput(container), { target: { value: 'Alp' } })
372
+ await waitFor(() => expect(screen.queryByText('Beta')).toBeNull())
373
+
374
+ expect(screen.getAllByRole('option')).toHaveLength(1)
375
+ expect(screen.getByRole('option')).toHaveTextContent('Alpha')
376
+ })
377
+
378
+ it('restores the selection once the typed query is cleared again', async () => {
379
+ const fetchItems = jest.fn(async (query: string) => (query ? [FIRST_PAGE[0]] : FIRST_PAGE))
380
+ const { container } = render(
381
+ <LookupSelect
382
+ value={STORED.id}
383
+ onChange={() => {}}
384
+ options={[STORED]}
385
+ fetchItems={fetchItems}
386
+ />,
387
+ )
388
+ const input = getInput(container)
389
+ await screen.findByText('Beta')
390
+
391
+ fireEvent.change(input, { target: { value: 'Alp' } })
392
+ await waitFor(() => expect(screen.queryByText('Beta')).toBeNull())
393
+ fireEvent.change(input, { target: { value: '' } })
394
+
395
+ await screen.findByText('Beta')
396
+ expect(screen.getByRole('option', { selected: true })).toHaveTextContent(STORED.title)
397
+ })
398
+
399
+ it('keeps a selection picked from a search when the list goes back to browsing', async () => {
400
+ const fetchItems = jest.fn(async (query: string) => (query ? [STORED] : FIRST_PAGE))
401
+ function Harness() {
402
+ const [value, setValue] = React.useState<string | null>(null)
403
+ return <LookupSelect value={value} onChange={setValue} fetchItems={fetchItems} />
404
+ }
405
+ const { container } = render(<Harness />)
406
+ const input = getInput(container)
407
+
408
+ fireEvent.change(input, { target: { value: 'Stored' } })
409
+ fireEvent.click(await screen.findByRole('option'))
410
+ fireEvent.change(input, { target: { value: '' } })
411
+
412
+ await screen.findByText('Alpha')
413
+ expect(screen.getByRole('option', { selected: true })).toHaveTextContent(STORED.title)
414
+ })
415
+
416
+ it('keeps a known selection listed when the browse fetch comes back empty', async () => {
417
+ const fetchItems = jest.fn(async (query: string) => (query ? [STORED] : []))
418
+ function Harness() {
419
+ const [value, setValue] = React.useState<string | null>(null)
420
+ return <LookupSelect value={value} onChange={setValue} fetchItems={fetchItems} />
421
+ }
422
+ const { container } = render(<Harness />)
423
+ const input = getInput(container)
424
+
425
+ fireEvent.change(input, { target: { value: 'Stored' } })
426
+ fireEvent.click(await screen.findByRole('option'))
427
+ fireEvent.change(input, { target: { value: '' } })
428
+
429
+ await waitFor(() => expect(fetchItems).toHaveBeenLastCalledWith(''))
430
+ await waitFor(() => expect(screen.queryByText('Searching…')).toBeNull())
431
+ expect(screen.getByRole('option', { selected: true })).toHaveTextContent(STORED.title)
432
+ expect(screen.getAllByRole('option')).toHaveLength(1)
433
+ expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument()
434
+ })
435
+
436
+ it('never re-adds a previous selection after the value moves on', async () => {
437
+ const fetchItems = jest.fn(async (query: string) => (query ? [FIRST_PAGE[0]] : FIRST_PAGE))
438
+ const view = render(
439
+ <LookupSelect
440
+ value={STORED.id}
441
+ onChange={() => {}}
442
+ options={[STORED]}
443
+ fetchItems={fetchItems}
444
+ />,
445
+ )
446
+ await screen.findByText('Beta')
447
+
448
+ view.rerender(
449
+ <LookupSelect
450
+ value="contractor-unknown"
451
+ onChange={() => {}}
452
+ options={[STORED]}
453
+ fetchItems={fetchItems}
454
+ />,
455
+ )
456
+ const input = getInput(view.container)
457
+ fireEvent.change(input, { target: { value: 'Alp' } })
458
+ await waitFor(() => expect(screen.queryByText('Beta')).toBeNull())
459
+ fireEvent.change(input, { target: { value: '' } })
460
+
461
+ await screen.findByText('Beta')
462
+ expect(screen.queryByText(STORED.title)).toBeNull()
463
+ expect(screen.queryByRole('option', { selected: true })).toBeNull()
464
+ })
465
+
466
+ it('leaves the browse results untouched when nothing is selected', async () => {
467
+ render(
468
+ <LookupSelect value={null} onChange={() => {}} fetchItems={async () => FIRST_PAGE} minQuery={0} />,
469
+ )
470
+
471
+ expect(await screen.findAllByRole('option')).toHaveLength(2)
472
+ expect(screen.queryByRole('option', { selected: true })).toBeNull()
473
+ })
474
+ })
475
+
311
476
  // `disabled` used to gate only the search box, so a caller that locked the
312
477
  // control still shipped a live option list: the selected card kept its click and
313
478
  // Enter/Space handlers, "Clear selection" stayed reachable, and the action slot
@@ -0,0 +1,43 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
4
+ useT: () => (_key: string, fallback: string) => fallback,
5
+ useOptionalLocale: () => undefined,
6
+ }))
7
+
8
+ jest.mock('../../../primitives/select', () => {
9
+ const React = jest.requireActual('react')
10
+ return {
11
+ Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
12
+ SelectTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
13
+ SelectContent: ({ children }: { children: React.ReactNode }) => <ul>{children}</ul>,
14
+ SelectItemLeading: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
15
+ SelectItem: ({ children, value, textValue }: { children: React.ReactNode; value: string; textValue?: string }) => (
16
+ <li data-testid="country-option" data-value={value} data-text-value={textValue ?? ''}>
17
+ {children}
18
+ </li>
19
+ ),
20
+ }
21
+ })
22
+
23
+ import * as React from 'react'
24
+ import { render, screen } from '@testing-library/react'
25
+ import { PHONE_COUNTRIES, PhoneNumberField } from '../PhoneNumberField'
26
+
27
+ describe('PhoneNumberField country typeahead', () => {
28
+ it('uses the country name as the searchable text of each option', () => {
29
+ render(<PhoneNumberField value={undefined} onValueChange={() => undefined} />)
30
+
31
+ const options = screen.getAllByTestId('country-option')
32
+ expect(options).toHaveLength(PHONE_COUNTRIES.length)
33
+
34
+ const poland = options.find((option) => option.getAttribute('data-value') === 'PL')
35
+ expect(poland?.getAttribute('data-text-value')).toMatch(/^Pol/)
36
+
37
+ for (const option of options) {
38
+ const textValue = option.getAttribute('data-text-value') ?? ''
39
+ expect(textValue).not.toBe('')
40
+ expect(textValue).not.toMatch(/^[\u{1F1E6}-\u{1F1FF}+]/u)
41
+ }
42
+ })
43
+ })