@open-mercato/ui 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

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 (67) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/records/ActivityCard.js +4 -3
  3. package/dist/ai/records/ActivityCard.js.map +2 -2
  4. package/dist/ai/records/DealCard.js +4 -3
  5. package/dist/ai/records/DealCard.js.map +2 -2
  6. package/dist/ai/records/ProductCard.js +3 -2
  7. package/dist/ai/records/ProductCard.js.map +2 -2
  8. package/dist/backend/AppShell.js +129 -106
  9. package/dist/backend/AppShell.js.map +2 -2
  10. package/dist/backend/DataTable.js +20 -7
  11. package/dist/backend/DataTable.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +26 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/icons/lucideRegistry.generated.js +4 -0
  15. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  16. package/dist/backend/injection/useRegisteredComponent.js +24 -6
  17. package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
  18. package/dist/backend/inputs/ComboboxInput.js +8 -4
  19. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +8 -8
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js +36 -30
  23. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  24. package/dist/backend/utils/crud.js.map +2 -2
  25. package/dist/primitives/alert.js +1 -1
  26. package/dist/primitives/alert.js.map +2 -2
  27. package/dist/primitives/icon-button.js +20 -1
  28. package/dist/primitives/icon-button.js.map +2 -2
  29. package/dist/primitives/pagination.js +14 -9
  30. package/dist/primitives/pagination.js.map +2 -2
  31. package/dist/primitives/rich-editor.js +14 -7
  32. package/dist/primitives/rich-editor.js.map +2 -2
  33. package/dist/utils/format.js +5 -5
  34. package/dist/utils/format.js.map +2 -2
  35. package/package.json +5 -4
  36. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +2 -0
  37. package/src/ai/records/ActivityCard.tsx +4 -3
  38. package/src/ai/records/DealCard.tsx +4 -3
  39. package/src/ai/records/ProductCard.tsx +3 -2
  40. package/src/backend/AppShell.tsx +89 -58
  41. package/src/backend/DataTable.tsx +44 -10
  42. package/src/backend/__tests__/AppShell.test.tsx +162 -1
  43. package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
  44. package/src/backend/__tests__/DataTable.headerWrap.test.tsx +111 -0
  45. package/src/backend/__tests__/DataTable.render.test.tsx +37 -0
  46. package/src/backend/__tests__/NotesSection.test.tsx +89 -0
  47. package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
  48. package/src/backend/detail/NotesSection.tsx +92 -67
  49. package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
  50. package/src/backend/injection/useRegisteredComponent.tsx +49 -8
  51. package/src/backend/inputs/ComboboxInput.tsx +21 -5
  52. package/src/backend/inputs/LookupSelect.tsx +8 -8
  53. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +142 -0
  54. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
  55. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +14 -11
  56. package/src/backend/utils/crud.ts +2 -0
  57. package/src/primitives/__tests__/alert.test.tsx +41 -1
  58. package/src/primitives/__tests__/icon-button.test.tsx +70 -0
  59. package/src/primitives/__tests__/notification.test.tsx +1 -1
  60. package/src/primitives/__tests__/pagination.test.tsx +54 -0
  61. package/src/primitives/__tests__/rich-editor.test.tsx +43 -0
  62. package/src/primitives/alert.tsx +5 -2
  63. package/src/primitives/icon-button.tsx +20 -1
  64. package/src/primitives/pagination.tsx +40 -9
  65. package/src/primitives/rich-editor.tsx +9 -4
  66. package/src/utils/__tests__/format.test.ts +27 -14
  67. package/src/utils/format.ts +10 -5
@@ -207,7 +207,20 @@ export type NotesSectionProps<C = unknown> = {
207
207
  iconSuggestions?: IconOption[]
208
208
  readMarkdownPreference?: () => boolean | null
209
209
  writeMarkdownPreference?: (value: boolean) => void
210
+ /** Force the plain textarea and hide the markdown toggle. Wins over `forceMarkdown`. */
210
211
  disableMarkdown?: boolean
212
+ /**
213
+ * Keep the rich markdown editor active for every note and hide the markdown toggle,
214
+ * so the editor shape cannot be switched. Ignored when `disableMarkdown` is set.
215
+ */
216
+ forceMarkdown?: boolean
217
+ /**
218
+ * Hide the markdown toggle without changing which editor renders — the seeded
219
+ * `readMarkdownPreference` value stays in effect. Implied by `forceMarkdown`.
220
+ */
221
+ hideMarkdownToggle?: boolean
222
+ /** Hide the appearance (icon/color) affordances: the palette buttons and the appearance dialog. */
223
+ disableAppearance?: boolean
211
224
  }
212
225
 
213
226
  export function sanitizeHexColor(value: string | null): string | null {
@@ -313,6 +326,9 @@ function NotesSectionImpl<C = unknown>({
313
326
  readMarkdownPreference,
314
327
  writeMarkdownPreference,
315
328
  disableMarkdown,
329
+ forceMarkdown,
330
+ hideMarkdownToggle,
331
+ disableAppearance,
316
332
  }: NotesSectionProps<C>) {
317
333
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
318
334
  const t = React.useMemo<Translator>(() => translator ?? ((key, fallback) => fallback ?? key), [translator])
@@ -448,13 +464,16 @@ function NotesSectionImpl<C = unknown>({
448
464
  const [draftIcon, setDraftIcon] = React.useState<string | null>(null)
449
465
  const [draftColor, setDraftColor] = React.useState<string | null>(null)
450
466
  const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(false)
467
+ const isMarkdownActive = !disableMarkdown && (Boolean(forceMarkdown) || isMarkdownEnabled)
468
+ const showMarkdownToggle = !disableMarkdown && !forceMarkdown && !hideMarkdownToggle
469
+ const showAppearanceControls = !disableAppearance
451
470
  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
452
471
  const formRef = React.useRef<HTMLFormElement | null>(null)
453
472
  const focusComposer = React.useCallback(() => {
454
473
  if (!hasEntity) return
455
474
  setComposerOpen(true)
456
475
  window.requestAnimationFrame(() => {
457
- if (isMarkdownEnabled) {
476
+ if (isMarkdownActive) {
458
477
  const markdownTextarea = formRef.current?.querySelector('textarea')
459
478
  if (markdownTextarea instanceof HTMLTextAreaElement) {
460
479
  markdownTextarea.focus()
@@ -467,7 +486,7 @@ function NotesSectionImpl<C = unknown>({
467
486
  element.focus()
468
487
  element.scrollIntoView({ behavior: 'smooth', block: 'center' })
469
488
  })
470
- }, [formRef, hasEntity, isMarkdownEnabled])
489
+ }, [formRef, hasEntity, isMarkdownActive])
471
490
  const [appearanceDialogState, setAppearanceDialogState] = React.useState<
472
491
  | { mode: 'create'; icon: string | null; color: string | null }
473
492
  | { mode: 'edit'; noteId: string; icon: string | null; color: string | null }
@@ -584,7 +603,7 @@ function NotesSectionImpl<C = unknown>({
584
603
 
585
604
  React.useEffect(() => {
586
605
  adjustTextareaSize(textareaRef.current)
587
- }, [adjustTextareaSize, draftBody, isMarkdownEnabled, composerOpen])
606
+ }, [adjustTextareaSize, draftBody, isMarkdownActive, composerOpen])
588
607
 
589
608
  React.useEffect(() => {
590
609
  const preference = readMarkdownPreference ? readMarkdownPreference() : null
@@ -990,31 +1009,33 @@ function NotesSectionImpl<C = unknown>({
990
1009
  <div className="flex flex-wrap items-center justify-between gap-2">
991
1010
  <h3 className="text-sm font-medium">{label('addLabel')}</h3>
992
1011
  <div className="flex flex-wrap items-center gap-1">
993
- <Button
994
- type="button"
995
- variant="ghost"
996
- size="icon"
997
- onClick={() => {
998
- setAppearanceDialogError(null)
999
- setAppearanceDialogState({ mode: 'create', icon: draftIcon, color: draftColor })
1000
- }}
1001
- disabled={isSubmitting || isLoading || !hasEntity}
1002
- >
1003
- <span className="sr-only">{label('appearance.toggleOpen', 'Customize appearance')}</span>
1004
- <Palette className="h-4 w-4" />
1005
- </Button>
1006
- {disableMarkdown ? null : (
1012
+ {showAppearanceControls ? (
1007
1013
  <Button
1008
1014
  type="button"
1009
- variant={isMarkdownEnabled ? 'secondary' : 'ghost'}
1015
+ variant="ghost"
1016
+ size="icon"
1017
+ onClick={() => {
1018
+ setAppearanceDialogError(null)
1019
+ setAppearanceDialogState({ mode: 'create', icon: draftIcon, color: draftColor })
1020
+ }}
1021
+ disabled={isSubmitting || isLoading || !hasEntity}
1022
+ >
1023
+ <span className="sr-only">{label('appearance.toggleOpen', 'Customize appearance')}</span>
1024
+ <Palette className="h-4 w-4" />
1025
+ </Button>
1026
+ ) : null}
1027
+ {showMarkdownToggle ? (
1028
+ <Button
1029
+ type="button"
1030
+ variant={isMarkdownActive ? 'secondary' : 'ghost'}
1010
1031
  size="icon"
1011
1032
  onClick={handleMarkdownToggle}
1012
- aria-pressed={isMarkdownEnabled}
1033
+ aria-pressed={isMarkdownActive}
1013
1034
  disabled={isSubmitting || isLoading}
1014
1035
  >
1015
1036
  <FileCode className="h-4 w-4" />
1016
1037
  </Button>
1017
- )}
1038
+ ) : null}
1018
1039
  <Button
1019
1040
  type="button"
1020
1041
  size="sm"
@@ -1090,7 +1111,7 @@ function NotesSectionImpl<C = unknown>({
1090
1111
  <SwitchableMarkdownInput
1091
1112
  value={draftBody}
1092
1113
  onChange={setDraftBody}
1093
- isMarkdownEnabled={isMarkdownEnabled}
1114
+ isMarkdownEnabled={isMarkdownActive}
1094
1115
  disableMarkdown={disableMarkdown}
1095
1116
  rows={1}
1096
1117
  placeholder={label('placeholder')}
@@ -1214,24 +1235,26 @@ function NotesSectionImpl<C = unknown>({
1214
1235
  >
1215
1236
  <Pencil className="h-4 w-4" />
1216
1237
  </Button>
1217
- <Button
1218
- type="button"
1219
- variant="ghost"
1220
- size="icon"
1221
- onClick={(event) => {
1222
- event.stopPropagation()
1223
- setAppearanceDialogError(null)
1224
- setAppearanceDialogState({
1225
- mode: 'edit',
1226
- noteId: note.id,
1227
- icon: note.appearanceIcon ?? null,
1228
- color: note.appearanceColor ?? null,
1229
- })
1230
- }}
1231
- disabled={appearanceDialogSaving && editingAppearanceNoteId === note.id}
1232
- >
1233
- {isAppearanceSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Palette className="h-4 w-4" />}
1234
- </Button>
1238
+ {showAppearanceControls ? (
1239
+ <Button
1240
+ type="button"
1241
+ variant="ghost"
1242
+ size="icon"
1243
+ onClick={(event) => {
1244
+ event.stopPropagation()
1245
+ setAppearanceDialogError(null)
1246
+ setAppearanceDialogState({
1247
+ mode: 'edit',
1248
+ noteId: note.id,
1249
+ icon: note.appearanceIcon ?? null,
1250
+ color: note.appearanceColor ?? null,
1251
+ })
1252
+ }}
1253
+ disabled={appearanceDialogSaving && editingAppearanceNoteId === note.id}
1254
+ >
1255
+ {isAppearanceSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Palette className="h-4 w-4" />}
1256
+ </Button>
1257
+ ) : null}
1235
1258
  <Button
1236
1259
  type="button"
1237
1260
  variant="ghost"
@@ -1257,7 +1280,7 @@ function NotesSectionImpl<C = unknown>({
1257
1280
  <SwitchableMarkdownInput
1258
1281
  value={contentEditor.value}
1259
1282
  onChange={(nextValue) => setContentEditor((prev) => ({ ...prev, value: nextValue }))}
1260
- isMarkdownEnabled={isMarkdownEnabled}
1283
+ isMarkdownEnabled={isMarkdownActive}
1261
1284
  disableMarkdown={disableMarkdown}
1262
1285
  rows={3}
1263
1286
  textareaRef={contentTextareaRef}
@@ -1278,19 +1301,19 @@ function NotesSectionImpl<C = unknown>({
1278
1301
  inlineLabel('saveShortcut')
1279
1302
  )}
1280
1303
  </Button>
1281
- {disableMarkdown ? null : (
1304
+ {showMarkdownToggle ? (
1282
1305
  <Button
1283
1306
  type="button"
1284
1307
  variant="ghost"
1285
1308
  size="icon"
1286
1309
  onClick={handleMarkdownToggle}
1287
- aria-pressed={isMarkdownEnabled}
1288
- className={isMarkdownEnabled ? 'text-primary' : undefined}
1310
+ aria-pressed={isMarkdownActive}
1311
+ className={isMarkdownActive ? 'text-primary' : undefined}
1289
1312
  disabled={contentSavingId === note.id}
1290
1313
  >
1291
1314
  <FileCode className="h-4 w-4" />
1292
1315
  </Button>
1293
- )}
1316
+ ) : null}
1294
1317
  <Button
1295
1318
  type="button"
1296
1319
  size="sm"
@@ -1340,29 +1363,31 @@ function NotesSectionImpl<C = unknown>({
1340
1363
  </div>
1341
1364
  )}
1342
1365
  </div>
1343
- <AppearanceDialog
1344
- open={appearanceDialogOpen}
1345
- title={
1346
- appearanceDialogState?.mode === 'edit'
1347
- ? label('appearance.edit')
1348
- : label('appearance.toggleOpen', 'Customize appearance')
1349
- }
1350
- icon={appearanceDialogState?.icon ?? null}
1351
- color={appearanceDialogState?.color ?? null}
1352
- labels={noteAppearanceLabels}
1353
- iconSuggestions={iconSuggestions}
1354
- onIconChange={(value) => setAppearanceDialogState((prev) => (prev ? { ...prev, icon: value ?? null } : prev))}
1355
- onColorChange={(value) => setAppearanceDialogState((prev) => (prev ? { ...prev, color: value ?? null } : prev))}
1356
- onSubmit={() => {
1357
- void handleAppearanceDialogSubmit()
1358
- }}
1359
- onClose={handleAppearanceDialogClose}
1360
- isSaving={appearanceDialogSaving}
1361
- errorMessage={appearanceDialogError}
1362
- primaryLabel={appearanceDialogPrimaryLabel}
1363
- savingLabel={appearanceDialogSavingLabel}
1364
- cancelLabel={label('appearance.cancel')}
1365
- />
1366
+ {showAppearanceControls ? (
1367
+ <AppearanceDialog
1368
+ open={appearanceDialogOpen}
1369
+ title={
1370
+ appearanceDialogState?.mode === 'edit'
1371
+ ? label('appearance.edit')
1372
+ : label('appearance.toggleOpen', 'Customize appearance')
1373
+ }
1374
+ icon={appearanceDialogState?.icon ?? null}
1375
+ color={appearanceDialogState?.color ?? null}
1376
+ labels={noteAppearanceLabels}
1377
+ iconSuggestions={iconSuggestions}
1378
+ onIconChange={(value) => setAppearanceDialogState((prev) => (prev ? { ...prev, icon: value ?? null } : prev))}
1379
+ onColorChange={(value) => setAppearanceDialogState((prev) => (prev ? { ...prev, color: value ?? null } : prev))}
1380
+ onSubmit={() => {
1381
+ void handleAppearanceDialogSubmit()
1382
+ }}
1383
+ onClose={handleAppearanceDialogClose}
1384
+ isSaving={appearanceDialogSaving}
1385
+ errorMessage={appearanceDialogError}
1386
+ primaryLabel={appearanceDialogPrimaryLabel}
1387
+ savingLabel={appearanceDialogSavingLabel}
1388
+ cancelLabel={label('appearance.cancel')}
1389
+ />
1390
+ ) : null}
1366
1391
  {ConfirmDialogElement}
1367
1392
  </div>
1368
1393
  )
@@ -11,6 +11,7 @@ import {
11
11
  AlertTriangle,
12
12
  Apple,
13
13
  Archive,
14
+ AtSign,
14
15
  Award,
15
16
  BadgeCheck,
16
17
  Ban,
@@ -21,6 +22,7 @@ import {
21
22
  Bolt,
22
23
  Book,
23
24
  Bookmark,
25
+ Bot,
24
26
  Box,
25
27
  Boxes,
26
28
  Briefcase,
@@ -173,6 +175,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
173
175
  'alert-triangle': AlertTriangle,
174
176
  'apple': Apple,
175
177
  'archive': Archive,
178
+ 'at-sign': AtSign,
176
179
  'award': Award,
177
180
  'badge-check': BadgeCheck,
178
181
  'ban': Ban,
@@ -183,6 +186,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
183
186
  'bolt': Bolt,
184
187
  'book': Book,
185
188
  'bookmark': Bookmark,
189
+ 'bot': Bot,
186
190
  'box': Box,
187
191
  'boxes': Boxes,
188
192
  'briefcase': Briefcase,
@@ -40,6 +40,30 @@ type Resolution<TProps> = {
40
40
  replacementModule: string
41
41
  }
42
42
 
43
+ /**
44
+ * Calling a wrapper override returns a fresh component every time, which would
45
+ * reintroduce the identity churn this hook exists to avoid. Memoizing per
46
+ * (wrapper, wrapped component) pair keeps the composed component referentially
47
+ * stable for as long as both inputs are.
48
+ */
49
+ const composedWrappers = new WeakMap<object, WeakMap<object, unknown>>()
50
+
51
+ function applyWrapper<TProps>(
52
+ wrapper: (Original: ComponentType<TProps>) => ComponentType<TProps>,
53
+ Base: ComponentType<TProps>,
54
+ ): ComponentType<TProps> {
55
+ let byBase = composedWrappers.get(wrapper)
56
+ if (!byBase) {
57
+ byBase = new WeakMap<object, unknown>()
58
+ composedWrappers.set(wrapper, byBase)
59
+ }
60
+ const cached = byBase.get(Base)
61
+ if (cached) return cached as ComponentType<TProps>
62
+ const composed = wrapper(Base)
63
+ byBase.set(Base, composed)
64
+ return composed
65
+ }
66
+
43
67
  function resolveComponent<TProps>(
44
68
  componentId: string,
45
69
  fallback: ComponentType<TProps> | undefined,
@@ -75,7 +99,7 @@ function resolveComponent<TProps>(
75
99
  }
76
100
 
77
101
  const base = replacement ?? original
78
- const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper(acc), base)
102
+ const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => applyWrapper(wrapper, acc), base)
79
103
 
80
104
  return {
81
105
  original,
@@ -87,8 +111,9 @@ function resolveComponent<TProps>(
87
111
  }
88
112
 
89
113
  /**
90
- * The returned component's identity must depend only on `componentId` and
91
- * `fallback` — never on the override registry.
114
+ * The returned component's identity must depend only on `componentId` — never
115
+ * on the override registry, and never on the `fallback` a caller happens to
116
+ * pass on this render.
92
117
  *
93
118
  * Overrides arrive asynchronously: `ComponentOverridesBootstrap` dynamically
94
119
  * imports the generated override module and hands the provider a fresh array,
@@ -103,18 +128,32 @@ function resolveComponent<TProps>(
103
128
  * carries no override for this id, `wrapped` keeps its previous identity and
104
129
  * React reconciles in place; only a genuine replacement or wrapper swaps the
105
130
  * rendered type, where a remount is the correct behaviour.
131
+ *
132
+ * The identity is held in a ref rather than a `useMemo`, because `useMemo` is a
133
+ * performance hint React is free to discard, and identity here is a correctness
134
+ * requirement rather than an optimisation. `fallback` is read through a ref for
135
+ * the same reason: a host that builds its fallback inline would otherwise swap
136
+ * the component this hook hands back on every render. That still cannot save a
137
+ * subtree rendered *through* such a fallback — the fallback itself is then the
138
+ * element type, and only the host can stabilise it — but it keeps the churn
139
+ * from spreading to hosts whose id does resolve to a registered component.
106
140
  */
107
141
  export function useRegisteredComponent<TProps>(
108
142
  componentId: string,
109
143
  fallback?: ComponentType<TProps>,
110
144
  ): ComponentType<TProps> {
111
- return React.useMemo(() => {
145
+ const fallbackRef = React.useRef<ComponentType<TProps> | undefined>(fallback)
146
+ fallbackRef.current = fallback
147
+
148
+ const registered = React.useRef<{ componentId: string; Component: ComponentType<TProps> } | null>(null)
149
+ if (!registered.current || registered.current.componentId !== componentId) {
112
150
  const Registered = (props: TProps) => {
113
151
  const userFeatures = useOverrideUserFeatures()
114
152
  const overrideRevision = useOverrideRegistryRevision()
153
+ const currentFallback = fallbackRef.current
115
154
  const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(
116
- () => resolveComponent<TProps>(componentId, fallback, userFeatures),
117
- [overrideRevision, userFeatures],
155
+ () => resolveComponent<TProps>(componentId, currentFallback, userFeatures),
156
+ [currentFallback, overrideRevision, userFeatures],
118
157
  )
119
158
 
120
159
  if (!original || !wrapped) return null
@@ -145,8 +184,10 @@ export function useRegisteredComponent<TProps>(
145
184
  }
146
185
 
147
186
  Registered.displayName = `RegisteredComponent(${componentId})`
148
- return Registered
149
- }, [componentId, fallback])
187
+ registered.current = { componentId, Component: Registered }
188
+ }
189
+
190
+ return registered.current.Component
150
191
  }
151
192
 
152
193
  export default useRegisteredComponent
@@ -100,6 +100,10 @@ export function ComboboxInput({
100
100
  const blurClosePendingRef = React.useRef(false)
101
101
  const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled))
102
102
  const eagerFallbackLoadedValueRef = React.useRef<string | null>(null)
103
+ // Tracks whether the user actually typed into the field during the current focus
104
+ // session. `touched` cannot serve this purpose because it is set by `onFocus`,
105
+ // which `autoFocus` triggers before the user does anything at all.
106
+ const userTypedRef = React.useRef(false)
103
107
 
104
108
  const staticOptions = React.useMemo(
105
109
  () => normalizeOptions([...(seedOptions ?? []), ...(suggestions ?? [])]),
@@ -247,12 +251,21 @@ export function ComboboxInput({
247
251
  // including it would re-run the effect on every render when the prop is an inline function
248
252
  }, [value, disabled, knownLabelValues, coveredOptionValues, eagerResolveLabel, loadSuggestions])
249
253
 
250
- // Sync input with value when value changes externally and input is not focused.
254
+ // Sync input with a value that changed outside the component. A focused field is
255
+ // synced too, because `autoFocus` can focus the control before an async default value
256
+ // arrives and a focus-only guard then leaves the control rendering an empty label for
257
+ // a value the form has already committed. Two conditions still block the sync:
258
+ // - the user is typing, so their query is never clobbered mid-keystroke;
259
+ // - `optionMap` only holds the self-mapping placeholder it synthesises for an
260
+ // uncovered value, which would paint the raw record id over a label the user just
261
+ // picked (`asyncOptions` is replaced on every load, so any follow-up load that
262
+ // misses the picked entry — a failed request, a debounce race, a composite label
263
+ // the route's `?search=` cannot match — drops it back to the placeholder).
251
264
  React.useEffect(() => {
252
- if (document.activeElement !== inputRef.current) {
253
- const option = optionMap.get(value)
254
- setInput(option?.label ?? value ?? '')
255
- }
265
+ const option = optionMap.get(value)
266
+ const hasRealLabel = Boolean(option && option.label !== option.value)
267
+ if (document.activeElement === inputRef.current && (userTypedRef.current || !hasRealLabel)) return
268
+ setInput(option?.label ?? value ?? '')
256
269
  }, [value, optionMap])
257
270
 
258
271
  const selectValue = React.useCallback(
@@ -265,6 +278,7 @@ export function ComboboxInput({
265
278
  setInput(option?.label ?? trimmed)
266
279
  setShowSuggestions(false)
267
280
  setSelectedIndex(-1)
281
+ userTypedRef.current = false
268
282
  },
269
283
  [disabled, onChange, optionMap, resetBlurCloseState]
270
284
  )
@@ -436,6 +450,7 @@ export function ComboboxInput({
436
450
  }}
437
451
  onChange={(event) => {
438
452
  setTouched(true)
453
+ userTypedRef.current = true
439
454
  setInput(event.target.value)
440
455
  setShowSuggestions(true)
441
456
  setSelectedIndex(-1)
@@ -445,6 +460,7 @@ export function ComboboxInput({
445
460
  // Delay closing so clicks on the popup can resolve first. If async
446
461
  // suggestions are still loading, keep the dropdown open instead of
447
462
  // closing before the first payload arrives.
463
+ userTypedRef.current = false
448
464
  blurClosePendingRef.current = true
449
465
  clearBlurCloseTimer()
450
466
  if (loadingRef.current) {
@@ -133,8 +133,8 @@ export function LookupSelect({
133
133
  )
134
134
 
135
135
  const isInteractiveItem = React.useCallback(
136
- (item: LookupSelectItem) => !item.disabled || value === item.id,
137
- [value],
136
+ (item: LookupSelectItem) => !disabled && (!item.disabled || value === item.id),
137
+ [disabled, value],
138
138
  )
139
139
 
140
140
  const moveActiveIndex = React.useCallback((direction: 1 | -1) => {
@@ -252,7 +252,7 @@ export function LookupSelect({
252
252
  aria-activedescendant={activeIndex >= 0 ? optionDomId(activeIndex) : undefined}
253
253
  />
254
254
  </div>
255
- {actionSlot ? <div className="sm:self-start">{actionSlot}</div> : null}
255
+ {actionSlot && !disabled ? <div className="sm:self-start">{actionSlot}</div> : null}
256
256
  </div>
257
257
  {shouldSearch ? (
258
258
  <div className="space-y-2">
@@ -272,7 +272,7 @@ export function LookupSelect({
272
272
  >
273
273
  {items.map((item, index) => {
274
274
  const isSelected = value === item.id
275
- const isInteractive = !item.disabled || isSelected
275
+ const isInteractive = isInteractiveItem(item)
276
276
  const isActive = index === activeIndex
277
277
  return (
278
278
  <div
@@ -287,7 +287,7 @@ export function LookupSelect({
287
287
  isActive && !isSelected ? 'border-foreground/20 bg-muted/30 shadow-sm' : null
288
288
  )}
289
289
  role="option"
290
- tabIndex={item.disabled ? -1 : 0}
290
+ tabIndex={isInteractive ? 0 : -1}
291
291
  onClick={() => {
292
292
  if (!isInteractive) return
293
293
  onChange(item.id)
@@ -300,8 +300,8 @@ export function LookupSelect({
300
300
  }
301
301
  }}
302
302
  aria-selected={isSelected}
303
- aria-disabled={item.disabled && !isSelected ? true : undefined}
304
- title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}
303
+ aria-disabled={isInteractive ? undefined : true}
304
+ title={isSelected ? resolvedSelectedLabel : isInteractive ? resolvedSelectLabel : undefined}
305
305
  >
306
306
  {item.icon ? (
307
307
  <div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground">
@@ -344,7 +344,7 @@ export function LookupSelect({
344
344
  )
345
345
  })}
346
346
  </div>
347
- {value ? (
347
+ {value && !disabled ? (
348
348
  <Button
349
349
  type="button"
350
350
  variant="ghost"
@@ -396,6 +396,148 @@ describe('ComboboxInput — eager label resolution', () => {
396
396
  rerender(<ComboboxInput value="b" onChange={() => {}} resolveLabel={resolveLabel} />)
397
397
  await waitFor(() => expect(getInput(container).value).toBe('Label-b'))
398
398
  })
399
+
400
+ it('shows a default value that arrives after autoFocus already took the field', async () => {
401
+ const options = [{ value: 'wh-1', label: 'Central warehouse' }]
402
+ const { container, rerender } = render(
403
+ <ComboboxInput value="" onChange={() => {}} autoFocus seedOptions={options} />,
404
+ )
405
+ expect(document.activeElement).toBe(getInput(container))
406
+ rerender(<ComboboxInput value="wh-1" onChange={() => {}} autoFocus seedOptions={options} />)
407
+ await waitFor(() => expect(getInput(container).value).toBe('Central warehouse'))
408
+ })
409
+
410
+ // Guards the opposite direction of the case above: it passes on both the old
411
+ // focus-based guard and the current typing-based one, so it is a behavior pin
412
+ // rather than a regression test for any specific bug.
413
+ it('does not overwrite the query while the user is typing into a focused field', async () => {
414
+ const options = [
415
+ { value: 'red', label: 'Red' },
416
+ { value: 'green', label: 'Green' },
417
+ ]
418
+ const { container, rerender } = render(
419
+ <ComboboxInput value="red" onChange={() => {}} seedOptions={options} />,
420
+ )
421
+ await waitFor(() => expect(getInput(container).value).toBe('Red'))
422
+ const input = getInput(container)
423
+ act(() => {
424
+ input.focus()
425
+ fireEvent.change(input, { target: { value: 'gre' } })
426
+ })
427
+ rerender(<ComboboxInput value="green" onChange={() => {}} seedOptions={options} />)
428
+ expect(getInput(container).value).toBe('gre')
429
+ })
430
+
431
+ // The regression the typing-based guard originally introduced: `selectValue` clears
432
+ // `userTypedRef` while the field keeps focus, so a follow-up load that no longer
433
+ // carries the picked option let the self-mapping placeholder overwrite the label with
434
+ // the raw record id. Fails on the intermediate implementation, passes on this one.
435
+ it('keeps a picked label when a follow-up load drops the option from the list', async () => {
436
+ const loadSuggestions = jest.fn(async (query?: string) =>
437
+ query === 'WH-B' ? [{ value: 'wh-b', label: 'Warehouse B (WH-B)' }] : [],
438
+ )
439
+ function Controlled() {
440
+ const [value, setValue] = React.useState('')
441
+ return (
442
+ <ComboboxInput
443
+ value={value}
444
+ onChange={setValue}
445
+ loadSuggestions={loadSuggestions}
446
+ allowCustomValues={false}
447
+ />
448
+ )
449
+ }
450
+ const { container } = render(<Controlled />)
451
+ const input = getInput(container)
452
+ act(() => {
453
+ input.focus()
454
+ fireEvent.change(input, { target: { value: 'WH-B' } })
455
+ })
456
+
457
+ const option = await screen.findByRole('option', { name: /Warehouse B \(WH-B\)/ })
458
+ act(() => {
459
+ fireEvent.click(option)
460
+ })
461
+ expect(getInput(container).value).toBe('Warehouse B (WH-B)')
462
+ expect(document.activeElement).toBe(getInput(container))
463
+
464
+ // Picking rewrote the query to the label, which re-fires the debounced loader; the
465
+ // route cannot match a composite label, so the option list comes back empty.
466
+ await waitFor(() => expect(loadSuggestions).toHaveBeenCalledWith('Warehouse B (WH-B)'))
467
+ await act(async () => {
468
+ await new Promise((res) => setTimeout(res, 250))
469
+ })
470
+ expect(getInput(container).value).toBe('Warehouse B (WH-B)')
471
+ })
472
+
473
+ it('adopts a real label that arrives while the field is focused', async () => {
474
+ const { container, rerender } = render(
475
+ <ComboboxInput value="wh-1" onChange={() => {}} seedOptions={[]} />,
476
+ )
477
+ const input = getInput(container)
478
+ act(() => {
479
+ input.focus()
480
+ })
481
+ expect(getInput(container).value).toBe('wh-1')
482
+
483
+ rerender(
484
+ <ComboboxInput
485
+ value="wh-1"
486
+ onChange={() => {}}
487
+ seedOptions={[{ value: 'wh-1', label: 'Central warehouse' }]}
488
+ />,
489
+ )
490
+ await waitFor(() => expect(getInput(container).value).toBe('Central warehouse'))
491
+ })
492
+
493
+ it('does not resurrect the previous label when the parent keeps the value after a clear', () => {
494
+ const onChange = jest.fn()
495
+ const { container } = render(
496
+ <ComboboxInput
497
+ value="red"
498
+ onChange={onChange}
499
+ clearable
500
+ seedOptions={[{ value: 'red', label: 'Red' }]}
501
+ />,
502
+ )
503
+ expect(getInput(container).value).toBe('Red')
504
+
505
+ act(() => {
506
+ fireEvent.click(screen.getByRole('button', { name: /clear value/i }))
507
+ })
508
+
509
+ expect(onChange).toHaveBeenCalledWith('')
510
+ expect(getInput(container).value).toBe('')
511
+ })
512
+
513
+ it('resumes syncing after a blur, so a later external change is not mistaken for typing', async () => {
514
+ const options = [
515
+ { value: 'red', label: 'Red' },
516
+ { value: 'green', label: 'Green' },
517
+ ]
518
+ const { container, rerender } = render(
519
+ <ComboboxInput value="red" onChange={() => {}} seedOptions={options} allowCustomValues={false} />,
520
+ )
521
+ const input = getInput(container)
522
+ act(() => {
523
+ input.focus()
524
+ fireEvent.change(input, { target: { value: 'gre' } })
525
+ })
526
+ expect(getInput(container).value).toBe('gre')
527
+
528
+ act(() => {
529
+ fireEvent.blur(input)
530
+ })
531
+ await waitFor(() => expect(getInput(container).value).toBe('Red'))
532
+
533
+ act(() => {
534
+ getInput(container).focus()
535
+ })
536
+ rerender(
537
+ <ComboboxInput value="green" onChange={() => {}} seedOptions={options} allowCustomValues={false} />,
538
+ )
539
+ await waitFor(() => expect(getInput(container).value).toBe('Green'))
540
+ })
399
541
  })
400
542
 
401
543
  describe('ComboboxInput accessibility', () => {