diff --git a/packages/core/src/Combobox/Combobox.test.ts b/packages/core/src/Combobox/Combobox.test.ts index a02fc7b94..874ba4be6 100644 --- a/packages/core/src/Combobox/Combobox.test.ts +++ b/packages/core/src/Combobox/Combobox.test.ts @@ -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' @@ -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') + }) +}) diff --git a/packages/core/src/Combobox/ComboboxContentImpl.vue b/packages/core/src/Combobox/ComboboxContentImpl.vue index ab6f1ee47..966ad12d7 100644 --- a/packages/core/src/Combobox/ComboboxContentImpl.vue +++ b/packages/core/src/Combobox/ComboboxContentImpl.vue @@ -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" :data-state="rootContext.open.value ? 'open' : 'closed'" :data-empty="isEmpty ? '' : undefined" :style="{ diff --git a/packages/core/src/Combobox/ComboboxItem.vue b/packages/core/src/Combobox/ComboboxItem.vue index df40d116c..a39daf2bd 100644 --- a/packages/core/src/Combobox/ComboboxItem.vue +++ b/packages/core/src/Combobox/ComboboxItem.vue @@ -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) diff --git a/packages/core/src/Popper/PopperContent.vue b/packages/core/src/Popper/PopperContent.vue index b7e7b1623..0a6cc2b03 100644 --- a/packages/core/src/Popper/PopperContent.vue +++ b/packages/core/src/Popper/PopperContent.vue @@ -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 @@ -402,10 +407,37 @@ providePopperContentContext({ }" > + + + +