@open-mercato/ui 0.6.8-develop.6924.1.a8d208fcdc → 0.6.8-develop.6930.1.1e5976efc3

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.
@@ -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
+ })