@open-mercato/ui 0.6.8-develop.7057.1.61440fc3bc → 0.6.8-develop.7063.1.664341cf65
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/dist/backend/injection/useRegisteredComponent.js +24 -6
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +8 -8
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
- package/src/backend/injection/useRegisteredComponent.tsx +49 -8
- package/src/backend/inputs/LookupSelect.tsx +8 -8
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
|
@@ -21,6 +21,19 @@ class ReplacementErrorBoundary extends React.Component {
|
|
|
21
21
|
return this.props.children;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
const composedWrappers = /* @__PURE__ */ new WeakMap();
|
|
25
|
+
function applyWrapper(wrapper, Base) {
|
|
26
|
+
let byBase = composedWrappers.get(wrapper);
|
|
27
|
+
if (!byBase) {
|
|
28
|
+
byBase = /* @__PURE__ */ new WeakMap();
|
|
29
|
+
composedWrappers.set(wrapper, byBase);
|
|
30
|
+
}
|
|
31
|
+
const cached = byBase.get(Base);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
const composed = wrapper(Base);
|
|
34
|
+
byBase.set(Base, composed);
|
|
35
|
+
return composed;
|
|
36
|
+
}
|
|
24
37
|
function resolveComponent(componentId, fallback, userFeatures) {
|
|
25
38
|
const entry = getComponentEntry(componentId);
|
|
26
39
|
const original = entry?.component ?? fallback ?? null;
|
|
@@ -48,7 +61,7 @@ function resolveComponent(componentId, fallback, userFeatures) {
|
|
|
48
61
|
if ("propsTransform" in override) transforms.push(override.propsTransform);
|
|
49
62
|
}
|
|
50
63
|
const base = replacement ?? original;
|
|
51
|
-
const wrapped = wrappers.reduce((acc, wrapper) => wrapper
|
|
64
|
+
const wrapped = wrappers.reduce((acc, wrapper) => applyWrapper(wrapper, acc), base);
|
|
52
65
|
return {
|
|
53
66
|
original,
|
|
54
67
|
wrapped,
|
|
@@ -58,13 +71,17 @@ function resolveComponent(componentId, fallback, userFeatures) {
|
|
|
58
71
|
};
|
|
59
72
|
}
|
|
60
73
|
function useRegisteredComponent(componentId, fallback) {
|
|
61
|
-
|
|
74
|
+
const fallbackRef = React.useRef(fallback);
|
|
75
|
+
fallbackRef.current = fallback;
|
|
76
|
+
const registered = React.useRef(null);
|
|
77
|
+
if (!registered.current || registered.current.componentId !== componentId) {
|
|
62
78
|
const Registered = (props) => {
|
|
63
79
|
const userFeatures = useOverrideUserFeatures();
|
|
64
80
|
const overrideRevision = useOverrideRegistryRevision();
|
|
81
|
+
const currentFallback = fallbackRef.current;
|
|
65
82
|
const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(
|
|
66
|
-
() => resolveComponent(componentId,
|
|
67
|
-
[overrideRevision, userFeatures]
|
|
83
|
+
() => resolveComponent(componentId, currentFallback, userFeatures),
|
|
84
|
+
[currentFallback, overrideRevision, userFeatures]
|
|
68
85
|
);
|
|
69
86
|
if (!original || !wrapped) return null;
|
|
70
87
|
const transformed = transforms.reduce((current, transform) => transform(current), props);
|
|
@@ -88,8 +105,9 @@ function useRegisteredComponent(componentId, fallback) {
|
|
|
88
105
|
);
|
|
89
106
|
};
|
|
90
107
|
Registered.displayName = `RegisteredComponent(${componentId})`;
|
|
91
|
-
|
|
92
|
-
}
|
|
108
|
+
registered.current = { componentId, Component: Registered };
|
|
109
|
+
}
|
|
110
|
+
return registered.current.Component;
|
|
93
111
|
}
|
|
94
112
|
var useRegisteredComponent_default = useRegisteredComponent;
|
|
95
113
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/injection/useRegisteredComponent.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { ComponentType } from 'react'\nimport type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { getComponentEntry, getComponentOverrides } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { useOverrideRegistryRevision, useOverrideUserFeatures } from './ComponentOverrideProvider'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'useRegisteredComponent' })\n\nclass ReplacementErrorBoundary extends React.Component<\n { fallback: React.ReactNode; onError: (error: unknown) => void; children: React.ReactNode },\n { hasError: boolean }\n> {\n constructor(props: { fallback: React.ReactNode; onError: (error: unknown) => void; children: React.ReactNode }) {\n super(props)\n this.state = { hasError: false }\n }\n\n static getDerivedStateFromError(): { hasError: boolean } {\n return { hasError: true }\n }\n\n componentDidCatch(error: unknown): void {\n this.props.onError(error)\n }\n\n render() {\n if (this.state.hasError) return this.props.fallback\n return this.props.children\n }\n}\n\ntype Resolution<TProps> = {\n original: ComponentType<TProps> | null\n wrapped: ComponentType<TProps> | null\n transforms: Array<(props: TProps) => TProps>\n replacementOverride: ComponentOverride | null\n replacementModule: string\n}\n\nfunction resolveComponent<TProps>(\n componentId: string,\n fallback: ComponentType<TProps> | undefined,\n userFeatures: readonly string[],\n): Resolution<TProps> {\n const entry = getComponentEntry(componentId)\n const original = (entry?.component as ComponentType<TProps> | undefined) ?? fallback ?? null\n if (!original) {\n if (process.env.NODE_ENV !== 'production' && !fallback) {\n logger.warn('Component is not registered', { componentId })\n }\n return { original: null, wrapped: null, transforms: [], replacementOverride: null, replacementModule: 'unknown' }\n }\n\n const overrides = getComponentOverrides(componentId, userFeatures)\n const replacementOverrides = overrides.filter((override) => 'replacement' in override)\n if (process.env.NODE_ENV !== 'production' && replacementOverrides.length > 1) {\n logger.warn('Multiple replacements registered; highest-priority replacement is applied', { componentId })\n }\n\n let replacement: ComponentType<TProps> | null = null\n let replacementOverride: ComponentOverride | null = null\n const wrappers: Array<(Original: ComponentType<TProps>) => ComponentType<TProps>> = []\n const transforms: Array<(props: TProps) => TProps> = []\n\n for (const override of overrides) {\n if ('replacement' in override) {\n replacement = override.replacement as ComponentType<TProps>\n replacementOverride = override\n }\n if ('wrapper' in override) wrappers.push(override.wrapper as (Original: ComponentType<TProps>) => ComponentType<TProps>)\n if ('propsTransform' in override) transforms.push(override.propsTransform as (props: TProps) => TProps)\n }\n\n const base = replacement ?? original\n const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { ComponentType } from 'react'\nimport type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { getComponentEntry, getComponentOverrides } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { useOverrideRegistryRevision, useOverrideUserFeatures } from './ComponentOverrideProvider'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'useRegisteredComponent' })\n\nclass ReplacementErrorBoundary extends React.Component<\n { fallback: React.ReactNode; onError: (error: unknown) => void; children: React.ReactNode },\n { hasError: boolean }\n> {\n constructor(props: { fallback: React.ReactNode; onError: (error: unknown) => void; children: React.ReactNode }) {\n super(props)\n this.state = { hasError: false }\n }\n\n static getDerivedStateFromError(): { hasError: boolean } {\n return { hasError: true }\n }\n\n componentDidCatch(error: unknown): void {\n this.props.onError(error)\n }\n\n render() {\n if (this.state.hasError) return this.props.fallback\n return this.props.children\n }\n}\n\ntype Resolution<TProps> = {\n original: ComponentType<TProps> | null\n wrapped: ComponentType<TProps> | null\n transforms: Array<(props: TProps) => TProps>\n replacementOverride: ComponentOverride | null\n replacementModule: string\n}\n\n/**\n * Calling a wrapper override returns a fresh component every time, which would\n * reintroduce the identity churn this hook exists to avoid. Memoizing per\n * (wrapper, wrapped component) pair keeps the composed component referentially\n * stable for as long as both inputs are.\n */\nconst composedWrappers = new WeakMap<object, WeakMap<object, unknown>>()\n\nfunction applyWrapper<TProps>(\n wrapper: (Original: ComponentType<TProps>) => ComponentType<TProps>,\n Base: ComponentType<TProps>,\n): ComponentType<TProps> {\n let byBase = composedWrappers.get(wrapper)\n if (!byBase) {\n byBase = new WeakMap<object, unknown>()\n composedWrappers.set(wrapper, byBase)\n }\n const cached = byBase.get(Base)\n if (cached) return cached as ComponentType<TProps>\n const composed = wrapper(Base)\n byBase.set(Base, composed)\n return composed\n}\n\nfunction resolveComponent<TProps>(\n componentId: string,\n fallback: ComponentType<TProps> | undefined,\n userFeatures: readonly string[],\n): Resolution<TProps> {\n const entry = getComponentEntry(componentId)\n const original = (entry?.component as ComponentType<TProps> | undefined) ?? fallback ?? null\n if (!original) {\n if (process.env.NODE_ENV !== 'production' && !fallback) {\n logger.warn('Component is not registered', { componentId })\n }\n return { original: null, wrapped: null, transforms: [], replacementOverride: null, replacementModule: 'unknown' }\n }\n\n const overrides = getComponentOverrides(componentId, userFeatures)\n const replacementOverrides = overrides.filter((override) => 'replacement' in override)\n if (process.env.NODE_ENV !== 'production' && replacementOverrides.length > 1) {\n logger.warn('Multiple replacements registered; highest-priority replacement is applied', { componentId })\n }\n\n let replacement: ComponentType<TProps> | null = null\n let replacementOverride: ComponentOverride | null = null\n const wrappers: Array<(Original: ComponentType<TProps>) => ComponentType<TProps>> = []\n const transforms: Array<(props: TProps) => TProps> = []\n\n for (const override of overrides) {\n if ('replacement' in override) {\n replacement = override.replacement as ComponentType<TProps>\n replacementOverride = override\n }\n if ('wrapper' in override) wrappers.push(override.wrapper as (Original: ComponentType<TProps>) => ComponentType<TProps>)\n if ('propsTransform' in override) transforms.push(override.propsTransform as (props: TProps) => TProps)\n }\n\n const base = replacement ?? original\n const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => applyWrapper(wrapper, acc), base)\n\n return {\n original,\n wrapped,\n transforms,\n replacementOverride,\n replacementModule: replacementOverride?.metadata?.module ?? 'unknown',\n }\n}\n\n/**\n * The returned component's identity must depend only on `componentId` \u2014 never\n * on the override registry, and never on the `fallback` a caller happens to\n * pass on this render.\n *\n * Overrides arrive asynchronously: `ComponentOverridesBootstrap` dynamically\n * imports the generated override module and hands the provider a fresh array,\n * which bumps the registry revision some time after first paint. Resolving the\n * component in a `useMemo` keyed on that revision handed callers a brand-new\n * function on every bump, so React saw a different element type at that\n * position and unmounted the whole subtree \u2014 discarding its DOM and state. On\n * the login form that threw away credentials the user had already typed\n * (#5037), and the same hazard applied to every host of a registered section.\n *\n * Resolution therefore happens *inside* a stable component. When the revision\n * carries no override for this id, `wrapped` keeps its previous identity and\n * React reconciles in place; only a genuine replacement or wrapper swaps the\n * rendered type, where a remount is the correct behaviour.\n *\n * The identity is held in a ref rather than a `useMemo`, because `useMemo` is a\n * performance hint React is free to discard, and identity here is a correctness\n * requirement rather than an optimisation. `fallback` is read through a ref for\n * the same reason: a host that builds its fallback inline would otherwise swap\n * the component this hook hands back on every render. That still cannot save a\n * subtree rendered *through* such a fallback \u2014 the fallback itself is then the\n * element type, and only the host can stabilise it \u2014 but it keeps the churn\n * from spreading to hosts whose id does resolve to a registered component.\n */\nexport function useRegisteredComponent<TProps>(\n componentId: string,\n fallback?: ComponentType<TProps>,\n): ComponentType<TProps> {\n const fallbackRef = React.useRef<ComponentType<TProps> | undefined>(fallback)\n fallbackRef.current = fallback\n\n const registered = React.useRef<{ componentId: string; Component: ComponentType<TProps> } | null>(null)\n if (!registered.current || registered.current.componentId !== componentId) {\n const Registered = (props: TProps) => {\n const userFeatures = useOverrideUserFeatures()\n const overrideRevision = useOverrideRegistryRevision()\n const currentFallback = fallbackRef.current\n const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(\n () => resolveComponent<TProps>(componentId, currentFallback, userFeatures),\n [currentFallback, overrideRevision, userFeatures],\n )\n\n if (!original || !wrapped) return null\n\n const transformed = transforms.reduce((current, transform) => transform(current), props)\n const Fallback = React.createElement(original as React.ComponentType<Record<string, unknown>>, transformed as Record<string, unknown>)\n if (\n process.env.NODE_ENV !== 'production'\n && replacementOverride\n && 'replacement' in replacementOverride\n ) {\n const validation = replacementOverride.propsSchema.safeParse(transformed)\n if (!validation.success) {\n logger.error('Props schema validation failed for replacement', { componentId, module: replacementModule, issues: validation.error.format() })\n return Fallback\n }\n }\n return (\n <ReplacementErrorBoundary\n fallback={Fallback}\n onError={(error) => {\n logger.error('Component replacement failed', { componentId, module: replacementModule, err: error })\n }}\n >\n {React.createElement(wrapped as React.ComponentType<Record<string, unknown>>, transformed as Record<string, unknown>)}\n </ReplacementErrorBoundary>\n )\n }\n\n Registered.displayName = `RegisteredComponent(${componentId})`\n registered.current = { componentId, Component: Registered }\n }\n\n return registered.current.Component\n}\n\nexport default useRegisteredComponent\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -83,8 +83,8 @@ function LookupSelect({
|
|
|
83
83
|
[listboxId]
|
|
84
84
|
);
|
|
85
85
|
const isInteractiveItem = React.useCallback(
|
|
86
|
-
(item) => !item.disabled || value === item.id,
|
|
87
|
-
[value]
|
|
86
|
+
(item) => !disabled && (!item.disabled || value === item.id),
|
|
87
|
+
[disabled, value]
|
|
88
88
|
);
|
|
89
89
|
const moveActiveIndex = React.useCallback((direction) => {
|
|
90
90
|
setActiveIndex((current) => {
|
|
@@ -197,7 +197,7 @@ function LookupSelect({
|
|
|
197
197
|
}
|
|
198
198
|
)
|
|
199
199
|
] }),
|
|
200
|
-
actionSlot ? /* @__PURE__ */ jsx("div", { className: "sm:self-start", children: actionSlot }) : null
|
|
200
|
+
actionSlot && !disabled ? /* @__PURE__ */ jsx("div", { className: "sm:self-start", children: actionSlot }) : null
|
|
201
201
|
] }),
|
|
202
202
|
shouldSearch ? /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
203
203
|
loading || loadingProp ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [
|
|
@@ -213,7 +213,7 @@ function LookupSelect({
|
|
|
213
213
|
className: "flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5",
|
|
214
214
|
children: items.map((item, index) => {
|
|
215
215
|
const isSelected = value === item.id;
|
|
216
|
-
const isInteractive =
|
|
216
|
+
const isInteractive = isInteractiveItem(item);
|
|
217
217
|
const isActive = index === activeIndex;
|
|
218
218
|
return /* @__PURE__ */ jsxs(
|
|
219
219
|
"div",
|
|
@@ -226,7 +226,7 @@ function LookupSelect({
|
|
|
226
226
|
isActive && !isSelected ? "border-foreground/20 bg-muted/30 shadow-sm" : null
|
|
227
227
|
),
|
|
228
228
|
role: "option",
|
|
229
|
-
tabIndex:
|
|
229
|
+
tabIndex: isInteractive ? 0 : -1,
|
|
230
230
|
onClick: () => {
|
|
231
231
|
if (!isInteractive) return;
|
|
232
232
|
onChange(item.id);
|
|
@@ -239,8 +239,8 @@ function LookupSelect({
|
|
|
239
239
|
}
|
|
240
240
|
},
|
|
241
241
|
"aria-selected": isSelected,
|
|
242
|
-
"aria-disabled":
|
|
243
|
-
title: isSelected ? resolvedSelectedLabel : resolvedSelectLabel,
|
|
242
|
+
"aria-disabled": isInteractive ? void 0 : true,
|
|
243
|
+
title: isSelected ? resolvedSelectedLabel : isInteractive ? resolvedSelectLabel : void 0,
|
|
244
244
|
children: [
|
|
245
245
|
item.icon ? /* @__PURE__ */ jsx("div", { className: "flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground", children: item.icon }) : /* @__PURE__ */ jsx("div", { className: cn(
|
|
246
246
|
"flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border transition-colors",
|
|
@@ -262,7 +262,7 @@ function LookupSelect({
|
|
|
262
262
|
})
|
|
263
263
|
}
|
|
264
264
|
),
|
|
265
|
-
value ? /* @__PURE__ */ jsxs(
|
|
265
|
+
value && !disabled ? /* @__PURE__ */ jsxs(
|
|
266
266
|
Button,
|
|
267
267
|
{
|
|
268
268
|
type: "button",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/inputs/LookupSelect.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Check, Loader2, Search, X } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'LookupSelect' })\n\nexport type LookupSelectItem = {\n id: string\n title: string\n subtitle?: string | null\n badge?: string | null\n icon?: React.ReactNode\n disabled?: boolean\n rightLabel?: string | null\n description?: string | null\n}\n\ntype LookupSelectProps = {\n value: string | null\n onChange: (next: string | null) => void\n fetchItems?: (query: string) => Promise<LookupSelectItem[]>\n fetchOptions?: (query?: string) => Promise<LookupSelectItem[]>\n options?: LookupSelectItem[]\n minQuery?: number\n actionSlot?: React.ReactNode\n onReady?: (controls: { setQuery: (value: string) => void }) => void\n searchPlaceholder?: string\n placeholder?: string\n clearLabel?: string\n emptyLabel?: string\n loadingLabel?: string\n selectLabel?: string\n selectedLabel?: string\n minQueryHintLabel?: string\n startTypingLabel?: string\n selectedHintLabel?: (id: string) => string\n disabled?: boolean\n loading?: boolean\n defaultOpen?: boolean\n}\n\nexport function LookupSelect({\n value,\n onChange,\n fetchItems,\n fetchOptions,\n options,\n minQuery = 2,\n actionSlot,\n onReady,\n placeholder,\n searchPlaceholder,\n clearLabel,\n emptyLabel,\n loadingLabel,\n selectLabel,\n selectedLabel,\n minQueryHintLabel,\n startTypingLabel,\n selectedHintLabel,\n disabled = false,\n loading: loadingProp = false,\n defaultOpen = false,\n}: LookupSelectProps) {\n const t = useT()\n const resolvedSearchPlaceholder = searchPlaceholder ?? placeholder ?? t('ui.lookupSelect.searchPlaceholder', 'Search\u2026')\n const resolvedClearLabel = clearLabel ?? t('ui.lookupSelect.clearSelection', 'Clear selection')\n const resolvedEmptyLabel = emptyLabel ?? t('ui.lookupSelect.noResults', 'No results')\n const resolvedLoadingLabel = loadingLabel ?? t('ui.lookupSelect.searching', 'Searching\u2026')\n const resolvedSelectLabel = selectLabel ?? t('ui.lookupSelect.select', 'Select')\n const resolvedSelectedLabel = selectedLabel ?? t('ui.lookupSelect.selected', 'Selected')\n const resolvedStartTypingLabel = startTypingLabel ?? t('ui.lookupSelect.startTyping', 'Start typing to search.')\n const resolvedMinQueryHintLabel = minQueryHintLabel ?? t(\n 'ui.lookupSelect.minQueryHint',\n 'Type at least {minQuery} characters or paste an id to search.',\n { minQuery: String(minQuery) }\n )\n const [query, setQuery] = React.useState('')\n const [items, setItems] = React.useState<LookupSelectItem[]>(options ?? [])\n const [loading, setLoading] = React.useState(false)\n const [hasTyped, setHasTyped] = React.useState(defaultOpen)\n const [error, setError] = React.useState<string | null>(null)\n const [fetchKey, setFetchKey] = React.useState(0)\n const [activeIndex, setActiveIndex] = React.useState(-1)\n const listboxId = React.useId()\n const fetchItemsRef = React.useRef(fetchItems ?? fetchOptions)\n const setQueryRef = React.useRef(setQuery)\n const onReadyRef = React.useRef(onReady)\n const optionsWasArrayRef = React.useRef(Array.isArray(options))\n\n React.useEffect(() => {\n fetchItemsRef.current = fetchItems ?? fetchOptions\n }, [fetchItems, fetchOptions])\n\n React.useEffect(() => {\n onReadyRef.current = onReady\n }, [onReady])\n\n React.useEffect(() => {\n if (Array.isArray(options)) {\n optionsWasArrayRef.current = true\n setItems(options)\n } else if (optionsWasArrayRef.current) {\n optionsWasArrayRef.current = false\n setFetchKey((k) => k + 1)\n }\n }, [options])\n\n React.useEffect(() => {\n setQueryRef.current = setQuery\n if (onReadyRef.current) onReadyRef.current({ setQuery })\n }, [setQuery])\n\n const shouldSearch =\n defaultOpen || query.trim().length >= minQuery || Boolean(value && (options?.length ?? 0) > 0)\n\n React.useEffect(() => {\n setActiveIndex(-1)\n }, [items])\n\n const optionDomId = React.useCallback(\n (index: number) => `${listboxId}-option-${index}`,\n [listboxId],\n )\n\n const isInteractiveItem = React.useCallback(\n (item: LookupSelectItem) => !item.disabled || value === item.id,\n [value],\n )\n\n const moveActiveIndex = React.useCallback((direction: 1 | -1) => {\n setActiveIndex((current) => {\n if (!items.length) return -1\n let next = current\n for (let step = 0; step < items.length; step += 1) {\n next = (next + direction + items.length) % items.length\n if (isInteractiveItem(items[next])) return next\n }\n return current\n })\n }, [isInteractiveItem, items])\n\n React.useEffect(() => {\n if (activeIndex < 0) return\n const activeElement = typeof document !== 'undefined'\n ? document.getElementById(optionDomId(activeIndex))\n : null\n if (typeof activeElement?.scrollIntoView === 'function') {\n activeElement.scrollIntoView({ block: 'nearest' })\n }\n }, [activeIndex, optionDomId])\n\n const listboxVisible = shouldSearch && !disabled\n const handleInputKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {\n if (!listboxVisible) return\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n moveActiveIndex(1)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n moveActiveIndex(-1)\n return\n }\n if (event.key === 'Enter') {\n if (activeIndex < 0 || activeIndex >= items.length) return\n const item = items[activeIndex]\n if (!isInteractiveItem(item)) return\n event.preventDefault()\n onChange(item.id)\n setActiveIndex(-1)\n return\n }\n if (event.key === 'Escape') {\n if (query.length === 0 && activeIndex < 0) return\n event.preventDefault()\n event.stopPropagation()\n setQuery('')\n setActiveIndex(-1)\n }\n }, [activeIndex, items, isInteractiveItem, listboxVisible, moveActiveIndex, onChange, query])\n React.useEffect(() => {\n if (disabled) {\n setItems(options ?? [])\n setLoading(false)\n return\n }\n let cancelled = false\n let timer: ReturnType<typeof setTimeout> | null = null\n if (!shouldSearch) {\n setItems(options ?? [])\n setLoading(false)\n setError(null)\n return () => { cancelled = true }\n }\n setLoading(true)\n setError(null)\n timer = setTimeout(() => {\n const requestId = Date.now()\n const fetcher = fetchItemsRef.current\n const loader = fetcher ?? (() => Promise.resolve(options ?? []))\n loader(query.trim())\n .then((result) => {\n if (cancelled) return\n setItems(result)\n })\n .catch((err) => {\n if (cancelled) return\n logger.error('Failed to fetch lookup items', { err })\n setError('error')\n })\n .finally(() => {\n if (!cancelled) setLoading(false)\n })\n return requestId\n }, 220)\n return () => {\n cancelled = true\n if (timer) clearTimeout(timer)\n }\n }, [query, shouldSearch, fetchKey])\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3\">\n <div className=\"relative flex-1\">\n <Search className=\"pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n <input\n className=\"w-full h-10 rounded-lg border border-input bg-background pl-10 pr-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:shadow-focus focus-visible:border-brand-violet disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed\"\n value={query}\n onChange={(event) => {\n setQuery(event.target.value)\n setHasTyped(true)\n }}\n onKeyDown={handleInputKeyDown}\n placeholder={resolvedSearchPlaceholder}\n disabled={disabled}\n role=\"combobox\"\n aria-expanded={listboxVisible}\n aria-controls={listboxId}\n aria-autocomplete=\"list\"\n aria-activedescendant={activeIndex >= 0 ? optionDomId(activeIndex) : undefined}\n />\n </div>\n {actionSlot ? <div className=\"sm:self-start\">{actionSlot}</div> : null}\n </div>\n {shouldSearch ? (\n <div className=\"space-y-2\">\n {loading || loadingProp ? (\n <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n {resolvedLoadingLabel}\n </div>\n ) : null}\n {!loading && !loadingProp && !items.length ? (\n <p className=\"text-xs text-muted-foreground\">{resolvedEmptyLabel}</p>\n ) : null}\n <div\n id={listboxId}\n role=\"listbox\"\n className=\"flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5\"\n >\n {items.map((item, index) => {\n const isSelected = value === item.id\n const isInteractive = !item.disabled || isSelected\n const isActive = index === activeIndex\n return (\n <div\n key={item.id}\n id={optionDomId(index)}\n className={cn(\n 'group flex items-center gap-4 rounded-xl border p-4 transition-all duration-150 focus-visible:outline-none focus-visible:shadow-focus',\n isInteractive ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',\n isSelected\n ? 'border-brand-violet bg-brand-violet/5 shadow-sm'\n : 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm',\n isActive && !isSelected ? 'border-foreground/20 bg-muted/30 shadow-sm' : null\n )}\n role=\"option\"\n tabIndex={item.disabled ? -1 : 0}\n onClick={() => {\n if (!isInteractive) return\n onChange(item.id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (!isInteractive) return\n onChange(item.id)\n }\n }}\n aria-selected={isSelected}\n aria-disabled={item.disabled && !isSelected ? true : undefined}\n title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}\n >\n {item.icon ? (\n <div className=\"flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground\">\n {item.icon}\n </div>\n ) : (\n <div className={cn(\n 'flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border transition-colors',\n isSelected\n ? 'border-brand-violet/40 bg-brand-violet/10 text-brand-violet'\n : 'border-input bg-muted text-muted-foreground group-hover:border-foreground/20'\n )}>\n <span className=\"text-base font-semibold uppercase\">{item.title.slice(0, 1)}</span>\n </div>\n )}\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"truncate text-sm font-semibold text-foreground\">{item.title}</div>\n {item.rightLabel ? (\n <div className=\"shrink-0 text-overline font-medium uppercase tracking-wider text-muted-foreground\">\n {item.rightLabel}\n </div>\n ) : null}\n </div>\n {item.subtitle ? (\n <div className=\"text-xs text-muted-foreground truncate\">{item.subtitle}</div>\n ) : null}\n {item.description ? (\n <div className=\"text-xs text-muted-foreground/70 truncate\">{item.description}</div>\n ) : null}\n </div>\n <div className=\"flex shrink-0 items-center justify-center\">\n {isSelected ? (\n <Check className=\"size-5 text-brand-violet\" aria-hidden=\"true\" />\n ) : (\n <div className=\"size-5\" aria-hidden=\"true\" />\n )}\n </div>\n </div>\n )\n })}\n </div>\n {value ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-fit gap-1 text-sm font-normal\"\n onClick={() => onChange(null)}\n >\n <X className=\"h-4 w-4\" />\n {resolvedClearLabel}\n </Button>\n ) : null}\n </div>\n ) : hasTyped ? (\n <p className=\"text-xs text-muted-foreground\">\n {resolvedMinQueryHintLabel}\n </p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">{resolvedStartTypingLabel}</p>\n )}\n {error ? <p className=\"text-xs text-status-error-text\" role=\"alert\">{resolvedEmptyLabel}</p> : null}\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Check, Loader2, Search, X } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'LookupSelect' })\n\nexport type LookupSelectItem = {\n id: string\n title: string\n subtitle?: string | null\n badge?: string | null\n icon?: React.ReactNode\n disabled?: boolean\n rightLabel?: string | null\n description?: string | null\n}\n\ntype LookupSelectProps = {\n value: string | null\n onChange: (next: string | null) => void\n fetchItems?: (query: string) => Promise<LookupSelectItem[]>\n fetchOptions?: (query?: string) => Promise<LookupSelectItem[]>\n options?: LookupSelectItem[]\n minQuery?: number\n actionSlot?: React.ReactNode\n onReady?: (controls: { setQuery: (value: string) => void }) => void\n searchPlaceholder?: string\n placeholder?: string\n clearLabel?: string\n emptyLabel?: string\n loadingLabel?: string\n selectLabel?: string\n selectedLabel?: string\n minQueryHintLabel?: string\n startTypingLabel?: string\n selectedHintLabel?: (id: string) => string\n disabled?: boolean\n loading?: boolean\n defaultOpen?: boolean\n}\n\nexport function LookupSelect({\n value,\n onChange,\n fetchItems,\n fetchOptions,\n options,\n minQuery = 2,\n actionSlot,\n onReady,\n placeholder,\n searchPlaceholder,\n clearLabel,\n emptyLabel,\n loadingLabel,\n selectLabel,\n selectedLabel,\n minQueryHintLabel,\n startTypingLabel,\n selectedHintLabel,\n disabled = false,\n loading: loadingProp = false,\n defaultOpen = false,\n}: LookupSelectProps) {\n const t = useT()\n const resolvedSearchPlaceholder = searchPlaceholder ?? placeholder ?? t('ui.lookupSelect.searchPlaceholder', 'Search\u2026')\n const resolvedClearLabel = clearLabel ?? t('ui.lookupSelect.clearSelection', 'Clear selection')\n const resolvedEmptyLabel = emptyLabel ?? t('ui.lookupSelect.noResults', 'No results')\n const resolvedLoadingLabel = loadingLabel ?? t('ui.lookupSelect.searching', 'Searching\u2026')\n const resolvedSelectLabel = selectLabel ?? t('ui.lookupSelect.select', 'Select')\n const resolvedSelectedLabel = selectedLabel ?? t('ui.lookupSelect.selected', 'Selected')\n const resolvedStartTypingLabel = startTypingLabel ?? t('ui.lookupSelect.startTyping', 'Start typing to search.')\n const resolvedMinQueryHintLabel = minQueryHintLabel ?? t(\n 'ui.lookupSelect.minQueryHint',\n 'Type at least {minQuery} characters or paste an id to search.',\n { minQuery: String(minQuery) }\n )\n const [query, setQuery] = React.useState('')\n const [items, setItems] = React.useState<LookupSelectItem[]>(options ?? [])\n const [loading, setLoading] = React.useState(false)\n const [hasTyped, setHasTyped] = React.useState(defaultOpen)\n const [error, setError] = React.useState<string | null>(null)\n const [fetchKey, setFetchKey] = React.useState(0)\n const [activeIndex, setActiveIndex] = React.useState(-1)\n const listboxId = React.useId()\n const fetchItemsRef = React.useRef(fetchItems ?? fetchOptions)\n const setQueryRef = React.useRef(setQuery)\n const onReadyRef = React.useRef(onReady)\n const optionsWasArrayRef = React.useRef(Array.isArray(options))\n\n React.useEffect(() => {\n fetchItemsRef.current = fetchItems ?? fetchOptions\n }, [fetchItems, fetchOptions])\n\n React.useEffect(() => {\n onReadyRef.current = onReady\n }, [onReady])\n\n React.useEffect(() => {\n if (Array.isArray(options)) {\n optionsWasArrayRef.current = true\n setItems(options)\n } else if (optionsWasArrayRef.current) {\n optionsWasArrayRef.current = false\n setFetchKey((k) => k + 1)\n }\n }, [options])\n\n React.useEffect(() => {\n setQueryRef.current = setQuery\n if (onReadyRef.current) onReadyRef.current({ setQuery })\n }, [setQuery])\n\n const shouldSearch =\n defaultOpen || query.trim().length >= minQuery || Boolean(value && (options?.length ?? 0) > 0)\n\n React.useEffect(() => {\n setActiveIndex(-1)\n }, [items])\n\n const optionDomId = React.useCallback(\n (index: number) => `${listboxId}-option-${index}`,\n [listboxId],\n )\n\n const isInteractiveItem = React.useCallback(\n (item: LookupSelectItem) => !disabled && (!item.disabled || value === item.id),\n [disabled, value],\n )\n\n const moveActiveIndex = React.useCallback((direction: 1 | -1) => {\n setActiveIndex((current) => {\n if (!items.length) return -1\n let next = current\n for (let step = 0; step < items.length; step += 1) {\n next = (next + direction + items.length) % items.length\n if (isInteractiveItem(items[next])) return next\n }\n return current\n })\n }, [isInteractiveItem, items])\n\n React.useEffect(() => {\n if (activeIndex < 0) return\n const activeElement = typeof document !== 'undefined'\n ? document.getElementById(optionDomId(activeIndex))\n : null\n if (typeof activeElement?.scrollIntoView === 'function') {\n activeElement.scrollIntoView({ block: 'nearest' })\n }\n }, [activeIndex, optionDomId])\n\n const listboxVisible = shouldSearch && !disabled\n const handleInputKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {\n if (!listboxVisible) return\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n moveActiveIndex(1)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n moveActiveIndex(-1)\n return\n }\n if (event.key === 'Enter') {\n if (activeIndex < 0 || activeIndex >= items.length) return\n const item = items[activeIndex]\n if (!isInteractiveItem(item)) return\n event.preventDefault()\n onChange(item.id)\n setActiveIndex(-1)\n return\n }\n if (event.key === 'Escape') {\n if (query.length === 0 && activeIndex < 0) return\n event.preventDefault()\n event.stopPropagation()\n setQuery('')\n setActiveIndex(-1)\n }\n }, [activeIndex, items, isInteractiveItem, listboxVisible, moveActiveIndex, onChange, query])\n React.useEffect(() => {\n if (disabled) {\n setItems(options ?? [])\n setLoading(false)\n return\n }\n let cancelled = false\n let timer: ReturnType<typeof setTimeout> | null = null\n if (!shouldSearch) {\n setItems(options ?? [])\n setLoading(false)\n setError(null)\n return () => { cancelled = true }\n }\n setLoading(true)\n setError(null)\n timer = setTimeout(() => {\n const requestId = Date.now()\n const fetcher = fetchItemsRef.current\n const loader = fetcher ?? (() => Promise.resolve(options ?? []))\n loader(query.trim())\n .then((result) => {\n if (cancelled) return\n setItems(result)\n })\n .catch((err) => {\n if (cancelled) return\n logger.error('Failed to fetch lookup items', { err })\n setError('error')\n })\n .finally(() => {\n if (!cancelled) setLoading(false)\n })\n return requestId\n }, 220)\n return () => {\n cancelled = true\n if (timer) clearTimeout(timer)\n }\n }, [query, shouldSearch, fetchKey])\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3\">\n <div className=\"relative flex-1\">\n <Search className=\"pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n <input\n className=\"w-full h-10 rounded-lg border border-input bg-background pl-10 pr-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:shadow-focus focus-visible:border-brand-violet disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed\"\n value={query}\n onChange={(event) => {\n setQuery(event.target.value)\n setHasTyped(true)\n }}\n onKeyDown={handleInputKeyDown}\n placeholder={resolvedSearchPlaceholder}\n disabled={disabled}\n role=\"combobox\"\n aria-expanded={listboxVisible}\n aria-controls={listboxId}\n aria-autocomplete=\"list\"\n aria-activedescendant={activeIndex >= 0 ? optionDomId(activeIndex) : undefined}\n />\n </div>\n {actionSlot && !disabled ? <div className=\"sm:self-start\">{actionSlot}</div> : null}\n </div>\n {shouldSearch ? (\n <div className=\"space-y-2\">\n {loading || loadingProp ? (\n <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n {resolvedLoadingLabel}\n </div>\n ) : null}\n {!loading && !loadingProp && !items.length ? (\n <p className=\"text-xs text-muted-foreground\">{resolvedEmptyLabel}</p>\n ) : null}\n <div\n id={listboxId}\n role=\"listbox\"\n className=\"flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5\"\n >\n {items.map((item, index) => {\n const isSelected = value === item.id\n const isInteractive = isInteractiveItem(item)\n const isActive = index === activeIndex\n return (\n <div\n key={item.id}\n id={optionDomId(index)}\n className={cn(\n 'group flex items-center gap-4 rounded-xl border p-4 transition-all duration-150 focus-visible:outline-none focus-visible:shadow-focus',\n isInteractive ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',\n isSelected\n ? 'border-brand-violet bg-brand-violet/5 shadow-sm'\n : 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm',\n isActive && !isSelected ? 'border-foreground/20 bg-muted/30 shadow-sm' : null\n )}\n role=\"option\"\n tabIndex={isInteractive ? 0 : -1}\n onClick={() => {\n if (!isInteractive) return\n onChange(item.id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (!isInteractive) return\n onChange(item.id)\n }\n }}\n aria-selected={isSelected}\n aria-disabled={isInteractive ? undefined : true}\n title={isSelected ? resolvedSelectedLabel : isInteractive ? resolvedSelectLabel : undefined}\n >\n {item.icon ? (\n <div className=\"flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground\">\n {item.icon}\n </div>\n ) : (\n <div className={cn(\n 'flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border transition-colors',\n isSelected\n ? 'border-brand-violet/40 bg-brand-violet/10 text-brand-violet'\n : 'border-input bg-muted text-muted-foreground group-hover:border-foreground/20'\n )}>\n <span className=\"text-base font-semibold uppercase\">{item.title.slice(0, 1)}</span>\n </div>\n )}\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"truncate text-sm font-semibold text-foreground\">{item.title}</div>\n {item.rightLabel ? (\n <div className=\"shrink-0 text-overline font-medium uppercase tracking-wider text-muted-foreground\">\n {item.rightLabel}\n </div>\n ) : null}\n </div>\n {item.subtitle ? (\n <div className=\"text-xs text-muted-foreground truncate\">{item.subtitle}</div>\n ) : null}\n {item.description ? (\n <div className=\"text-xs text-muted-foreground/70 truncate\">{item.description}</div>\n ) : null}\n </div>\n <div className=\"flex shrink-0 items-center justify-center\">\n {isSelected ? (\n <Check className=\"size-5 text-brand-violet\" aria-hidden=\"true\" />\n ) : (\n <div className=\"size-5\" aria-hidden=\"true\" />\n )}\n </div>\n </div>\n )\n })}\n </div>\n {value && !disabled ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-fit gap-1 text-sm font-normal\"\n onClick={() => onChange(null)}\n >\n <X className=\"h-4 w-4\" />\n {resolvedClearLabel}\n </Button>\n ) : null}\n </div>\n ) : hasTyped ? (\n <p className=\"text-xs text-muted-foreground\">\n {resolvedMinQueryHintLabel}\n </p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">{resolvedStartTypingLabel}</p>\n )}\n {error ? <p className=\"text-xs text-status-error-text\" role=\"alert\">{resolvedEmptyLabel}</p> : null}\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7063.1.664341cf65",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.8-develop.7063.1.664341cf65",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
165
|
+
"@open-mercato/shared": "0.6.8-develop.7063.1.664341cf65",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.0",
|
|
168
168
|
"@testing-library/react": "^16.3.1",
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import {
|
|
6
|
+
registerComponent,
|
|
7
|
+
registerComponentOverrides,
|
|
8
|
+
} from '@open-mercato/shared/modules/widgets/component-registry'
|
|
9
|
+
import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
|
|
10
|
+
import { ComponentOverrideProvider, useOverrideUserFeatures } from '../injection/ComponentOverrideProvider'
|
|
11
|
+
import { useRegisteredComponent } from '../injection/useRegisteredComponent'
|
|
12
|
+
|
|
13
|
+
jest.mock('../utils/apiCall', () => ({
|
|
14
|
+
apiCall: jest.fn(async () => ({ ok: true, result: { granted: ['acme.override'] } })),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
type FieldProps = { label?: string }
|
|
18
|
+
|
|
19
|
+
const mounts = { count: 0 }
|
|
20
|
+
|
|
21
|
+
function StatefulField({ label }: FieldProps) {
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
mounts.count += 1
|
|
24
|
+
}, [])
|
|
25
|
+
return (
|
|
26
|
+
<label>
|
|
27
|
+
{label ?? 'field'}
|
|
28
|
+
<input data-testid="field" defaultValue="" />
|
|
29
|
+
</label>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function FeaturesProbe() {
|
|
34
|
+
const features = useOverrideUserFeatures()
|
|
35
|
+
return <span data-testid="features">{features.join(',')}</span>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function typeIntoField(value: string) {
|
|
39
|
+
fireEvent.change(screen.getByTestId('field'), { target: { value } })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('useRegisteredComponent identity', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mounts.count = 0
|
|
45
|
+
registerComponentOverrides([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
registerComponentOverrides([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('keeps the subtree mounted when override user features resolve after first paint', async () => {
|
|
53
|
+
const componentId = 'test.identity.features'
|
|
54
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
55
|
+
|
|
56
|
+
function Consumer() {
|
|
57
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
58
|
+
return <Resolved />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The override targets a different component, so the resolution result for
|
|
62
|
+
// `componentId` is identical before and after the feature grant arrives.
|
|
63
|
+
const overrides: ComponentOverride[] = [
|
|
64
|
+
{
|
|
65
|
+
target: { componentId: 'test.identity.other' },
|
|
66
|
+
priority: 10,
|
|
67
|
+
features: ['acme.override'],
|
|
68
|
+
metadata: { module: 'test' },
|
|
69
|
+
propsTransform: (props: unknown) => props,
|
|
70
|
+
} as ComponentOverride,
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
render(
|
|
74
|
+
<ComponentOverrideProvider overrides={overrides}>
|
|
75
|
+
<FeaturesProbe />
|
|
76
|
+
<Consumer />
|
|
77
|
+
</ComponentOverrideProvider>,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
typeIntoField('admin@acme.com')
|
|
81
|
+
expect(mounts.count).toBe(1)
|
|
82
|
+
|
|
83
|
+
await waitFor(() => expect(screen.getByTestId('features')).toHaveTextContent('acme.override'))
|
|
84
|
+
|
|
85
|
+
expect(mounts.count).toBe(1)
|
|
86
|
+
expect(screen.getByTestId('field')).toHaveValue('admin@acme.com')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('keeps the subtree mounted when overrides are registered late', async () => {
|
|
90
|
+
const componentId = 'test.identity.late'
|
|
91
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
92
|
+
|
|
93
|
+
function Consumer() {
|
|
94
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
95
|
+
return <Resolved />
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lateOverrides: ComponentOverride[] = [
|
|
99
|
+
{
|
|
100
|
+
target: { componentId: 'test.identity.late.other' },
|
|
101
|
+
priority: 10,
|
|
102
|
+
metadata: { module: 'test' },
|
|
103
|
+
propsTransform: (props: unknown) => props,
|
|
104
|
+
} as ComponentOverride,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
const { rerender } = render(
|
|
108
|
+
<ComponentOverrideProvider overrides={[]}>
|
|
109
|
+
<Consumer />
|
|
110
|
+
</ComponentOverrideProvider>,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
typeIntoField('typed before the registry settled')
|
|
114
|
+
expect(mounts.count).toBe(1)
|
|
115
|
+
|
|
116
|
+
await act(async () => {
|
|
117
|
+
rerender(
|
|
118
|
+
<ComponentOverrideProvider overrides={lateOverrides}>
|
|
119
|
+
<Consumer />
|
|
120
|
+
</ComponentOverrideProvider>,
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(mounts.count).toBe(1)
|
|
125
|
+
expect(screen.getByTestId('field')).toHaveValue('typed before the registry settled')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps a wrapper-wrapped subtree mounted across override re-registrations', async () => {
|
|
129
|
+
const componentId = 'test.identity.wrapped'
|
|
130
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
131
|
+
|
|
132
|
+
const wrapper = (Original: React.ComponentType<FieldProps>) => {
|
|
133
|
+
const Wrapped = (props: FieldProps) => (
|
|
134
|
+
<div data-testid="wrapped">
|
|
135
|
+
<Original {...props} />
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
Wrapped.displayName = 'Wrapped'
|
|
139
|
+
return Wrapped
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const buildOverrides = (): ComponentOverride[] => [
|
|
143
|
+
{ target: { componentId }, priority: 10, metadata: { module: 'test' }, wrapper } as unknown as ComponentOverride,
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
function Consumer() {
|
|
147
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
148
|
+
return <Resolved />
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { rerender } = render(
|
|
152
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
153
|
+
<Consumer />
|
|
154
|
+
</ComponentOverrideProvider>,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(screen.getByTestId('wrapped')).toBeInTheDocument()
|
|
158
|
+
typeIntoField('typed inside a wrapped section')
|
|
159
|
+
expect(mounts.count).toBe(1)
|
|
160
|
+
|
|
161
|
+
// A new array with the same wrapper function — what a provider re-render looks like.
|
|
162
|
+
await act(async () => {
|
|
163
|
+
rerender(
|
|
164
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
165
|
+
<Consumer />
|
|
166
|
+
</ComponentOverrideProvider>,
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
expect(mounts.count).toBe(1)
|
|
171
|
+
expect(screen.getByTestId('field')).toHaveValue('typed inside a wrapped section')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('composes a wrapper at most once per (wrapper, wrapped component) pair', async () => {
|
|
175
|
+
// The user-visible consequence is covered by the test above; this one pins the
|
|
176
|
+
// cache's own contract, so that dropping the memoization fails loudly and the
|
|
177
|
+
// purity requirement documented on the `wrapper` override member stays executable.
|
|
178
|
+
const componentId = 'test.identity.wrapper-composition'
|
|
179
|
+
const Base = (props: FieldProps) => <StatefulField {...props} />
|
|
180
|
+
Base.displayName = 'Base'
|
|
181
|
+
registerComponent({ id: componentId, component: Base, metadata: { module: 'test' } })
|
|
182
|
+
|
|
183
|
+
let compositions = 0
|
|
184
|
+
const wrapper = (Original: React.ComponentType<FieldProps>) => {
|
|
185
|
+
compositions += 1
|
|
186
|
+
const Wrapped = (props: FieldProps) => (
|
|
187
|
+
<div data-testid="wrapped">
|
|
188
|
+
<Original {...props} />
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
Wrapped.displayName = 'Wrapped'
|
|
192
|
+
return Wrapped
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const buildOverrides = (): ComponentOverride[] => [
|
|
196
|
+
{ target: { componentId }, priority: 10, metadata: { module: 'test' }, wrapper } as unknown as ComponentOverride,
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
function Consumer() {
|
|
200
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
201
|
+
return <Resolved />
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { rerender } = render(
|
|
205
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
206
|
+
<Consumer />
|
|
207
|
+
</ComponentOverrideProvider>,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(screen.getByTestId('wrapped')).toBeInTheDocument()
|
|
211
|
+
expect(compositions).toBe(1)
|
|
212
|
+
|
|
213
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
214
|
+
await act(async () => {
|
|
215
|
+
rerender(
|
|
216
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
217
|
+
<Consumer />
|
|
218
|
+
</ComponentOverrideProvider>,
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
expect(compositions).toBe(1)
|
|
224
|
+
expect(mounts.count).toBe(1)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('still applies a replacement that becomes active after the first render', async () => {
|
|
228
|
+
const componentId = 'test.identity.replacement'
|
|
229
|
+
const Original = () => <span data-testid="rendered">original</span>
|
|
230
|
+
const Replacement = () => <span data-testid="rendered">replacement</span>
|
|
231
|
+
registerComponent({ id: componentId, component: Original, metadata: { module: 'test' } })
|
|
232
|
+
|
|
233
|
+
function Consumer() {
|
|
234
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
235
|
+
return <Resolved />
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const lateOverrides: ComponentOverride[] = [
|
|
239
|
+
{
|
|
240
|
+
target: { componentId },
|
|
241
|
+
priority: 100,
|
|
242
|
+
metadata: { module: 'test' },
|
|
243
|
+
replacement: Replacement,
|
|
244
|
+
propsSchema: z.object({}).passthrough(),
|
|
245
|
+
} as unknown as ComponentOverride,
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
const { rerender } = render(
|
|
249
|
+
<ComponentOverrideProvider overrides={[]}>
|
|
250
|
+
<Consumer />
|
|
251
|
+
</ComponentOverrideProvider>,
|
|
252
|
+
)
|
|
253
|
+
expect(screen.getByTestId('rendered')).toHaveTextContent('original')
|
|
254
|
+
|
|
255
|
+
await act(async () => {
|
|
256
|
+
rerender(
|
|
257
|
+
<ComponentOverrideProvider overrides={lateOverrides}>
|
|
258
|
+
<Consumer />
|
|
259
|
+
</ComponentOverrideProvider>,
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
expect(screen.getByTestId('rendered')).toHaveTextContent('replacement')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('cannot save a subtree whose host re-creates the fallback on every render', () => {
|
|
267
|
+
const componentId = 'test.identity.inline-fallback'
|
|
268
|
+
|
|
269
|
+
function Consumer({ label }: { label: string }) {
|
|
270
|
+
// A fallback declared inside the host's render body is a new function on
|
|
271
|
+
// every render, and with no component registered under this id it *is*
|
|
272
|
+
// the component being rendered. The hook keeps its own identity stable,
|
|
273
|
+
// but it cannot make the caller's fallback stable — React sees a new
|
|
274
|
+
// element type below and must remount. Hosts that care about the state
|
|
275
|
+
// under an unregistered id have to hoist the fallback out of render.
|
|
276
|
+
const Fallback = (props: FieldProps) => <StatefulField {...props} />
|
|
277
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId, Fallback)
|
|
278
|
+
return <Resolved label={label} />
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const { rerender } = render(<Consumer label="first" />)
|
|
282
|
+
typeIntoField('typed against an inline fallback')
|
|
283
|
+
expect(mounts.count).toBe(1)
|
|
284
|
+
|
|
285
|
+
rerender(<Consumer label="second" />)
|
|
286
|
+
|
|
287
|
+
expect(mounts.count).toBe(2)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('remounts when the host asks for a different component id', () => {
|
|
291
|
+
function Consumer({ componentId }: { componentId: string }) {
|
|
292
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId, StatefulField)
|
|
293
|
+
return <Resolved />
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { rerender } = render(<Consumer componentId="test.identity.provider-a" />)
|
|
297
|
+
typeIntoField('provider a input')
|
|
298
|
+
expect(mounts.count).toBe(1)
|
|
299
|
+
|
|
300
|
+
rerender(<Consumer componentId="test.identity.provider-b" />)
|
|
301
|
+
|
|
302
|
+
expect(mounts.count).toBe(2)
|
|
303
|
+
expect(screen.getByTestId('field')).toHaveValue('')
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -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
|
|
@@ -129,8 +129,8 @@ export function LookupSelect({
|
|
|
129
129
|
)
|
|
130
130
|
|
|
131
131
|
const isInteractiveItem = React.useCallback(
|
|
132
|
-
(item: LookupSelectItem) => !item.disabled || value === item.id,
|
|
133
|
-
[value],
|
|
132
|
+
(item: LookupSelectItem) => !disabled && (!item.disabled || value === item.id),
|
|
133
|
+
[disabled, value],
|
|
134
134
|
)
|
|
135
135
|
|
|
136
136
|
const moveActiveIndex = React.useCallback((direction: 1 | -1) => {
|
|
@@ -248,7 +248,7 @@ export function LookupSelect({
|
|
|
248
248
|
aria-activedescendant={activeIndex >= 0 ? optionDomId(activeIndex) : undefined}
|
|
249
249
|
/>
|
|
250
250
|
</div>
|
|
251
|
-
{actionSlot ? <div className="sm:self-start">{actionSlot}</div> : null}
|
|
251
|
+
{actionSlot && !disabled ? <div className="sm:self-start">{actionSlot}</div> : null}
|
|
252
252
|
</div>
|
|
253
253
|
{shouldSearch ? (
|
|
254
254
|
<div className="space-y-2">
|
|
@@ -268,7 +268,7 @@ export function LookupSelect({
|
|
|
268
268
|
>
|
|
269
269
|
{items.map((item, index) => {
|
|
270
270
|
const isSelected = value === item.id
|
|
271
|
-
const isInteractive =
|
|
271
|
+
const isInteractive = isInteractiveItem(item)
|
|
272
272
|
const isActive = index === activeIndex
|
|
273
273
|
return (
|
|
274
274
|
<div
|
|
@@ -283,7 +283,7 @@ export function LookupSelect({
|
|
|
283
283
|
isActive && !isSelected ? 'border-foreground/20 bg-muted/30 shadow-sm' : null
|
|
284
284
|
)}
|
|
285
285
|
role="option"
|
|
286
|
-
tabIndex={
|
|
286
|
+
tabIndex={isInteractive ? 0 : -1}
|
|
287
287
|
onClick={() => {
|
|
288
288
|
if (!isInteractive) return
|
|
289
289
|
onChange(item.id)
|
|
@@ -296,8 +296,8 @@ export function LookupSelect({
|
|
|
296
296
|
}
|
|
297
297
|
}}
|
|
298
298
|
aria-selected={isSelected}
|
|
299
|
-
aria-disabled={
|
|
300
|
-
title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}
|
|
299
|
+
aria-disabled={isInteractive ? undefined : true}
|
|
300
|
+
title={isSelected ? resolvedSelectedLabel : isInteractive ? resolvedSelectLabel : undefined}
|
|
301
301
|
>
|
|
302
302
|
{item.icon ? (
|
|
303
303
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden [&>svg]:size-6 [&_svg]:text-muted-foreground">
|
|
@@ -340,7 +340,7 @@ export function LookupSelect({
|
|
|
340
340
|
)
|
|
341
341
|
})}
|
|
342
342
|
</div>
|
|
343
|
-
{value ? (
|
|
343
|
+
{value && !disabled ? (
|
|
344
344
|
<Button
|
|
345
345
|
type="button"
|
|
346
346
|
variant="ghost"
|
|
@@ -163,3 +163,78 @@ describe('LookupSelect keyboard accessibility', () => {
|
|
|
163
163
|
expect(escapeSpy).not.toHaveBeenCalled()
|
|
164
164
|
})
|
|
165
165
|
})
|
|
166
|
+
|
|
167
|
+
// `disabled` used to gate only the search box, so a caller that locked the
|
|
168
|
+
// control still shipped a live option list: the selected card kept its click and
|
|
169
|
+
// Enter/Space handlers, "Clear selection" stayed reachable, and the action slot
|
|
170
|
+
// could still create a new record. Issue #5248 depended on `disabled` meaning
|
|
171
|
+
// "no interaction at all", so every one of those paths is pinned here.
|
|
172
|
+
describe('LookupSelect disabled', () => {
|
|
173
|
+
const SELECTED = [{ id: 'product-1', title: 'Product One' }]
|
|
174
|
+
|
|
175
|
+
function renderDisabled(onChange: (next: string | null) => void) {
|
|
176
|
+
return render(
|
|
177
|
+
<LookupSelect
|
|
178
|
+
value="product-1"
|
|
179
|
+
onChange={onChange}
|
|
180
|
+
options={SELECTED}
|
|
181
|
+
disabled
|
|
182
|
+
actionSlot={
|
|
183
|
+
<button type="button" data-testid="quick-create">
|
|
184
|
+
Create
|
|
185
|
+
</button>
|
|
186
|
+
}
|
|
187
|
+
clearLabel="Clear selection"
|
|
188
|
+
/>,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it('still shows the current selection so the value stays readable', () => {
|
|
193
|
+
renderDisabled(() => {})
|
|
194
|
+
expect(screen.getByRole('option')).toHaveTextContent('Product One')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('ignores clicks on the option row', () => {
|
|
198
|
+
const onChange = jest.fn()
|
|
199
|
+
renderDisabled(onChange)
|
|
200
|
+
|
|
201
|
+
fireEvent.click(screen.getByRole('option'))
|
|
202
|
+
|
|
203
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('ignores Enter and Space on the option row and keeps it out of the tab order', () => {
|
|
207
|
+
const onChange = jest.fn()
|
|
208
|
+
renderDisabled(onChange)
|
|
209
|
+
const option = screen.getByRole('option')
|
|
210
|
+
|
|
211
|
+
fireEvent.keyDown(option, { key: 'Enter' })
|
|
212
|
+
fireEvent.keyDown(option, { key: ' ' })
|
|
213
|
+
|
|
214
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
215
|
+
expect(option).toHaveAttribute('tabindex', '-1')
|
|
216
|
+
expect(option).toHaveAttribute('aria-disabled', 'true')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('hides the clear-selection button so the value cannot be nulled', () => {
|
|
220
|
+
renderDisabled(() => {})
|
|
221
|
+
expect(screen.queryByRole('button', { name: /clear selection/i })).toBeNull()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('hides the action slot so no new record can be created into a locked field', () => {
|
|
225
|
+
renderDisabled(() => {})
|
|
226
|
+
expect(screen.queryByTestId('quick-create')).toBeNull()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('keeps the search box disabled and its keyboard path inert', () => {
|
|
230
|
+
const onChange = jest.fn()
|
|
231
|
+
const { container } = renderDisabled(onChange)
|
|
232
|
+
const input = getInput(container)
|
|
233
|
+
|
|
234
|
+
expect(input.disabled).toBe(true)
|
|
235
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
|
236
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
237
|
+
|
|
238
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
239
|
+
})
|
|
240
|
+
})
|