Commit 387a4091 by CaIon

fix(web): keep drawer popups interactive and shim storage in tests

- Portal Combobox and Select popups into the vaul DrawerContent via a
  portal-container context so they stay inside the Radix modal layer
  instead of inheriting body pointer-events: none
- Provide an in-memory localStorage/sessionStorage in test-setup when the
  Node 25+ global accessor resolves to undefined and shadows jsdom
- Add regression tests for popups rendered inside and outside the drawer
parent 7bbe85bc
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Combobox } from '../combobox'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
} from '../drawer'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../select'
const options = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Google' },
]
function ProviderCombobox(props: {
onValueChange: (value: string | null) => void
}) {
return (
<Combobox
options={options}
value='openai'
onValueChange={props.onValueChange}
aria-label='Provider'
/>
)
}
function ProviderSelect(props: {
onValueChange: (value: string | null) => void
}) {
return (
<Select value='openai' onValueChange={props.onValueChange}>
<SelectTrigger aria-label='Provider'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
function FilterDrawer(props: { children: ReactNode }) {
return (
<Drawer open>
<DrawerContent>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>Adjust the result filters</DrawerDescription>
{props.children}
</DrawerContent>
</Drawer>
)
}
// JSDOM does not implement the pointer-capture API used by the drawer.
const pointerCapture = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'setPointerCapture'
)
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: () => undefined,
})
})
afterEach(() => {
if (pointerCapture) {
Object.defineProperty(
HTMLElement.prototype,
'setPointerCapture',
pointerCapture
)
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture')
}
})
describe('popups inside a drawer', () => {
it('renders combobox options inside the drawer dialog and clicking one applies it without closing the drawer', async () => {
const change = vi.fn()
render(
<FilterDrawer>
<ProviderCombobox onValueChange={change} />
</FilterDrawer>
)
const user = userEvent.setup()
const dialog = screen.getByRole('dialog', { name: 'Filters' })
await user.click(within(dialog).getByRole('combobox', { name: 'Provider' }))
await user.click(
await within(dialog).findByRole('option', { name: 'Google' })
)
expect(change).toHaveBeenCalledWith('gemini')
expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument()
})
it('renders select options inside the drawer dialog and clicking one applies it without closing the drawer', async () => {
const change = vi.fn()
render(
<FilterDrawer>
<ProviderSelect onValueChange={change} />
</FilterDrawer>
)
const user = userEvent.setup()
const dialog = screen.getByRole('dialog', { name: 'Filters' })
await user.click(within(dialog).getByRole('combobox', { name: 'Provider' }))
await user.click(
await within(dialog).findByRole('option', { name: 'Google' })
)
expect(change).toHaveBeenCalledWith('gemini', expect.anything())
expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument()
})
})
describe('popups outside a drawer', () => {
it('portals combobox options to document.body outside the component subtree', async () => {
const view = render(<ProviderCombobox onValueChange={vi.fn()} />)
const user = userEvent.setup()
await user.click(screen.getByRole('combobox', { name: 'Provider' }))
const listbox = await screen.findByRole('listbox')
expect(document.body).toContainElement(listbox)
expect(view.container).not.toContainElement(listbox)
})
it('portals select options to document.body outside the component subtree', async () => {
const view = render(<ProviderSelect onValueChange={vi.fn()} />)
const user = userEvent.setup()
await user.click(screen.getByRole('combobox', { name: 'Provider' }))
const listbox = await screen.findByRole('listbox')
expect(document.body).toContainElement(listbox)
expect(view.container).not.toContainElement(listbox)
})
})
......@@ -37,6 +37,7 @@ import {
InputGroupButton,
InputGroupInput,
} from '@/components/ui/input-group'
import { usePortalContainer } from '@/components/ui/portal-container'
import { cn } from '@/lib/utils'
type LegacyComboboxProps = {
......@@ -116,7 +117,10 @@ function OptionCombobox(props: LegacyComboboxProps) {
}}
filter={(option, query) => {
const term = query.trim().toLowerCase()
return option.label.toLowerCase().includes(term) || option.value.toLowerCase().includes(term)
return (
option.label.toLowerCase().includes(term) ||
option.value.toLowerCase().includes(term)
)
}}
isItemEqualToValue={(item, value) => item.value === value.value}
>
......@@ -133,18 +137,33 @@ function OptionCombobox(props: LegacyComboboxProps) {
aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']}
aria-invalid={props['aria-invalid']}
placeholder={props.searchPlaceholder ?? props.placeholder ?? t('Search...')}
placeholder={
props.searchPlaceholder ?? props.placeholder ?? t('Search...')
}
triggerAriaLabel={props['aria-label'] ?? t('Open')}
className='h-full min-h-8 w-full'
/>
</div>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{props.emptyText ?? t('No results found')}</ComboboxEmpty>
<ComboboxEmpty>
{props.emptyText ?? t('No results found')}
</ComboboxEmpty>
<ComboboxList>
{(option: ComboboxInputOption) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
<ComboboxItem
key={option.value}
value={option}
disabled={option.disabled}
>
{option.icon && <span aria-hidden>{option.icon}</span>}
<span className='min-w-0 break-words'>{option.label}{option.description && <span className='text-muted-foreground block text-xs break-all'>{option.description}</span>}</span>
<span className='min-w-0 break-words'>
{option.label}
{option.description && (
<span className='text-muted-foreground block text-xs break-all'>
{option.description}
</span>
)}
</span>
</ComboboxItem>
)}
</ComboboxList>
......@@ -245,8 +264,9 @@ function ComboboxContent({
ComboboxPrimitive.Positioner.Props,
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
>) {
const container = usePortalContainer()
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Portal container={container}>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
......
......@@ -21,6 +21,7 @@ For commercial licensing, please contact support@quantumnous.com
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { PortalContainerContext } from '@/components/ui/portal-container'
import { cn } from '@/lib/utils'
function Drawer({
......@@ -66,12 +67,21 @@ function DrawerOverlay({
function DrawerContent({
className,
children,
ref,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
// Popups rendered inside the drawer portal into this element so they stay
// within the modal layer that keeps pointer events and focus enabled.
const content = React.useRef<HTMLDivElement | null>(null)
return (
<DrawerPortal data-slot='drawer-portal'>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={(node) => {
content.current = node
if (typeof ref === 'function') return ref(node)
if (ref) ref.current = node
}}
data-slot='drawer-content'
className={cn(
'group/drawer-content bg-background text-foreground fixed z-50 flex h-auto flex-col overflow-hidden text-sm shadow-none data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
......@@ -80,7 +90,9 @@ function DrawerContent({
{...props}
>
<div className='bg-muted mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block' />
{children}
<PortalContainerContext.Provider value={content}>
{children}
</PortalContainerContext.Provider>
</DrawerPrimitive.Content>
</DrawerPortal>
)
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
/**
* Element that floating popups (Combobox, Select, ...) portal into instead of
* `document.body`. Layered surfaces that disable pointer events or trap focus
* outside themselves, such as the vaul-based Drawer, provide their own content
* element so nested popups stay interactive. `undefined` keeps the default.
*/
const PortalContainerContext = React.createContext<
React.RefObject<HTMLElement | null> | undefined
>(undefined)
function usePortalContainer() {
return React.useContext(PortalContainerContext)
}
export { PortalContainerContext, usePortalContainer }
......@@ -28,6 +28,7 @@ import {
import { HugeiconsIcon } from '@hugeicons/react'
import * as React from 'react'
import { usePortalContainer } from '@/components/ui/portal-container'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
......@@ -100,6 +101,7 @@ function SelectContent({
'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
>) {
const isMobile = useMediaQuery('(max-width: 640px)')
const container = usePortalContainer()
const content = (
<SelectPrimitive.Positioner
......@@ -130,7 +132,11 @@ function SelectContent({
return content
}
return <SelectPrimitive.Portal>{content}</SelectPrimitive.Portal>
return (
<SelectPrimitive.Portal container={container}>
{content}
</SelectPrimitive.Portal>
)
}
function SelectLabel({
......
......@@ -71,3 +71,32 @@ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: () => undefined,
})
// Node.js 25+ defines `localStorage`/`sessionStorage` accessors on the global
// object that resolve to `undefined` unless `--localstorage-file` is set, and
// vitest's jsdom environment does not replace globals that already exist.
// Provide an in-memory Storage so tests see the same API as in a browser.
for (const name of ['localStorage', 'sessionStorage'] as const) {
if (typeof globalThis[name]?.setItem === 'function') continue
const entries = new Map<string, string>()
const storage: Storage = {
get length() {
return entries.size
},
clear: () => entries.clear(),
getItem: (key) => entries.get(String(key)) ?? null,
key: (index) => [...entries.keys()][index] ?? null,
removeItem: (key) => {
entries.delete(String(key))
},
setItem: (key, value) => {
entries.set(String(key), String(value))
},
}
Object.defineProperty(globalThis, name, {
configurable: true,
enumerable: true,
writable: true,
value: storage,
})
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment