@open-mercato/ui 0.6.8-develop.6925.1.cefa200fa4 → 0.6.8-develop.6931.1.4a1e1c786b

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.
@@ -0,0 +1,96 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { fireEvent, render } from '@testing-library/react'
5
+ import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
6
+ import { ComponentOverrideProvider } from '../ComponentOverrideProvider'
7
+ import { useRegisteredComponent } from '../useRegisteredComponent'
8
+
9
+ const COMPONENT_ID = 'section:test.host.form'
10
+
11
+ function Section({ children }: { children: React.ReactNode }) {
12
+ return <>{children}</>
13
+ }
14
+
15
+ function Host() {
16
+ const Resolved = useRegisteredComponent<{ children: React.ReactNode }>(COMPONENT_ID, Section)
17
+ return (
18
+ <Resolved>
19
+ <form>
20
+ <input id="email" name="email" defaultValue="" />
21
+ </form>
22
+ </Resolved>
23
+ )
24
+ }
25
+
26
+ /**
27
+ * `overrides` arrives as a brand-new array once the async override bundle
28
+ * resolves, exactly the way ComponentOverridesBootstrap hands it over after its
29
+ * dynamic import settles.
30
+ */
31
+ function App({ overrides }: { overrides: ComponentOverride[] }) {
32
+ return (
33
+ <ComponentOverrideProvider overrides={overrides}>
34
+ <Host />
35
+ </ComponentOverrideProvider>
36
+ )
37
+ }
38
+
39
+ function readEmail(): HTMLInputElement {
40
+ return document.querySelector('#email') as HTMLInputElement
41
+ }
42
+
43
+ function typeEmail(value: string): HTMLInputElement {
44
+ const email = readEmail()
45
+ fireEvent.change(email, { target: { value } })
46
+ expect(email.value).toBe(value)
47
+ return email
48
+ }
49
+
50
+ describe('useRegisteredComponent — a late override registry must not discard host state', () => {
51
+ it('keeps typed input when the override bundle resolves with no override for this id', () => {
52
+ const { rerender } = render(<App overrides={[]} />)
53
+ const email = typeEmail('admin@acme.com')
54
+
55
+ // Same (empty) override set, fresh array identity — the registry revision bumps.
56
+ rerender(<App overrides={[]} />)
57
+
58
+ expect(readEmail()).toBe(email)
59
+ expect(readEmail().value).toBe('admin@acme.com')
60
+ })
61
+
62
+ it('keeps typed input when unrelated overrides register', () => {
63
+ const { rerender } = render(<App overrides={[]} />)
64
+ const email = typeEmail('admin@acme.com')
65
+
66
+ const unrelated: ComponentOverride[] = [{
67
+ target: { componentId: 'section:some.other.component' },
68
+ priority: 0,
69
+ metadata: { module: 'other' },
70
+ wrapper: (Original) => Original,
71
+ }]
72
+ rerender(<App overrides={unrelated} />)
73
+
74
+ expect(readEmail()).toBe(email)
75
+ expect(readEmail().value).toBe('admin@acme.com')
76
+ })
77
+
78
+ it('still applies a wrapper registered for this id', () => {
79
+ const withBanner: ComponentOverride[] = [{
80
+ target: { componentId: COMPONENT_ID },
81
+ priority: 0,
82
+ metadata: { module: 'other' },
83
+ wrapper: (Original) => {
84
+ const Wrapped = (props: Record<string, unknown>) => (
85
+ <div data-testid="wrapper-banner">{React.createElement(Original as React.ComponentType<Record<string, unknown>>, props)}</div>
86
+ )
87
+ return Wrapped as typeof Original
88
+ },
89
+ }]
90
+
91
+ const { getByTestId } = render(<App overrides={withBanner} />)
92
+
93
+ expect(getByTestId('wrapper-banner')).toBeTruthy()
94
+ expect(readEmail()).toBeTruthy()
95
+ })
96
+ })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import type { ComponentType } from 'react'
5
+ import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
5
6
  import { getComponentEntry, getComponentOverrides } from '@open-mercato/shared/modules/widgets/component-registry'
6
7
  import { useOverrideRegistryRevision, useOverrideUserFeatures } from './ComponentOverrideProvider'
7
8
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -31,46 +32,93 @@ class ReplacementErrorBoundary extends React.Component<
31
32
  }
32
33
  }
33
34
 
34
- export function useRegisteredComponent<TProps>(
35
+ type Resolution<TProps> = {
36
+ original: ComponentType<TProps> | null
37
+ wrapped: ComponentType<TProps> | null
38
+ transforms: Array<(props: TProps) => TProps>
39
+ replacementOverride: ComponentOverride | null
40
+ replacementModule: string
41
+ }
42
+
43
+ function resolveComponent<TProps>(
35
44
  componentId: string,
36
- fallback?: ComponentType<TProps>,
37
- ): ComponentType<TProps> {
38
- const userFeatures = useOverrideUserFeatures()
39
- const overrideRevision = useOverrideRegistryRevision()
40
- return React.useMemo(() => {
41
- const entry = getComponentEntry(componentId)
42
- const original = (entry?.component as ComponentType<TProps> | undefined) ?? fallback ?? null
43
- if (!original) {
44
- if (process.env.NODE_ENV !== 'production' && !fallback) {
45
- logger.warn('Component is not registered', { componentId })
46
- }
47
- const Missing = () => null
48
- return Missing as ComponentType<TProps>
49
- }
50
- const overrides = getComponentOverrides(componentId, userFeatures)
51
- const replacementOverrides = overrides.filter((override) => 'replacement' in override)
52
- if (process.env.NODE_ENV !== 'production' && replacementOverrides.length > 1) {
53
- logger.warn('Multiple replacements registered; highest-priority replacement is applied', { componentId })
45
+ fallback: ComponentType<TProps> | undefined,
46
+ userFeatures: readonly string[],
47
+ ): Resolution<TProps> {
48
+ const entry = getComponentEntry(componentId)
49
+ const original = (entry?.component as ComponentType<TProps> | undefined) ?? fallback ?? null
50
+ if (!original) {
51
+ if (process.env.NODE_ENV !== 'production' && !fallback) {
52
+ logger.warn('Component is not registered', { componentId })
54
53
  }
54
+ return { original: null, wrapped: null, transforms: [], replacementOverride: null, replacementModule: 'unknown' }
55
+ }
55
56
 
56
- let replacement: ComponentType<TProps> | null = null
57
- let replacementOverride: (typeof overrides)[number] | null = null
58
- const wrappers: Array<(Original: ComponentType<TProps>) => ComponentType<TProps>> = []
59
- const transforms: Array<(props: TProps) => TProps> = []
57
+ const overrides = getComponentOverrides(componentId, userFeatures)
58
+ const replacementOverrides = overrides.filter((override) => 'replacement' in override)
59
+ if (process.env.NODE_ENV !== 'production' && replacementOverrides.length > 1) {
60
+ logger.warn('Multiple replacements registered; highest-priority replacement is applied', { componentId })
61
+ }
60
62
 
61
- for (const override of overrides) {
62
- if ('replacement' in override) {
63
- replacement = override.replacement as ComponentType<TProps>
64
- replacementOverride = override
65
- }
66
- if ('wrapper' in override) wrappers.push(override.wrapper as (Original: ComponentType<TProps>) => ComponentType<TProps>)
67
- if ('propsTransform' in override) transforms.push(override.propsTransform as (props: TProps) => TProps)
63
+ let replacement: ComponentType<TProps> | null = null
64
+ let replacementOverride: ComponentOverride | null = null
65
+ const wrappers: Array<(Original: ComponentType<TProps>) => ComponentType<TProps>> = []
66
+ const transforms: Array<(props: TProps) => TProps> = []
67
+
68
+ for (const override of overrides) {
69
+ if ('replacement' in override) {
70
+ replacement = override.replacement as ComponentType<TProps>
71
+ replacementOverride = override
68
72
  }
73
+ if ('wrapper' in override) wrappers.push(override.wrapper as (Original: ComponentType<TProps>) => ComponentType<TProps>)
74
+ if ('propsTransform' in override) transforms.push(override.propsTransform as (props: TProps) => TProps)
75
+ }
76
+
77
+ const base = replacement ?? original
78
+ const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper(acc), base)
79
+
80
+ return {
81
+ original,
82
+ wrapped,
83
+ transforms,
84
+ replacementOverride,
85
+ replacementModule: replacementOverride?.metadata?.module ?? 'unknown',
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The returned component's identity must depend only on `componentId` and
91
+ * `fallback` — never on the override registry.
92
+ *
93
+ * Overrides arrive asynchronously: `ComponentOverridesBootstrap` dynamically
94
+ * imports the generated override module and hands the provider a fresh array,
95
+ * which bumps the registry revision some time after first paint. Resolving the
96
+ * component in a `useMemo` keyed on that revision handed callers a brand-new
97
+ * function on every bump, so React saw a different element type at that
98
+ * position and unmounted the whole subtree — discarding its DOM and state. On
99
+ * the login form that threw away credentials the user had already typed
100
+ * (#5037), and the same hazard applied to every host of a registered section.
101
+ *
102
+ * Resolution therefore happens *inside* a stable component. When the revision
103
+ * carries no override for this id, `wrapped` keeps its previous identity and
104
+ * React reconciles in place; only a genuine replacement or wrapper swaps the
105
+ * rendered type, where a remount is the correct behaviour.
106
+ */
107
+ export function useRegisteredComponent<TProps>(
108
+ componentId: string,
109
+ fallback?: ComponentType<TProps>,
110
+ ): ComponentType<TProps> {
111
+ return React.useMemo(() => {
112
+ const Registered = (props: TProps) => {
113
+ const userFeatures = useOverrideUserFeatures()
114
+ const overrideRevision = useOverrideRegistryRevision()
115
+ const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(
116
+ () => resolveComponent<TProps>(componentId, fallback, userFeatures),
117
+ [overrideRevision, userFeatures],
118
+ )
69
119
 
70
- const base = replacement ?? original
71
- const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper(acc), base)
120
+ if (!original || !wrapped) return null
72
121
 
73
- const Resolved = (props: TProps) => {
74
122
  const transformed = transforms.reduce((current, transform) => transform(current), props)
75
123
  const Fallback = React.createElement(original as React.ComponentType<Record<string, unknown>>, transformed as Record<string, unknown>)
76
124
  if (
@@ -80,7 +128,7 @@ export function useRegisteredComponent<TProps>(
80
128
  ) {
81
129
  const validation = replacementOverride.propsSchema.safeParse(transformed)
82
130
  if (!validation.success) {
83
- logger.error('Props schema validation failed for replacement', { componentId, module: replacementOverride.metadata?.module ?? 'unknown', issues: validation.error.format() })
131
+ logger.error('Props schema validation failed for replacement', { componentId, module: replacementModule, issues: validation.error.format() })
84
132
  return Fallback
85
133
  }
86
134
  }
@@ -88,7 +136,6 @@ export function useRegisteredComponent<TProps>(
88
136
  <ReplacementErrorBoundary
89
137
  fallback={Fallback}
90
138
  onError={(error) => {
91
- const replacementModule = overrides.find((override) => 'replacement' in override)?.metadata?.module ?? 'unknown'
92
139
  logger.error('Component replacement failed', { componentId, module: replacementModule, err: error })
93
140
  }}
94
141
  >
@@ -97,9 +144,9 @@ export function useRegisteredComponent<TProps>(
97
144
  )
98
145
  }
99
146
 
100
- Resolved.displayName = `RegisteredComponent(${componentId})`
101
- return Resolved
102
- }, [componentId, fallback, overrideRevision, userFeatures])
147
+ Registered.displayName = `RegisteredComponent(${componentId})`
148
+ return Registered
149
+ }, [componentId, fallback])
103
150
  }
104
151
 
105
152
  export default useRegisteredComponent
@@ -92,6 +92,7 @@ export function ComboboxInput({
92
92
  const [touched, setTouched] = React.useState(false)
93
93
  const [showSuggestions, setShowSuggestions] = React.useState(false)
94
94
  const [selectedIndex, setSelectedIndex] = React.useState(-1)
95
+ const listboxId = React.useId()
95
96
  const inputRef = React.useRef<HTMLInputElement>(null)
96
97
  const loadingRef = React.useRef(false)
97
98
  const blurCloseTimerRef = React.useRef<number | null>(null)
@@ -365,7 +366,9 @@ export function ComboboxInput({
365
366
  confirmSelection(input)
366
367
  }
367
368
  } else if (event.key === 'Escape') {
369
+ if (!showSuggestions) return
368
370
  event.preventDefault()
371
+ event.stopPropagation()
369
372
  setShowSuggestions(false)
370
373
  setSelectedIndex(-1)
371
374
  }
@@ -373,7 +376,23 @@ export function ComboboxInput({
373
376
  [confirmSelection, disabled, filteredSuggestions, input, selectValue, selectedIndex, showSuggestions]
374
377
  )
375
378
 
379
+ const optionDomId = React.useCallback(
380
+ (index: number) => `${listboxId}-option-${index}`,
381
+ [listboxId],
382
+ )
383
+
384
+ React.useEffect(() => {
385
+ if (selectedIndex < 0 || !showSuggestions) return
386
+ const activeElement = typeof document !== 'undefined'
387
+ ? document.getElementById(optionDomId(selectedIndex))
388
+ : null
389
+ if (typeof activeElement?.scrollIntoView === 'function') {
390
+ activeElement.scrollIntoView({ block: 'nearest' })
391
+ }
392
+ }, [optionDomId, selectedIndex, showSuggestions])
393
+
376
394
  const showClearButton = clearable && !disabled && (value !== '' || input !== '')
395
+ const listboxVisible = showSuggestions && !disabled && (loading || filteredSuggestions.length > 0)
377
396
 
378
397
  return (
379
398
  <div className="relative w-full">
@@ -395,6 +414,11 @@ export function ComboboxInput({
395
414
  autoFocus={autoFocus}
396
415
  data-crud-focus-target=""
397
416
  disabled={disabled}
417
+ role="combobox"
418
+ aria-expanded={listboxVisible}
419
+ aria-controls={listboxId}
420
+ aria-autocomplete="list"
421
+ aria-activedescendant={listboxVisible && selectedIndex >= 0 ? optionDomId(selectedIndex) : undefined}
398
422
  onFocus={() => {
399
423
  setTouched(true)
400
424
  if (suppressOpenOnFocusRef.current) {
@@ -442,18 +466,21 @@ export function ComboboxInput({
442
466
  </IconButton>
443
467
  ) : null}
444
468
 
445
- {showSuggestions && !disabled && (loading || filteredSuggestions.length > 0) && (
469
+ {listboxVisible && (
446
470
  <div className="absolute z-popover w-full mt-1 rounded-md border border-input bg-popover p-2 shadow-md max-h-48 sm:max-h-60 overflow-auto">
447
471
  {loading && touched ? (
448
472
  <div className="px-2 py-1.5 text-xs text-muted-foreground">{loadingLabel}</div>
449
473
  ) : (
450
- <div className="flex flex-col gap-1">
474
+ <div id={listboxId} role="listbox" className="flex flex-col gap-1">
451
475
  {filteredSuggestions.map((option, index) => (
452
476
  <Button
453
477
  key={option.value}
478
+ id={optionDomId(index)}
454
479
  type="button"
455
480
  variant="ghost"
456
481
  size="sm"
482
+ role="option"
483
+ aria-selected={index === selectedIndex}
457
484
  className={[
458
485
  'w-full h-auto justify-start font-normal text-left flex flex-col items-start rounded-lg p-2',
459
486
  index === selectedIndex ? 'bg-muted' : '',
@@ -86,6 +86,8 @@ export function LookupSelect({
86
86
  const [hasTyped, setHasTyped] = React.useState(defaultOpen)
87
87
  const [error, setError] = React.useState<string | null>(null)
88
88
  const [fetchKey, setFetchKey] = React.useState(0)
89
+ const [activeIndex, setActiveIndex] = React.useState(-1)
90
+ const listboxId = React.useId()
89
91
  const fetchItemsRef = React.useRef(fetchItems ?? fetchOptions)
90
92
  const setQueryRef = React.useRef(setQuery)
91
93
  const onReadyRef = React.useRef(onReady)
@@ -116,6 +118,73 @@ export function LookupSelect({
116
118
 
117
119
  const shouldSearch =
118
120
  defaultOpen || query.trim().length >= minQuery || Boolean(value && (options?.length ?? 0) > 0)
121
+
122
+ React.useEffect(() => {
123
+ setActiveIndex(-1)
124
+ }, [items])
125
+
126
+ const optionDomId = React.useCallback(
127
+ (index: number) => `${listboxId}-option-${index}`,
128
+ [listboxId],
129
+ )
130
+
131
+ const isInteractiveItem = React.useCallback(
132
+ (item: LookupSelectItem) => !item.disabled || value === item.id,
133
+ [value],
134
+ )
135
+
136
+ const moveActiveIndex = React.useCallback((direction: 1 | -1) => {
137
+ setActiveIndex((current) => {
138
+ if (!items.length) return -1
139
+ let next = current
140
+ for (let step = 0; step < items.length; step += 1) {
141
+ next = (next + direction + items.length) % items.length
142
+ if (isInteractiveItem(items[next])) return next
143
+ }
144
+ return current
145
+ })
146
+ }, [isInteractiveItem, items])
147
+
148
+ React.useEffect(() => {
149
+ if (activeIndex < 0) return
150
+ const activeElement = typeof document !== 'undefined'
151
+ ? document.getElementById(optionDomId(activeIndex))
152
+ : null
153
+ if (typeof activeElement?.scrollIntoView === 'function') {
154
+ activeElement.scrollIntoView({ block: 'nearest' })
155
+ }
156
+ }, [activeIndex, optionDomId])
157
+
158
+ const listboxVisible = shouldSearch && !disabled
159
+ const handleInputKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
160
+ if (!listboxVisible) return
161
+ if (event.key === 'ArrowDown') {
162
+ event.preventDefault()
163
+ moveActiveIndex(1)
164
+ return
165
+ }
166
+ if (event.key === 'ArrowUp') {
167
+ event.preventDefault()
168
+ moveActiveIndex(-1)
169
+ return
170
+ }
171
+ if (event.key === 'Enter') {
172
+ if (activeIndex < 0 || activeIndex >= items.length) return
173
+ const item = items[activeIndex]
174
+ if (!isInteractiveItem(item)) return
175
+ event.preventDefault()
176
+ onChange(item.id)
177
+ setActiveIndex(-1)
178
+ return
179
+ }
180
+ if (event.key === 'Escape') {
181
+ if (query.length === 0 && activeIndex < 0) return
182
+ event.preventDefault()
183
+ event.stopPropagation()
184
+ setQuery('')
185
+ setActiveIndex(-1)
186
+ }
187
+ }, [activeIndex, items, isInteractiveItem, listboxVisible, moveActiveIndex, onChange, query])
119
188
  React.useEffect(() => {
120
189
  if (disabled) {
121
190
  setItems(options ?? [])
@@ -169,8 +238,14 @@ export function LookupSelect({
169
238
  setQuery(event.target.value)
170
239
  setHasTyped(true)
171
240
  }}
241
+ onKeyDown={handleInputKeyDown}
172
242
  placeholder={resolvedSearchPlaceholder}
173
243
  disabled={disabled}
244
+ role="combobox"
245
+ aria-expanded={listboxVisible}
246
+ aria-controls={listboxId}
247
+ aria-autocomplete="list"
248
+ aria-activedescendant={activeIndex >= 0 ? optionDomId(activeIndex) : undefined}
174
249
  />
175
250
  </div>
176
251
  {actionSlot ? <div className="sm:self-start">{actionSlot}</div> : null}
@@ -186,21 +261,28 @@ export function LookupSelect({
186
261
  {!loading && !loadingProp && !items.length ? (
187
262
  <p className="text-xs text-muted-foreground">{resolvedEmptyLabel}</p>
188
263
  ) : null}
189
- <div className="flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5">
190
- {items.map((item) => {
264
+ <div
265
+ id={listboxId}
266
+ role="listbox"
267
+ className="flex flex-col gap-1.5 max-h-80 overflow-y-auto -mx-0.5 px-0.5 py-0.5"
268
+ >
269
+ {items.map((item, index) => {
191
270
  const isSelected = value === item.id
192
271
  const isInteractive = !item.disabled || isSelected
272
+ const isActive = index === activeIndex
193
273
  return (
194
274
  <div
195
275
  key={item.id}
276
+ id={optionDomId(index)}
196
277
  className={cn(
197
278
  'group flex items-center gap-4 rounded-xl border p-4 transition-all duration-150 focus-visible:outline-none focus-visible:shadow-focus',
198
279
  isInteractive ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',
199
280
  isSelected
200
281
  ? 'border-brand-violet bg-brand-violet/5 shadow-sm'
201
- : 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm'
282
+ : 'border-input bg-card hover:border-foreground/20 hover:bg-muted/30 hover:shadow-sm',
283
+ isActive && !isSelected ? 'border-foreground/20 bg-muted/30 shadow-sm' : null
202
284
  )}
203
- role="button"
285
+ role="option"
204
286
  tabIndex={item.disabled ? -1 : 0}
205
287
  onClick={() => {
206
288
  if (!isInteractive) return
@@ -213,7 +295,7 @@ export function LookupSelect({
213
295
  onChange(item.id)
214
296
  }
215
297
  }}
216
- aria-pressed={isSelected}
298
+ aria-selected={isSelected}
217
299
  aria-disabled={item.disabled && !isSelected ? true : undefined}
218
300
  title={isSelected ? resolvedSelectedLabel : resolvedSelectLabel}
219
301
  >
@@ -71,7 +71,7 @@ describe('ComboboxInput clearable behavior', () => {
71
71
  />
72
72
  )
73
73
 
74
- const input = screen.getByRole('textbox') as HTMLInputElement
74
+ const input = screen.getByRole('combobox') as HTMLInputElement
75
75
  expect(input.value).toBe('Red')
76
76
 
77
77
  fireEvent.focus(input)
@@ -96,7 +96,7 @@ describe('ComboboxInput clearable behavior', () => {
96
96
  />
97
97
  )
98
98
 
99
- const input = screen.getByRole('textbox') as HTMLInputElement
99
+ const input = screen.getByRole('combobox') as HTMLInputElement
100
100
  fireEvent.focus(input)
101
101
  fireEvent.change(input, { target: { value: '' } })
102
102
 
@@ -111,7 +111,7 @@ describe('ComboboxInput clearable behavior', () => {
111
111
  const onChange = jest.fn()
112
112
  render(<Harness initialValue="red" clearable onChange={onChange} />)
113
113
 
114
- const input = screen.getByRole('textbox') as HTMLInputElement
114
+ const input = screen.getByRole('combobox') as HTMLInputElement
115
115
  expect(input.value).toBe('Red')
116
116
 
117
117
  const clearBtn = screen.getByRole('button', { name: /clear value/i })
@@ -86,3 +86,80 @@ describe('LookupSelect onReady stability', () => {
86
86
  expect(input.value).toBe('Mercato Fashion Online')
87
87
  })
88
88
  })
89
+
90
+ describe('LookupSelect keyboard accessibility', () => {
91
+ const ITEMS = [
92
+ { id: 'plot-1', title: 'Fazenda Norte' },
93
+ { id: 'plot-2', title: 'Fazenda Sul' },
94
+ ]
95
+
96
+ function renderWithResults(onChange: (next: string | null) => void) {
97
+ const utils = render(
98
+ <LookupSelect
99
+ value={null}
100
+ onChange={onChange}
101
+ fetchItems={async () => ITEMS}
102
+ minQuery={2}
103
+ />,
104
+ )
105
+ return utils
106
+ }
107
+
108
+ it('exposes combobox/listbox/option semantics once results render', async () => {
109
+ const { container } = renderWithResults(() => {})
110
+ const input = getInput(container)
111
+ expect(input).toHaveAttribute('role', 'combobox')
112
+ expect(input).toHaveAttribute('aria-autocomplete', 'list')
113
+
114
+ fireEvent.change(input, { target: { value: 'Faz' } })
115
+ const options = await screen.findAllByRole('option')
116
+ expect(options).toHaveLength(2)
117
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
118
+ expect(input).toHaveAttribute('aria-expanded', 'true')
119
+ })
120
+
121
+ it('selects the highlighted result with ArrowDown + Enter', async () => {
122
+ const onChange = jest.fn()
123
+ const { container } = renderWithResults(onChange)
124
+ const input = getInput(container)
125
+
126
+ fireEvent.change(input, { target: { value: 'Faz' } })
127
+ await screen.findAllByRole('option')
128
+
129
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
130
+ expect(input).toHaveAttribute('aria-activedescendant')
131
+ fireEvent.keyDown(input, { key: 'Enter' })
132
+ expect(onChange).toHaveBeenCalledWith('plot-1')
133
+ })
134
+
135
+ it('moves the highlight with repeated arrows and wraps', async () => {
136
+ const onChange = jest.fn()
137
+ const { container } = renderWithResults(onChange)
138
+ const input = getInput(container)
139
+
140
+ fireEvent.change(input, { target: { value: 'Faz' } })
141
+ await screen.findAllByRole('option')
142
+
143
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
144
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
145
+ fireEvent.keyDown(input, { key: 'Enter' })
146
+ expect(onChange).toHaveBeenCalledWith('plot-2')
147
+ })
148
+
149
+ it('clears the query with Escape instead of leaking it to the dialog', async () => {
150
+ const escapeSpy = jest.fn()
151
+ const { container } = render(
152
+ <div onKeyDown={escapeSpy}>
153
+ <LookupSelect value={null} onChange={() => {}} fetchItems={async () => ITEMS} minQuery={2} />
154
+ </div>,
155
+ )
156
+ const input = getInput(container)
157
+ fireEvent.change(input, { target: { value: 'Faz' } })
158
+ await screen.findAllByRole('option')
159
+
160
+ escapeSpy.mockClear()
161
+ fireEvent.keyDown(input, { key: 'Escape' })
162
+ expect(input.value).toBe('')
163
+ expect(escapeSpy).not.toHaveBeenCalled()
164
+ })
165
+ })