@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +9 -2
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +10 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useRegisteredComponent.js +48 -36
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +24 -1
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +125 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +9 -2
- package/src/backend/__tests__/CrudForm.render.test.tsx +1 -1
- package/src/backend/__tests__/CrudForm.validation.test.tsx +53 -2
- package/src/backend/__tests__/CrudForm.visibleWhen.test.tsx +108 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +10 -0
- package/src/backend/injection/__tests__/useRegisteredComponent.remount.test.tsx +96 -0
- package/src/backend/injection/useRegisteredComponent.tsx +85 -38
- package/src/backend/inputs/ComboboxInput.tsx +29 -2
- package/src/backend/inputs/LookupSelect.tsx +87 -5
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +3 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +77 -0
|
@@ -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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
101
|
-
return
|
|
102
|
-
}, [componentId, fallback
|
|
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
|
-
{
|
|
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
|
|
190
|
-
{
|
|
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="
|
|
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-
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
+
})
|