@open-mercato/ui 0.7.0 → 0.7.1-develop.7103.1.41ff100d93
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/ai/records/ActivityCard.js +4 -3
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +4 -3
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +3 -2
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/backend/AppShell.js +129 -106
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +20 -7
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +26 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +4 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useRegisteredComponent.js +24 -6
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +8 -4
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +8 -8
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +36 -30
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/crud.js.map +2 -2
- package/dist/primitives/alert.js +1 -1
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/icon-button.js +20 -1
- package/dist/primitives/icon-button.js.map +2 -2
- package/dist/primitives/pagination.js +14 -9
- package/dist/primitives/pagination.js.map +2 -2
- package/dist/primitives/rich-editor.js +14 -7
- package/dist/primitives/rich-editor.js.map +2 -2
- package/dist/utils/format.js +5 -5
- package/dist/utils/format.js.map +2 -2
- package/package.json +5 -4
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +2 -0
- package/src/ai/records/ActivityCard.tsx +4 -3
- package/src/ai/records/DealCard.tsx +4 -3
- package/src/ai/records/ProductCard.tsx +3 -2
- package/src/backend/AppShell.tsx +89 -58
- package/src/backend/DataTable.tsx +44 -10
- package/src/backend/__tests__/AppShell.test.tsx +162 -1
- package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
- package/src/backend/__tests__/DataTable.headerWrap.test.tsx +111 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +37 -0
- package/src/backend/__tests__/NotesSection.test.tsx +89 -0
- package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
- package/src/backend/detail/NotesSection.tsx +92 -67
- package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
- package/src/backend/injection/useRegisteredComponent.tsx +49 -8
- package/src/backend/inputs/ComboboxInput.tsx +21 -5
- package/src/backend/inputs/LookupSelect.tsx +8 -8
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +142 -0
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +14 -11
- package/src/backend/utils/crud.ts +2 -0
- package/src/primitives/__tests__/alert.test.tsx +41 -1
- package/src/primitives/__tests__/icon-button.test.tsx +70 -0
- package/src/primitives/__tests__/notification.test.tsx +1 -1
- package/src/primitives/__tests__/pagination.test.tsx +54 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +43 -0
- package/src/primitives/alert.tsx +5 -2
- package/src/primitives/icon-button.tsx +20 -1
- package/src/primitives/pagination.tsx +40 -9
- package/src/primitives/rich-editor.tsx +9 -4
- package/src/utils/__tests__/format.test.ts +27 -14
- 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 (
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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=
|
|
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={
|
|
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={
|
|
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
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
event
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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={
|
|
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
|
-
{
|
|
1304
|
+
{showMarkdownToggle ? (
|
|
1282
1305
|
<Button
|
|
1283
1306
|
type="button"
|
|
1284
1307
|
variant="ghost"
|
|
1285
1308
|
size="icon"
|
|
1286
1309
|
onClick={handleMarkdownToggle}
|
|
1287
|
-
aria-pressed={
|
|
1288
|
-
className={
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
|
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`
|
|
91
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
149
|
-
}
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 =
|
|
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={
|
|
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={
|
|
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', () => {
|