Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion packages/core/src/Combobox/Combobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { nextTick } from 'vue'
import { defineComponent, h, nextTick, ref } from 'vue'
import { handleSubmit, sleep } from '@/test'
import { ComboboxAnchor, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxRoot, ComboboxTrigger, ComboboxViewport } from '.'
import Combobox from './story/_Combobox.vue'
import ComboboxObject from './story/_ComboboxObject.vue'
import ComboboxTagsInput from './story/_ComboboxTagsInput.vue'
Expand Down Expand Up @@ -542,3 +543,93 @@ describe('given combobox handleBlur with deferred close', () => {
externalButton.remove()
})
})

describe('comboboxContent with popper positioning', () => {
const getSlotRenderCount = vi.fn(() => ({ value: 0 }))

const PopperCombobox = defineComponent({
setup() {
const modelValue = ref('')
const slotRenderCount = getSlotRenderCount()
const options = Array.from({ length: 60 }, (_, index) => `Option ${index}`)

return () => h(ComboboxRoot, {
'modelValue': modelValue.value,
'onUpdate:modelValue': (value: string) => modelValue.value = value,
}, {
default: () => [
h(ComboboxAnchor, null, {
default: () => [
h(ComboboxInput),
h(ComboboxTrigger, null, { default: () => 'Open' }),
],
}),
h(ComboboxContent, { position: 'popper' }, {
default: () => {
slotRenderCount.value += 1
return h(ComboboxViewport, null, {
default: () => options.map(option => h(ComboboxItem, { key: option, value: option }, { default: () => option })),
})
},
}),
],
})
},
})

beforeEach(() => {
document.body.innerHTML = ''
getSlotRenderCount.mockClear()
globalThis.ResizeObserver = class ResizeObserver {
private callback: ResizeObserverCallback

constructor(callback: ResizeObserverCallback) {
this.callback = callback
}

observe(target: Element) {
this.callback([{ target } as ResizeObserverEntry], this)
this.callback([{ target } as ResizeObserverEntry], this)
}

unobserve() {}
disconnect() {}
}
})

it('does not rerender option slot content after popper position updates', async () => {
const slotRenderCount = { value: 0 }
getSlotRenderCount.mockReturnValue(slotRenderCount)

const wrapper = mount(PopperCombobox, { attachTo: document.body })

await wrapper.find('button').trigger('click')
await nextTick()

expect(slotRenderCount.value).toBe(3)

await sleep(0)
await nextTick()

expect(slotRenderCount.value).toBe(4)
})

it('updates visible options when filtering while popper content is open', async () => {
const slotRenderCount = { value: 0 }
getSlotRenderCount.mockReturnValue(slotRenderCount)

const wrapper = mount(PopperCombobox, { attachTo: document.body })

await wrapper.find('button').trigger('click')
await nextTick()

expect(wrapper.text()).toContain('Option 1')
expect(wrapper.text()).toContain('Option 59')

await wrapper.find('input').setValue('Option 59')
await nextTick()

expect(wrapper.text()).toContain('Option 59')
expect(wrapper.text()).not.toContain('Option 1')
})
})
3 changes: 3 additions & 0 deletions packages/core/src/Combobox/ComboboxContentImpl.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ onUnmounted(() => {
v-bind="{ ...$attrs, ...forwardedProps }"
:id="rootContext.contentId"
:ref="forwardRef"
:memo-dependencies="position === 'popper'
? [rootContext.filterSearch.value, rootContext.filterState.value]
: undefined"
Comment thread
yan-ad marked this conversation as resolved.
:data-state="rootContext.open.value ? 'open' : 'closed'"
:data-empty="isEmpty ? '' : undefined"
:style="{
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/Combobox/ComboboxItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ onUnmounted(() => {
v-bind="props"
:id="id"
ref="primitiveElement"
v-memo="[isRender, rootContext.filterSearch.value, rootContext.disabled.value, disabled, props.value, props.as, props.asChild, ...Object.values($attrs)]"
:disabled="rootContext.disabled.value || disabled"
@select="(event) => {
emits('select', event as any)
Expand Down
34 changes: 33 additions & 1 deletion packages/core/src/Popper/PopperContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export const PopperContentPropsDefaultValue = {
}

export interface PopperContentProps extends PrimitiveProps {
/**
* Reactive dependencies that should invalidate the memoized content subtree.
*/
memoDependencies?: unknown[]

/**
* The preferred side of the trigger to render against when open.
* Will be reversed when collisions occur and avoidCollisions
Expand Down Expand Up @@ -402,10 +407,37 @@ providePopperContentContext({
}"
>
<Primitive
v-if="props.memoDependencies"
:ref="forwardRef"
v-memo="[
props.asChild,
props.as,
placedSide,
placedAlign,
isPositioned,
...Object.values($attrs),
...props.memoDependencies,
]"
v-bind="$attrs"
:as-child="props.asChild"
:as="props.as"
:data-side="placedSide"
:data-align="placedAlign"
:style="{
// if the PopperContent hasn't been placed yet (not all measurements done)
// we prevent animations so that users's animation don't kick in too early referring wrong sides
animation: !isPositioned ? 'none' : undefined,
}"
>
<slot />
</Primitive>

<Primitive
v-else
:ref="forwardRef"
v-bind="$attrs"
:as-child="props.asChild"
:as="as"
:as="props.as"
:data-side="placedSide"
:data-align="placedAlign"
:style="{
Expand Down
Loading