@open-mercato/ui 0.7.1-develop.7122.1.421cefe668 → 0.7.1-develop.7130.1.fef2396fd8

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.
@@ -5,6 +5,7 @@ import { X } from 'lucide-react'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { Button } from '../../primitives/button'
7
7
  import { IconButton } from '../../primitives/icon-button'
8
+ import { Popover, PopoverAnchor, PopoverContent } from '../../primitives/popover'
8
9
 
9
10
  export type ComboboxOption = {
10
11
  value: string
@@ -412,82 +413,114 @@ export function ComboboxInput({
412
413
  && (loading || filteredSuggestions.length > 0 || (touched && input.trim().length > 0))
413
414
 
414
415
  return (
415
- <div className="relative w-full">
416
- {/* Use raw <input> here instead of the DS Input primitive: ComboboxInput's
417
- focus / suggestions-popup interplay relies on the trigger being a plain
418
- input element. The DS wrapper introduces a <div> that desyncs autocomplete
419
- on this specific surface. Keeps the rest of the form on Input primitive. */}
420
- <input
421
- ref={inputRef}
422
- type="text"
423
- className={[
424
- 'w-full h-9 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:shadow-focus focus-visible:border-foreground disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed',
425
- showClearButton ? 'pr-9' : '',
426
- ]
427
- .filter(Boolean)
428
- .join(' ')}
429
- value={input}
430
- placeholder={resolvedPlaceholder}
431
- autoFocus={autoFocus}
432
- data-crud-focus-target=""
433
- disabled={disabled}
434
- role="combobox"
435
- aria-expanded={listboxVisible}
436
- aria-controls={listboxVisible && !loading && filteredSuggestions.length > 0 ? listboxId : undefined}
437
- aria-autocomplete="list"
438
- aria-activedescendant={listboxVisible && selectedIndex >= 0 ? optionDomId(selectedIndex) : undefined}
439
- onFocus={() => {
440
- setTouched(true)
441
- if (suppressOpenOnFocusRef.current) {
442
- suppressOpenOnFocusRef.current = false
443
- return
444
- }
445
- resetBlurCloseState()
446
- if (loadSuggestions && availableOptions.length === 0) {
447
- setLoading(true)
448
- }
449
- setShowSuggestions(true)
450
- }}
451
- onChange={(event) => {
452
- setTouched(true)
453
- userTypedRef.current = true
454
- setInput(event.target.value)
455
- setShowSuggestions(true)
456
- setSelectedIndex(-1)
457
- }}
458
- onKeyDown={handleKeyDown}
459
- onBlur={() => {
460
- // Delay closing so clicks on the popup can resolve first. If async
461
- // suggestions are still loading, keep the dropdown open instead of
462
- // closing before the first payload arrives.
463
- userTypedRef.current = false
464
- blurClosePendingRef.current = true
465
- clearBlurCloseTimer()
466
- if (loadingRef.current) {
467
- blurCloseTimerRef.current = window.setTimeout(closeAfterBlur, blurCloseMaxDelayMs)
468
- return
469
- }
470
- blurCloseTimerRef.current = window.setTimeout(attemptBlurClose, blurCloseDelayMs)
471
- }}
472
- />
473
-
474
- {showClearButton ? (
475
- <IconButton
476
- type="button"
477
- variant="ghost"
478
- size="xs"
479
- aria-label={resolvedClearLabel}
480
- className="absolute right-1 top-1/2 -translate-y-1/2"
481
- onMouseDown={(event) => event.preventDefault()}
482
- onClick={handleClear}
483
- >
484
- <X className="size-3" />
485
- </IconButton>
486
- ) : null}
487
-
488
- {listboxVisible && (
489
- <div
490
- 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"
416
+ // The suggestion list goes through the DS Popover so it is portaled out of any
417
+ // scrolling ancestor. Rendered in place it was clipped by a Dialog's
418
+ // `overflow-y-auto`, where z-index cannot help. `open` stays fully controlled
419
+ // (no `onOpenChange`) so the blur timer, Escape and selection keep owning
420
+ // dismissal exactly as before.
421
+ <Popover open={listboxVisible}>
422
+ <PopoverAnchor asChild>
423
+ <div className="relative w-full">
424
+ {/* Use raw <input> here instead of the DS Input primitive: ComboboxInput's
425
+ focus / suggestions-popup interplay relies on the trigger being a plain
426
+ input element. The DS wrapper introduces a <div> that desyncs autocomplete
427
+ on this specific surface. Keeps the rest of the form on Input primitive. */}
428
+ <input
429
+ ref={inputRef}
430
+ type="text"
431
+ className={[
432
+ 'w-full h-9 rounded-md border border-input bg-background px-3 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:shadow-focus focus-visible:border-foreground disabled:bg-bg-disabled disabled:border-border-disabled disabled:text-muted-foreground disabled:cursor-not-allowed',
433
+ showClearButton ? 'pr-9' : '',
434
+ ]
435
+ .filter(Boolean)
436
+ .join(' ')}
437
+ value={input}
438
+ placeholder={resolvedPlaceholder}
439
+ autoFocus={autoFocus}
440
+ data-crud-focus-target=""
441
+ disabled={disabled}
442
+ role="combobox"
443
+ aria-expanded={listboxVisible}
444
+ aria-controls={listboxVisible && !loading && filteredSuggestions.length > 0 ? listboxId : undefined}
445
+ // The listbox is portaled out of the input's subtree, so `aria-owns` is what
446
+ // makes it a logical descendant and keeps `aria-activedescendant` below valid.
447
+ aria-owns={listboxVisible && !loading && filteredSuggestions.length > 0 ? listboxId : undefined}
448
+ aria-autocomplete="list"
449
+ aria-activedescendant={listboxVisible && selectedIndex >= 0 ? optionDomId(selectedIndex) : undefined}
450
+ onFocus={() => {
451
+ setTouched(true)
452
+ if (suppressOpenOnFocusRef.current) {
453
+ suppressOpenOnFocusRef.current = false
454
+ return
455
+ }
456
+ resetBlurCloseState()
457
+ if (loadSuggestions && availableOptions.length === 0) {
458
+ setLoading(true)
459
+ }
460
+ setShowSuggestions(true)
461
+ }}
462
+ onChange={(event) => {
463
+ setTouched(true)
464
+ userTypedRef.current = true
465
+ setInput(event.target.value)
466
+ setShowSuggestions(true)
467
+ setSelectedIndex(-1)
468
+ }}
469
+ onKeyDown={handleKeyDown}
470
+ onBlur={() => {
471
+ // Delay closing so clicks on the popup can resolve first. If async
472
+ // suggestions are still loading, keep the dropdown open instead of
473
+ // closing before the first payload arrives.
474
+ userTypedRef.current = false
475
+ blurClosePendingRef.current = true
476
+ clearBlurCloseTimer()
477
+ if (loadingRef.current) {
478
+ blurCloseTimerRef.current = window.setTimeout(closeAfterBlur, blurCloseMaxDelayMs)
479
+ return
480
+ }
481
+ blurCloseTimerRef.current = window.setTimeout(attemptBlurClose, blurCloseDelayMs)
482
+ }}
483
+ />
484
+
485
+ {showClearButton ? (
486
+ <IconButton
487
+ type="button"
488
+ variant="ghost"
489
+ size="xs"
490
+ aria-label={resolvedClearLabel}
491
+ className="absolute right-1 top-1/2 -translate-y-1/2"
492
+ onMouseDown={(event) => event.preventDefault()}
493
+ onClick={handleClear}
494
+ >
495
+ <X className="size-3" />
496
+ </IconButton>
497
+ ) : null}
498
+ </div>
499
+ </PopoverAnchor>
500
+
501
+ {/* Unmount the content outright rather than leaning on Radix's exit animation:
502
+ a closing-but-still-mounted layer keeps swallowing Escape, which would
503
+ strand the popup's host (see AdvancedFilterPanel for that failure mode). */}
504
+ {listboxVisible ? (
505
+ <PopoverContent
506
+ // Radix hardcodes `role="dialog"` on popover content. This popup is a
507
+ // positioning shell around the listbox below, and a second dialog node
508
+ // would both misdescribe it and break `getByRole('dialog')` for anything
509
+ // that queries while suggestions happen to be open.
510
+ role="presentation"
511
+ // Focus must stay in the input: the blur-close timer and
512
+ // aria-activedescendant model depend on it, and Radix would otherwise
513
+ // move focus into the list on open and back to the trigger on close.
514
+ onOpenAutoFocus={(event) => event.preventDefault()}
515
+ onCloseAutoFocus={(event) => event.preventDefault()}
516
+ // A modal Dialog locks scrolling through `react-remove-scroll`, which
517
+ // cancels document-level `wheel` and `touchmove` outside its own content
518
+ // node. The portaled list is not part of that exemption, so without these
519
+ // a long list inside a dialog cannot be scrolled by wheel or by touch.
520
+ // `overscroll-contain` stops the scroll chaining to the page at the ends.
521
+ onWheel={(event) => event.stopPropagation()}
522
+ onTouchMove={(event) => event.stopPropagation()}
523
+ className="w-[var(--radix-popover-trigger-width)] min-w-0 max-h-48 sm:max-h-60 overflow-auto overscroll-contain border-input p-2"
491
524
  >
492
525
  {loading && touched ? (
493
526
  <div className="px-2 py-1.5 text-xs text-muted-foreground" role="status">{loadingLabel}</div>
@@ -525,8 +558,8 @@ export function ComboboxInput({
525
558
  ))}
526
559
  </div>
527
560
  )}
528
- </div>
529
- )}
530
- </div>
561
+ </PopoverContent>
562
+ ) : null}
563
+ </Popover>
531
564
  )
532
565
  }
@@ -0,0 +1,134 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
4
+ useT: () => (_key: string, fallback: string) => fallback,
5
+ }))
6
+
7
+ import * as React from 'react'
8
+ import { act, fireEvent, render, screen } from '@testing-library/react'
9
+ import { Dialog, DialogContent, DialogTitle } from '../../../primitives/dialog'
10
+ import { ComboboxInput } from '../ComboboxInput'
11
+
12
+ function DialogHarness() {
13
+ const [open, setOpen] = React.useState(true)
14
+ const [value, setValue] = React.useState('')
15
+ return (
16
+ <Dialog open={open} onOpenChange={setOpen}>
17
+ <DialogContent>
18
+ <DialogTitle>Move inventory</DialogTitle>
19
+ <ComboboxInput
20
+ value={value}
21
+ onChange={setValue}
22
+ suggestions={[
23
+ { value: 'red', label: 'Red' },
24
+ { value: 'green', label: 'Green' },
25
+ ]}
26
+ />
27
+ <output data-testid="value">{value}</output>
28
+ </DialogContent>
29
+ </Dialog>
30
+ )
31
+ }
32
+
33
+ function openSuggestions() {
34
+ const input = screen.getByRole('combobox')
35
+ fireEvent.focus(input)
36
+ fireEvent.change(input, { target: { value: 'gre' } })
37
+ return input
38
+ }
39
+
40
+ describe('ComboboxInput inside a Dialog', () => {
41
+ it('renders the suggestion list outside the dialog scroll container', () => {
42
+ render(<DialogHarness />)
43
+ openSuggestions()
44
+
45
+ const dialogContent = document.querySelector('[data-dialog-content]')
46
+ const listbox = screen.getByRole('listbox')
47
+
48
+ expect(dialogContent).not.toBeNull()
49
+ expect(dialogContent!.contains(listbox)).toBe(false)
50
+ })
51
+
52
+ // `react-remove-scroll` (the dialog's scroll lock) listens on the document for
53
+ // both `wheel` and `touchmove` and cancels either one when it is raised outside
54
+ // the dialog content, which would freeze the portaled list.
55
+ it.each(['wheel', 'touchMove'] as const)('keeps %s events off the dialog scroll lock so a long list stays scrollable', (eventName) => {
56
+ render(<DialogHarness />)
57
+ openSuggestions()
58
+
59
+ const domEventName = eventName === 'wheel' ? 'wheel' : 'touchmove'
60
+ // The scroll lock reads coordinates off the event, so give it usable ones.
61
+ const init = eventName === 'wheel'
62
+ ? { deltaY: 120 }
63
+ : { touches: [{ clientX: 0, clientY: 0 }], changedTouches: [{ clientX: 0, clientY: 0 }] }
64
+ const onDocumentEvent = jest.fn()
65
+ document.addEventListener(domEventName, onDocumentEvent)
66
+ try {
67
+ fireEvent[eventName](screen.getByRole('listbox'), init)
68
+ expect(onDocumentEvent).not.toHaveBeenCalled()
69
+
70
+ // control: the same event anywhere else still reaches the document listener
71
+ fireEvent[eventName](screen.getByRole('combobox'), init)
72
+ expect(onDocumentEvent).toHaveBeenCalledTimes(1)
73
+ } finally {
74
+ document.removeEventListener(domEventName, onDocumentEvent)
75
+ }
76
+ })
77
+
78
+ it('does not add a second dialog node while the suggestions are open', () => {
79
+ render(<DialogHarness />)
80
+ openSuggestions()
81
+
82
+ // Radix hardcodes role="dialog" on popover content; ~35 test files resolve
83
+ // `getByRole('dialog')`, which is strict about multiple matches.
84
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
85
+ expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
86
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-dialog-content')
87
+ })
88
+
89
+ it('keeps the dialog open when an option is picked', async () => {
90
+ render(<DialogHarness />)
91
+ openSuggestions()
92
+
93
+ // Radix registers its outside-interaction listener on a macrotask, so let it
94
+ // land before simulating the pointer sequence a real click produces.
95
+ await act(async () => {
96
+ await Promise.resolve()
97
+ await new Promise((resolve) => setTimeout(resolve, 0))
98
+ })
99
+
100
+ const option = screen.getByRole('option', { name: /green/i })
101
+ fireEvent.pointerDown(option, { bubbles: true })
102
+ fireEvent.mouseDown(option, { bubbles: true })
103
+ fireEvent.click(option)
104
+
105
+ expect(screen.getByTestId('value')).toHaveTextContent('green')
106
+ expect(document.querySelector('[data-dialog-content]')).not.toBeNull()
107
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
108
+ })
109
+
110
+ it('still dismisses the dialog on a genuinely outside pointer down', async () => {
111
+ render(<DialogHarness />)
112
+ openSuggestions()
113
+
114
+ await act(async () => {
115
+ await Promise.resolve()
116
+ await new Promise((resolve) => setTimeout(resolve, 0))
117
+ })
118
+
119
+ // Control for the case above: the suppression must be scoped to the portaled
120
+ // list, not a blanket block on the dialog's outside-interaction handling.
121
+ const outside = document.createElement('button')
122
+ document.body.appendChild(outside)
123
+ try {
124
+ await act(async () => {
125
+ fireEvent.pointerDown(outside, { bubbles: true })
126
+ fireEvent.mouseDown(outside, { bubbles: true })
127
+ fireEvent.click(outside)
128
+ })
129
+ expect(document.querySelector('[data-dialog-content]')).toBeNull()
130
+ } finally {
131
+ outside.remove()
132
+ }
133
+ })
134
+ })
@@ -557,5 +557,100 @@ describe('ComboboxInput accessibility', () => {
557
557
  const listbox = screen.getByRole('listbox')
558
558
  expect(within(listbox).getAllByRole('option')).toHaveLength(2)
559
559
  expect(input).toHaveAttribute('aria-controls', listbox.id)
560
+ // The listbox is portaled out of the input's subtree, so aria-activedescendant
561
+ // is only valid while aria-owns makes it a logical descendant.
562
+ expect(input).toHaveAttribute('aria-owns', listbox.id)
563
+ expect(listbox.contains(input)).toBe(false)
564
+ })
565
+ })
566
+
567
+ describe('ComboboxInput suggestion popup placement', () => {
568
+ it('renders the suggestion list outside the field wrapper so an overflow ancestor cannot clip it', () => {
569
+ const { container } = render(<Harness />)
570
+ const input = getInput(container)
571
+
572
+ fireEvent.focus(input)
573
+ fireEvent.change(input, { target: { value: 're' } })
574
+
575
+ const listbox = screen.getByRole('listbox')
576
+ expect(container.contains(listbox)).toBe(false)
577
+ expect(document.body.contains(listbox)).toBe(true)
578
+ })
579
+
580
+ it('keeps focus on the input while the popup is open', () => {
581
+ const { container } = render(<Harness />)
582
+ const input = getInput(container)
583
+
584
+ act(() => {
585
+ input.focus()
586
+ fireEvent.focus(input)
587
+ })
588
+ fireEvent.change(input, { target: { value: 're' } })
589
+
590
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
591
+ expect(document.activeElement).toBe(input)
592
+ })
593
+
594
+ it('selects a portaled option by click', () => {
595
+ render(<Harness />)
596
+ const input = screen.getByRole('combobox')
597
+
598
+ fireEvent.focus(input)
599
+ fireEvent.change(input, { target: { value: 'gre' } })
600
+ fireEvent.click(screen.getByRole('option', { name: /green/i }))
601
+
602
+ expect(screen.getByTestId('value')).toHaveTextContent('green')
603
+ })
604
+
605
+ it('selects a portaled option by keyboard', () => {
606
+ render(<Harness />)
607
+ const input = screen.getByRole('combobox')
608
+
609
+ fireEvent.focus(input)
610
+ fireEvent.change(input, { target: { value: 'gre' } })
611
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
612
+ fireEvent.keyDown(input, { key: 'Enter' })
613
+
614
+ expect(screen.getByTestId('value')).toHaveTextContent('green')
615
+ })
616
+
617
+ it('closes the popup on Escape without letting the key reach an enclosing surface', () => {
618
+ const onSurfaceKeyDown = jest.fn()
619
+ render(
620
+ <div onKeyDown={onSurfaceKeyDown}>
621
+ <Harness />
622
+ </div>,
623
+ )
624
+ const input = screen.getByRole('combobox')
625
+
626
+ fireEvent.focus(input)
627
+ fireEvent.change(input, { target: { value: 're' } })
628
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
629
+
630
+ fireEvent.keyDown(input, { key: 'Escape' })
631
+
632
+ expect(screen.queryByRole('listbox')).toBeNull()
633
+ expect(onSurfaceKeyDown).not.toHaveBeenCalled()
634
+ })
635
+
636
+ it('removes the popup from the document once it closes', () => {
637
+ jest.useFakeTimers()
638
+ try {
639
+ render(<Harness />)
640
+ const input = screen.getByRole('combobox')
641
+
642
+ fireEvent.focus(input)
643
+ fireEvent.change(input, { target: { value: 're' } })
644
+ expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
645
+
646
+ blurAndFlush(input)
647
+
648
+ expect(document.body.querySelector('[role="listbox"]')).toBeNull()
649
+ } finally {
650
+ act(() => {
651
+ jest.runOnlyPendingTimers()
652
+ })
653
+ jest.useRealTimers()
654
+ }
560
655
  })
561
656
  })
@@ -87,8 +87,11 @@ describe('Issue #1836: portaled overlay primitives sit above modals (z-popover >
87
87
  fireEvent.change(input!, { target: { value: 'a' } })
88
88
  })
89
89
 
90
- const suggestions = container.querySelector('[class*="z-popover"]')
90
+ // The list is portaled to document.body (via the DS Popover) so a Dialog's
91
+ // overflow cannot clip it -- so look it up on the document, not on `container`.
92
+ const suggestions = document.body.querySelector('[class*="z-popover"]')
91
93
  expect(suggestions).not.toBeNull()
94
+ expect(container.contains(suggestions)).toBe(false)
92
95
  const token = findZIndexToken((suggestions as HTMLElement).className)
93
96
  expect(token).toBe('z-popover')
94
97
  expect(Z_INDEX_BY_TOKEN[token!]).toBeGreaterThan(Z_INDEX_BY_TOKEN['z-modal'])