@open-mercato/ui 0.6.8-develop.7100.1.fbf66fca35 → 0.7.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 (71) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/records/ActivityCard.js +3 -4
  3. package/dist/ai/records/ActivityCard.js.map +2 -2
  4. package/dist/ai/records/DealCard.js +3 -4
  5. package/dist/ai/records/DealCard.js.map +2 -2
  6. package/dist/ai/records/ProductCard.js +2 -3
  7. package/dist/ai/records/ProductCard.js.map +2 -2
  8. package/dist/backend/AppShell.js +106 -129
  9. package/dist/backend/AppShell.js.map +2 -2
  10. package/dist/backend/DataTable.js +9 -22
  11. package/dist/backend/DataTable.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +20 -26
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/icons/lucideRegistry.generated.js +0 -4
  15. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  16. package/dist/backend/injection/useRegisteredComponent.js +6 -24
  17. package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
  18. package/dist/backend/inputs/ComboboxInput.js +4 -8
  19. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +9 -9
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -36
  23. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  24. package/dist/backend/utils/crud.js.map +2 -2
  25. package/dist/backend/utils/useAutoDiscoveredFields.js +8 -4
  26. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  27. package/dist/primitives/alert.js +1 -1
  28. package/dist/primitives/alert.js.map +2 -2
  29. package/dist/primitives/icon-button.js +1 -20
  30. package/dist/primitives/icon-button.js.map +2 -2
  31. package/dist/primitives/pagination.js +9 -14
  32. package/dist/primitives/pagination.js.map +2 -2
  33. package/dist/primitives/rich-editor.js +7 -14
  34. package/dist/primitives/rich-editor.js.map +2 -2
  35. package/dist/utils/format.js +5 -5
  36. package/dist/utils/format.js.map +2 -2
  37. package/package.json +4 -5
  38. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +0 -2
  39. package/src/ai/records/ActivityCard.tsx +3 -4
  40. package/src/ai/records/DealCard.tsx +3 -4
  41. package/src/ai/records/ProductCard.tsx +2 -3
  42. package/src/backend/AppShell.tsx +58 -89
  43. package/src/backend/DataTable.tsx +12 -46
  44. package/src/backend/__tests__/AppShell.test.tsx +1 -162
  45. package/src/backend/__tests__/DataTable.render.test.tsx +0 -37
  46. package/src/backend/__tests__/NotesSection.test.tsx +0 -89
  47. package/src/backend/detail/NotesSection.tsx +67 -92
  48. package/src/backend/icons/lucideRegistry.generated.tsx +0 -4
  49. package/src/backend/injection/useRegisteredComponent.tsx +8 -49
  50. package/src/backend/inputs/ComboboxInput.tsx +5 -21
  51. package/src/backend/inputs/LookupSelect.tsx +14 -10
  52. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +0 -142
  53. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +40 -63
  54. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +11 -14
  55. package/src/backend/utils/__tests__/useAutoDiscoveredFields.groupLabels.test.tsx +59 -0
  56. package/src/backend/utils/crud.ts +0 -2
  57. package/src/backend/utils/useAutoDiscoveredFields.ts +10 -4
  58. package/src/primitives/__tests__/alert.test.tsx +1 -41
  59. package/src/primitives/__tests__/icon-button.test.tsx +0 -70
  60. package/src/primitives/__tests__/notification.test.tsx +1 -1
  61. package/src/primitives/__tests__/pagination.test.tsx +0 -54
  62. package/src/primitives/__tests__/rich-editor.test.tsx +0 -43
  63. package/src/primitives/alert.tsx +2 -5
  64. package/src/primitives/icon-button.tsx +1 -20
  65. package/src/primitives/pagination.tsx +9 -40
  66. package/src/primitives/rich-editor.tsx +4 -9
  67. package/src/utils/__tests__/format.test.ts +14 -27
  68. package/src/utils/format.ts +5 -10
  69. package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +0 -122
  70. package/src/backend/__tests__/DataTable.headerWrap.test.tsx +0 -111
  71. package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +0 -305
@@ -7,33 +7,6 @@ import { NotesSection, type NotesDataAdapter } from '../detail/NotesSection'
7
7
  import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
8
8
  import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
9
9
 
10
- const createDataAdapter = (): NotesDataAdapter => ({
11
- list: jest.fn(async () => [
12
- {
13
- id: 'note-1',
14
- body: 'Existing note',
15
- createdAt: '2026-04-10T08:00:00.000Z',
16
- authorName: 'Ada Lovelace',
17
- },
18
- ]),
19
- create: jest.fn(async () => ({ id: 'note-2' })),
20
- update: jest.fn(async () => undefined),
21
- delete: jest.fn(async () => undefined),
22
- })
23
-
24
- const baseProps = (dataAdapter: NotesDataAdapter) => ({
25
- entityId: 'person-1',
26
- emptyLabel: '—',
27
- viewerUserId: 'user-1',
28
- viewerName: 'Ada Lovelace',
29
- addActionLabel: 'Add note',
30
- emptyState: {
31
- title: 'No notes yet',
32
- actionLabel: 'Add note',
33
- },
34
- dataAdapter,
35
- })
36
-
37
10
  describe('NotesSection', () => {
38
11
  beforeEach(() => {
39
12
  dismissRecordConflict()
@@ -198,68 +171,6 @@ describe('NotesSection', () => {
198
171
  })
199
172
  })
200
173
 
201
- it('renders the markdown toggle and appearance controls by default', async () => {
202
- const { container } = renderWithProviders(<NotesSection {...baseProps(createDataAdapter())} />)
203
-
204
- await screen.findByText('Existing note')
205
- expect(container.querySelectorAll('svg.lucide-palette').length).toBe(1)
206
-
207
- fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
208
-
209
- await waitFor(() => {
210
- expect(container.querySelector('button[aria-pressed]')).not.toBeNull()
211
- })
212
- expect(screen.getByRole('button', { name: 'Customize appearance' })).toBeTruthy()
213
- expect(screen.queryByTestId('markdown-field')).toBeNull()
214
- })
215
-
216
- it('keeps the rich editor active and hides the toggle when forceMarkdown is set', async () => {
217
- const { container } = renderWithProviders(
218
- <NotesSection {...baseProps(createDataAdapter())} forceMarkdown />,
219
- )
220
-
221
- await screen.findByText('Existing note')
222
- fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
223
-
224
- await waitFor(() => {
225
- expect(screen.getByTestId('markdown-field')).toBeTruthy()
226
- })
227
- expect(container.querySelector('button[aria-pressed]')).toBeNull()
228
- })
229
-
230
- it('hides the toggle but honors the seeded preference when hideMarkdownToggle is set', async () => {
231
- const { container } = renderWithProviders(
232
- <NotesSection
233
- {...baseProps(createDataAdapter())}
234
- hideMarkdownToggle
235
- readMarkdownPreference={() => true}
236
- />,
237
- )
238
-
239
- await screen.findByText('Existing note')
240
- fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
241
-
242
- await waitFor(() => {
243
- expect(screen.getByTestId('markdown-field')).toBeTruthy()
244
- })
245
- expect(container.querySelector('button[aria-pressed]')).toBeNull()
246
- })
247
-
248
- it('removes every appearance affordance when disableAppearance is set', async () => {
249
- const { container } = renderWithProviders(
250
- <NotesSection {...baseProps(createDataAdapter())} disableAppearance />,
251
- )
252
-
253
- await screen.findByText('Existing note')
254
- fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
255
-
256
- await waitFor(() => {
257
- expect(container.querySelector('button[aria-pressed]')).not.toBeNull()
258
- })
259
- expect(screen.queryByRole('button', { name: 'Customize appearance' })).toBeNull()
260
- expect(container.querySelectorAll('svg.lucide-palette').length).toBe(0)
261
- })
262
-
263
174
  it('surfaces the unified conflict bar when a write fails with a 409', async () => {
264
175
  const conflict = {
265
176
  status: 409,
@@ -207,20 +207,7 @@ 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`. */
211
210
  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
224
211
  }
225
212
 
226
213
  export function sanitizeHexColor(value: string | null): string | null {
@@ -326,9 +313,6 @@ function NotesSectionImpl<C = unknown>({
326
313
  readMarkdownPreference,
327
314
  writeMarkdownPreference,
328
315
  disableMarkdown,
329
- forceMarkdown,
330
- hideMarkdownToggle,
331
- disableAppearance,
332
316
  }: NotesSectionProps<C>) {
333
317
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
334
318
  const t = React.useMemo<Translator>(() => translator ?? ((key, fallback) => fallback ?? key), [translator])
@@ -464,16 +448,13 @@ function NotesSectionImpl<C = unknown>({
464
448
  const [draftIcon, setDraftIcon] = React.useState<string | null>(null)
465
449
  const [draftColor, setDraftColor] = React.useState<string | null>(null)
466
450
  const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(false)
467
- const isMarkdownActive = !disableMarkdown && (Boolean(forceMarkdown) || isMarkdownEnabled)
468
- const showMarkdownToggle = !disableMarkdown && !forceMarkdown && !hideMarkdownToggle
469
- const showAppearanceControls = !disableAppearance
470
451
  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
471
452
  const formRef = React.useRef<HTMLFormElement | null>(null)
472
453
  const focusComposer = React.useCallback(() => {
473
454
  if (!hasEntity) return
474
455
  setComposerOpen(true)
475
456
  window.requestAnimationFrame(() => {
476
- if (isMarkdownActive) {
457
+ if (isMarkdownEnabled) {
477
458
  const markdownTextarea = formRef.current?.querySelector('textarea')
478
459
  if (markdownTextarea instanceof HTMLTextAreaElement) {
479
460
  markdownTextarea.focus()
@@ -486,7 +467,7 @@ function NotesSectionImpl<C = unknown>({
486
467
  element.focus()
487
468
  element.scrollIntoView({ behavior: 'smooth', block: 'center' })
488
469
  })
489
- }, [formRef, hasEntity, isMarkdownActive])
470
+ }, [formRef, hasEntity, isMarkdownEnabled])
490
471
  const [appearanceDialogState, setAppearanceDialogState] = React.useState<
491
472
  | { mode: 'create'; icon: string | null; color: string | null }
492
473
  | { mode: 'edit'; noteId: string; icon: string | null; color: string | null }
@@ -603,7 +584,7 @@ function NotesSectionImpl<C = unknown>({
603
584
 
604
585
  React.useEffect(() => {
605
586
  adjustTextareaSize(textareaRef.current)
606
- }, [adjustTextareaSize, draftBody, isMarkdownActive, composerOpen])
587
+ }, [adjustTextareaSize, draftBody, isMarkdownEnabled, composerOpen])
607
588
 
608
589
  React.useEffect(() => {
609
590
  const preference = readMarkdownPreference ? readMarkdownPreference() : null
@@ -1009,33 +990,31 @@ function NotesSectionImpl<C = unknown>({
1009
990
  <div className="flex flex-wrap items-center justify-between gap-2">
1010
991
  <h3 className="text-sm font-medium">{label('addLabel')}</h3>
1011
992
  <div className="flex flex-wrap items-center gap-1">
1012
- {showAppearanceControls ? (
1013
- <Button
1014
- type="button"
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 ? (
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 : (
1028
1007
  <Button
1029
1008
  type="button"
1030
- variant={isMarkdownActive ? 'secondary' : 'ghost'}
1009
+ variant={isMarkdownEnabled ? 'secondary' : 'ghost'}
1031
1010
  size="icon"
1032
1011
  onClick={handleMarkdownToggle}
1033
- aria-pressed={isMarkdownActive}
1012
+ aria-pressed={isMarkdownEnabled}
1034
1013
  disabled={isSubmitting || isLoading}
1035
1014
  >
1036
1015
  <FileCode className="h-4 w-4" />
1037
1016
  </Button>
1038
- ) : null}
1017
+ )}
1039
1018
  <Button
1040
1019
  type="button"
1041
1020
  size="sm"
@@ -1111,7 +1090,7 @@ function NotesSectionImpl<C = unknown>({
1111
1090
  <SwitchableMarkdownInput
1112
1091
  value={draftBody}
1113
1092
  onChange={setDraftBody}
1114
- isMarkdownEnabled={isMarkdownActive}
1093
+ isMarkdownEnabled={isMarkdownEnabled}
1115
1094
  disableMarkdown={disableMarkdown}
1116
1095
  rows={1}
1117
1096
  placeholder={label('placeholder')}
@@ -1235,26 +1214,24 @@ function NotesSectionImpl<C = unknown>({
1235
1214
  >
1236
1215
  <Pencil className="h-4 w-4" />
1237
1216
  </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}
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>
1258
1235
  <Button
1259
1236
  type="button"
1260
1237
  variant="ghost"
@@ -1280,7 +1257,7 @@ function NotesSectionImpl<C = unknown>({
1280
1257
  <SwitchableMarkdownInput
1281
1258
  value={contentEditor.value}
1282
1259
  onChange={(nextValue) => setContentEditor((prev) => ({ ...prev, value: nextValue }))}
1283
- isMarkdownEnabled={isMarkdownActive}
1260
+ isMarkdownEnabled={isMarkdownEnabled}
1284
1261
  disableMarkdown={disableMarkdown}
1285
1262
  rows={3}
1286
1263
  textareaRef={contentTextareaRef}
@@ -1301,19 +1278,19 @@ function NotesSectionImpl<C = unknown>({
1301
1278
  inlineLabel('saveShortcut')
1302
1279
  )}
1303
1280
  </Button>
1304
- {showMarkdownToggle ? (
1281
+ {disableMarkdown ? null : (
1305
1282
  <Button
1306
1283
  type="button"
1307
1284
  variant="ghost"
1308
1285
  size="icon"
1309
1286
  onClick={handleMarkdownToggle}
1310
- aria-pressed={isMarkdownActive}
1311
- className={isMarkdownActive ? 'text-primary' : undefined}
1287
+ aria-pressed={isMarkdownEnabled}
1288
+ className={isMarkdownEnabled ? 'text-primary' : undefined}
1312
1289
  disabled={contentSavingId === note.id}
1313
1290
  >
1314
1291
  <FileCode className="h-4 w-4" />
1315
1292
  </Button>
1316
- ) : null}
1293
+ )}
1317
1294
  <Button
1318
1295
  type="button"
1319
1296
  size="sm"
@@ -1363,31 +1340,29 @@ function NotesSectionImpl<C = unknown>({
1363
1340
  </div>
1364
1341
  )}
1365
1342
  </div>
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}
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
+ />
1391
1366
  {ConfirmDialogElement}
1392
1367
  </div>
1393
1368
  )
@@ -11,7 +11,6 @@ import {
11
11
  AlertTriangle,
12
12
  Apple,
13
13
  Archive,
14
- AtSign,
15
14
  Award,
16
15
  BadgeCheck,
17
16
  Ban,
@@ -22,7 +21,6 @@ import {
22
21
  Bolt,
23
22
  Book,
24
23
  Bookmark,
25
- Bot,
26
24
  Box,
27
25
  Boxes,
28
26
  Briefcase,
@@ -175,7 +173,6 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
175
173
  'alert-triangle': AlertTriangle,
176
174
  'apple': Apple,
177
175
  'archive': Archive,
178
- 'at-sign': AtSign,
179
176
  'award': Award,
180
177
  'badge-check': BadgeCheck,
181
178
  'ban': Ban,
@@ -186,7 +183,6 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
186
183
  'bolt': Bolt,
187
184
  'book': Book,
188
185
  'bookmark': Bookmark,
189
- 'bot': Bot,
190
186
  'box': Box,
191
187
  'boxes': Boxes,
192
188
  'briefcase': Briefcase,
@@ -40,30 +40,6 @@ 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
-
67
43
  function resolveComponent<TProps>(
68
44
  componentId: string,
69
45
  fallback: ComponentType<TProps> | undefined,
@@ -99,7 +75,7 @@ function resolveComponent<TProps>(
99
75
  }
100
76
 
101
77
  const base = replacement ?? original
102
- const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => applyWrapper(wrapper, acc), base)
78
+ const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper(acc), base)
103
79
 
104
80
  return {
105
81
  original,
@@ -111,9 +87,8 @@ function resolveComponent<TProps>(
111
87
  }
112
88
 
113
89
  /**
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.
90
+ * The returned component's identity must depend only on `componentId` and
91
+ * `fallback` — never on the override registry.
117
92
  *
118
93
  * Overrides arrive asynchronously: `ComponentOverridesBootstrap` dynamically
119
94
  * imports the generated override module and hands the provider a fresh array,
@@ -128,32 +103,18 @@ function resolveComponent<TProps>(
128
103
  * carries no override for this id, `wrapped` keeps its previous identity and
129
104
  * React reconciles in place; only a genuine replacement or wrapper swaps the
130
105
  * 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.
140
106
  */
141
107
  export function useRegisteredComponent<TProps>(
142
108
  componentId: string,
143
109
  fallback?: ComponentType<TProps>,
144
110
  ): ComponentType<TProps> {
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) {
111
+ return React.useMemo(() => {
150
112
  const Registered = (props: TProps) => {
151
113
  const userFeatures = useOverrideUserFeatures()
152
114
  const overrideRevision = useOverrideRegistryRevision()
153
- const currentFallback = fallbackRef.current
154
115
  const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(
155
- () => resolveComponent<TProps>(componentId, currentFallback, userFeatures),
156
- [currentFallback, overrideRevision, userFeatures],
116
+ () => resolveComponent<TProps>(componentId, fallback, userFeatures),
117
+ [overrideRevision, userFeatures],
157
118
  )
158
119
 
159
120
  if (!original || !wrapped) return null
@@ -184,10 +145,8 @@ export function useRegisteredComponent<TProps>(
184
145
  }
185
146
 
186
147
  Registered.displayName = `RegisteredComponent(${componentId})`
187
- registered.current = { componentId, Component: Registered }
188
- }
189
-
190
- return registered.current.Component
148
+ return Registered
149
+ }, [componentId, fallback])
191
150
  }
192
151
 
193
152
  export default useRegisteredComponent
@@ -100,10 +100,6 @@ 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)
107
103
 
108
104
  const staticOptions = React.useMemo(
109
105
  () => normalizeOptions([...(seedOptions ?? []), ...(suggestions ?? [])]),
@@ -251,21 +247,12 @@ export function ComboboxInput({
251
247
  // including it would re-run the effect on every render when the prop is an inline function
252
248
  }, [value, disabled, knownLabelValues, coveredOptionValues, eagerResolveLabel, loadSuggestions])
253
249
 
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).
250
+ // Sync input with value when value changes externally and input is not focused.
264
251
  React.useEffect(() => {
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 ?? '')
252
+ if (document.activeElement !== inputRef.current) {
253
+ const option = optionMap.get(value)
254
+ setInput(option?.label ?? value ?? '')
255
+ }
269
256
  }, [value, optionMap])
270
257
 
271
258
  const selectValue = React.useCallback(
@@ -278,7 +265,6 @@ export function ComboboxInput({
278
265
  setInput(option?.label ?? trimmed)
279
266
  setShowSuggestions(false)
280
267
  setSelectedIndex(-1)
281
- userTypedRef.current = false
282
268
  },
283
269
  [disabled, onChange, optionMap, resetBlurCloseState]
284
270
  )
@@ -450,7 +436,6 @@ export function ComboboxInput({
450
436
  }}
451
437
  onChange={(event) => {
452
438
  setTouched(true)
453
- userTypedRef.current = true
454
439
  setInput(event.target.value)
455
440
  setShowSuggestions(true)
456
441
  setSelectedIndex(-1)
@@ -460,7 +445,6 @@ export function ComboboxInput({
460
445
  // Delay closing so clicks on the popup can resolve first. If async
461
446
  // suggestions are still loading, keep the dropdown open instead of
462
447
  // closing before the first payload arrives.
463
- userTypedRef.current = false
464
448
  blurClosePendingRef.current = true
465
449
  clearBlurCloseTimer()
466
450
  if (loadingRef.current) {
@@ -116,8 +116,12 @@ export function LookupSelect({
116
116
  if (onReadyRef.current) onReadyRef.current({ setQuery })
117
117
  }, [setQuery])
118
118
 
119
- const shouldSearch =
120
- defaultOpen || query.trim().length >= minQuery || Boolean(value && (options?.length ?? 0) > 0)
119
+ // A set `value` always opens the list: it is the only place the selected row,
120
+ // its checkmark and the clear control render, so collapsing over a selection
121
+ // would hide it with no way to see or undo it. This used to also require an
122
+ // `options` prop, which left every caller that resolves its selection through
123
+ // `fetchItems` blind whenever minQuery kept the list shut.
124
+ const shouldSearch = defaultOpen || query.trim().length >= minQuery || Boolean(value)
121
125
 
122
126
  React.useEffect(() => {
123
127
  setActiveIndex(-1)
@@ -129,8 +133,8 @@ export function LookupSelect({
129
133
  )
130
134
 
131
135
  const isInteractiveItem = React.useCallback(
132
- (item: LookupSelectItem) => !disabled && (!item.disabled || value === item.id),
133
- [disabled, value],
136
+ (item: LookupSelectItem) => !item.disabled || value === item.id,
137
+ [value],
134
138
  )
135
139
 
136
140
  const moveActiveIndex = React.useCallback((direction: 1 | -1) => {
@@ -248,7 +252,7 @@ export function LookupSelect({
248
252
  aria-activedescendant={activeIndex >= 0 ? optionDomId(activeIndex) : undefined}
249
253
  />
250
254
  </div>
251
- {actionSlot && !disabled ? <div className="sm:self-start">{actionSlot}</div> : null}
255
+ {actionSlot ? <div className="sm:self-start">{actionSlot}</div> : null}
252
256
  </div>
253
257
  {shouldSearch ? (
254
258
  <div className="space-y-2">
@@ -268,7 +272,7 @@ export function LookupSelect({
268
272
  >
269
273
  {items.map((item, index) => {
270
274
  const isSelected = value === item.id
271
- const isInteractive = isInteractiveItem(item)
275
+ const isInteractive = !item.disabled || isSelected
272
276
  const isActive = index === activeIndex
273
277
  return (
274
278
  <div
@@ -283,7 +287,7 @@ export function LookupSelect({
283
287
  isActive && !isSelected ? 'border-foreground/20 bg-muted/30 shadow-sm' : null
284
288
  )}
285
289
  role="option"
286
- tabIndex={isInteractive ? 0 : -1}
290
+ tabIndex={item.disabled ? -1 : 0}
287
291
  onClick={() => {
288
292
  if (!isInteractive) return
289
293
  onChange(item.id)
@@ -296,8 +300,8 @@ export function LookupSelect({
296
300
  }
297
301
  }}
298
302
  aria-selected={isSelected}
299
- aria-disabled={isInteractive ? undefined : true}
300
- title={isSelected ? resolvedSelectedLabel : isInteractive ? resolvedSelectLabel : undefined}
303
+ aria-disabled={item.disabled && !isSelected ? true : undefined}
304
+ title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}
301
305
  >
302
306
  {item.icon ? (
303
307
  <div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground">
@@ -340,7 +344,7 @@ export function LookupSelect({
340
344
  )
341
345
  })}
342
346
  </div>
343
- {value && !disabled ? (
347
+ {value ? (
344
348
  <Button
345
349
  type="button"
346
350
  variant="ghost"