From bf0d2274c4297c2d5ca233281430f0c5c6cc4ddd Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER Date: Thu, 16 Oct 2025 00:54:57 +0200 Subject: [PATCH 1/4] refactor: implement event handler system for overlay components with type-safe emits --- src/runtime/components/OverlayProvider.vue | 6 +- src/runtime/composables/useOverlay.ts | 72 ++++++- .../useOverlay.integration.spec.ts | 193 ++++++++++++++++++ test/composables/useOverlay.spec.ts | 31 ++- test/mocks/MockModal.vue | 10 + 5 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 test/composables/useOverlay.integration.spec.ts diff --git a/src/runtime/components/OverlayProvider.vue b/src/runtime/components/OverlayProvider.vue index 49fcc7fd71..4606aa5536 100644 --- a/src/runtime/components/OverlayProvider.vue +++ b/src/runtime/components/OverlayProvider.vue @@ -11,10 +11,6 @@ const onAfterLeave = (id: symbol) => { close(id) unmount(id) } - -const onClose = (id: symbol, value: any) => { - close(id, value) -} diff --git a/src/runtime/composables/useOverlay.ts b/src/runtime/composables/useOverlay.ts index 0e8e77b96a..23ae68c9f8 100644 --- a/src/runtime/composables/useOverlay.ts +++ b/src/runtime/composables/useOverlay.ts @@ -3,6 +3,45 @@ import { reactive, markRaw, shallowReactive } from 'vue' import { createSharedComposable } from '@vueuse/core' import type { ComponentProps, ComponentEmit } from 'vue-component-type-helpers' +/** + * Workaround for TypeScript limitation with overloaded functions in conditional types. + * + * TypeScript's conditional types infer from the last overload only when matching overloaded functions. + * These utilities extract the union of all event names and their corresponding arguments from ComponentEmit's overloads. + * + * @see https://github.com/microsoft/TypeScript/issues/32164 + */ +type OverloadUnion = T extends { + (...args: infer A1): any + (...args: infer A2): any + (...args: infer A3): any + (...args: infer A4): any +} ? A1[0] | A2[0] | A3[0] | A4[0] : T extends { + (...args: infer A1): any + (...args: infer A2): any + (...args: infer A3): any +} ? A1[0] | A2[0] | A3[0] : T extends { + (...args: infer A1): any + (...args: infer A2): any +} ? A1[0] | A2[0] : T extends (...args: infer A1) => any ? A1[0] : never + +type OverloadArgs = T extends { + (event: K, ...args: infer A1): any + (...args: any[]): any + (...args: any[]): any + (...args: any[]): any +} ? A1 : T extends { + (event: K, ...args: infer A1): any + (...args: any[]): any + (...args: any[]): any + } ? A1 : T extends { + (event: K, ...args: infer A1): any + (...args: any[]): any + } ? A1 : T extends (event: K, ...args: infer A1) => any ? A1 : never + +type EmitKeys = OverloadUnion> +type EmitArgs = OverloadArgs, K> + /** * This is a workaround for a design limitation in TypeScript. * @@ -33,7 +72,7 @@ type CloseEventArgType = T extends { } ? Arg : never export type OverlayOptions> = { defaultOpen?: boolean - props?: OverlayAttrs + props?: Partial destroyOnClose?: boolean } @@ -42,19 +81,21 @@ interface ManagedOverlayOptionsPrivate { id: symbol isMounted: boolean isOpen: boolean + emits?: Record unknown> originalProps?: ComponentProps resolvePromise?: (value: any) => void } export type Overlay = OverlayOptions & ManagedOverlayOptionsPrivate -type OverlayInstance = Omit, 'component'> & { +type OverlayInstance = Omit, 'component' | 'emits'> & { id: symbol open: (props?: ComponentProps) => OpenedOverlay close: (value?: any) => void patch: (props: Partial>) => void + on>(event: K, callback: (...args: EmitArgs) => void): void } -type OpenedOverlay = Omit, 'open' | 'close' | 'patch' | 'modelValue' | 'resolvePromise'> & { +type OpenedOverlay = Omit, 'open' | 'close' | 'patch' | 'modelValue' | 'resolvePromise' | 'on'> & { result: Promise>> } & Promise>> @@ -64,13 +105,18 @@ function _useOverlay() { const create = (component: T, _options?: OverlayOptions>): OverlayInstance => { const { props, defaultOpen, destroyOnClose } = _options || {} + const id = Symbol(import.meta.dev ? 'useOverlay' : '') + const options = reactive({ - id: Symbol(import.meta.dev ? 'useOverlay' : ''), + id, isOpen: !!defaultOpen, component: markRaw(component!), isMounted: !!defaultOpen, destroyOnClose: !!destroyOnClose, originalProps: props || {}, + emits: { + close: (value: unknown) => close(id, value) + }, props: { ...props } }) @@ -80,7 +126,8 @@ function _useOverlay() { ...options, open: (props?: ComponentProps) => open(options.id, props), close: value => close(options.id, value), - patch: (props: Partial>) => patch(options.id, props) + patch: (props: Partial>) => patch(options.id, props), + on: >(event: K, callback: (...args: EmitArgs) => void): void => on(options.id, event, callback) } } @@ -109,6 +156,7 @@ function _useOverlay() { const close = (id: symbol, value?: any): void => { const overlay = getOverlay(id) + console.log('close', id) overlay.isOpen = false // Resolve the promise if it exists @@ -155,12 +203,24 @@ function _useOverlay() { return overlay.isOpen } + function on>( + id: symbol, + event: K, + callback: (...args: EmitArgs) => void + ): void { + const overlay = getOverlay(id) + + if (!overlay.emits) overlay.emits = {} + overlay.emits[event as string] = callback + } + return { overlays, + create, + on, open, close, closeAll, - create, patch, unmount, isOpen diff --git a/test/composables/useOverlay.integration.spec.ts b/test/composables/useOverlay.integration.spec.ts new file mode 100644 index 0000000000..550afca966 --- /dev/null +++ b/test/composables/useOverlay.integration.spec.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, nextTick } from 'vue' +import { useOverlay } from '../../src/runtime/composables/useOverlay' +import OverlayProvider from '../../src/runtime/components/OverlayProvider.vue' +import MockModal from '../mocks/MockModal.vue' + +describe('useOverlay with OverlayProvider integration', () => { + it('should render overlay component and handle emits', async () => { + const closeSpy = vi.fn() + const submitSpy = vi.fn() + + const TestWrapper = defineComponent({ + setup() { + const { create } = useOverlay() + const modal = create(MockModal, { + props: { + title: 'Test Title', + description: 'Test Description' + } + }) + + // Register event handlers + modal.on('close', (value) => { + closeSpy(value) + modal.close(value) + }) + + modal.on('submit', (id) => { + submitSpy(id) + }) + + // Open the modal + modal.open() + + return () => h(OverlayProvider) + } + }) + + const wrapper = mount(TestWrapper) + await nextTick() + + // Check that the modal is rendered + expect(wrapper.text()).toContain('Test Title') + expect(wrapper.text()).toContain('Test Description') + + // Find and click the close button + const closeButton = wrapper.find('button.close') + expect(closeButton.text()).toBe('Close') + await closeButton.trigger('click') + + // Check that close event was called with correct value + expect(closeSpy).toHaveBeenCalledWith('test-result') + + // Find and click the submit button + const submitButton = wrapper.find('button.submit') + expect(submitButton.text()).toBe('Submit') + await submitButton.trigger('click') + + // Check that submit event was called with correct value + expect(submitSpy).toHaveBeenCalledWith(42) + }) + + it('should handle multiple overlays', async () => { + const TestWrapper = defineComponent({ + setup() { + const { create } = useOverlay() + + const modal1 = create(MockModal, { + props: { title: 'Modal 1' } + }) + + const modal2 = create(MockModal, { + props: { title: 'Modal 2' } + }) + + modal1.open() + modal2.open() + + return () => h(OverlayProvider) + } + }) + + const wrapper = mount(TestWrapper) + await nextTick() + + // Both modals should be rendered + expect(wrapper.text()).toContain('Modal 1') + expect(wrapper.text()).toContain('Modal 2') + }) + + it('should update props dynamically', async () => { + const TestWrapper = defineComponent({ + setup() { + const { create } = useOverlay() + const modal = create(MockModal, { + props: { title: 'Initial Title' } + }) + + modal.open() + + // Update props after opening + setTimeout(() => { + modal.patch({ title: 'Updated Title' }) + }, 10) + + return () => h(OverlayProvider) + } + }) + + const wrapper = mount(TestWrapper) + await nextTick() + + expect(wrapper.text()).toContain('Initial Title') + + // Wait for patch to be applied + await new Promise(resolve => setTimeout(resolve, 20)) + await nextTick() + + expect(wrapper.text()).toContain('Updated Title') + }) + + it('should handle async close with result', async () => { + const resultSpy = vi.fn() + const TestWrapper = defineComponent({ + setup() { + const { create } = useOverlay() + const modal = create(MockModal) + + modal.on('close', (value) => { + modal.close(value) + }) + + const openedModal = modal.open() + + // Test the promise result + openedModal.then((result) => { + resultSpy(result) + }) + + modal.close('test-result') + + return () => h(OverlayProvider) + } + }) + + const wrapper = mount(TestWrapper) + await nextTick() + + // Click close button to trigger the close event + const closeButton = wrapper.find('button.close') + await closeButton.trigger('click') + await nextTick() + + expect(resultSpy).toHaveBeenCalledWith('test-result') + }) + + it('should type check event arguments correctly', async () => { + const TestWrapper = defineComponent({ + setup() { + const { create } = useOverlay() + const modal = create(MockModal) + + // These should type check correctly + modal.on('close', (value) => { + // value should be string + const uppercased: string = value.toUpperCase() + expect(typeof uppercased).toBe('string') + }) + + modal.on('submit', (id) => { + // id should be number + const doubled: number = id * 2 + expect(typeof doubled).toBe('number') + }) + + modal.open() + + return () => h(OverlayProvider) + } + }) + + const wrapper = mount(TestWrapper) + await nextTick() + + // Trigger events to run the type-checked callbacks + const closeButton = wrapper.find('button.close') + await closeButton.trigger('click') + + const submitButton = wrapper.find('button.submit') + await submitButton.trigger('click') + }) +}) diff --git a/test/composables/useOverlay.spec.ts b/test/composables/useOverlay.spec.ts index bbede7e56d..174971ace2 100644 --- a/test/composables/useOverlay.spec.ts +++ b/test/composables/useOverlay.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { useOverlay } from '../../src/runtime/composables/useOverlay' import MockModal from '../mocks/MockModal.vue' @@ -167,4 +167,33 @@ describe('useOverlay', () => { expect(getModalById(overlay.overlays, modal.id).isOpen).toBe(false) }) + + describe('listen to emits', () => { + it('should listen to emits', () => { + const modal = overlay.create(MockModal) + modal.open({ + onClose() { + modal.close() + } + }) + const spyCall = vi.fn() + modal.on('close', (value) => { + value.toLowerCase() + spyCall(value) + }) + modal.on('submit', (value) => { + value.toFixed(2) + spyCall(value) + }) + modal.on('refresh', (value) => { + value.join() + spyCall(value) + }) + + // Check that emits is set + const overlayData = getModalById(overlay.overlays, modal.id) + expect(overlayData.emits).toHaveProperty('close') + expect(typeof overlayData.emits!.close).toBe('function') + }) + }) }) diff --git a/test/mocks/MockModal.vue b/test/mocks/MockModal.vue index 9845e5daf5..3709875bcb 100644 --- a/test/mocks/MockModal.vue +++ b/test/mocks/MockModal.vue @@ -3,10 +3,18 @@

{{ title }}

{{ description }}

+ @@ -17,6 +25,8 @@ const { title = 'Test Modal', description = 'Test Description' } = defineProps<{ }>() defineEmits<{ + refresh: [ids: number[]] + submit: [id: number] close: [value: string] }>() From 66f9282d3d2bd1969b3ed71c0e939b50dbe889dc Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER Date: Thu, 16 Oct 2025 01:11:26 +0200 Subject: [PATCH 2/4] docs: add event listener documentation for overlay component --- .../content/docs/3.composables/use-overlay.md | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/3.composables/use-overlay.md b/docs/content/docs/3.composables/use-overlay.md index 16e49abe1f..a26ac14e75 100644 --- a/docs/content/docs/3.composables/use-overlay.md +++ b/docs/content/docs/3.composables/use-overlay.md @@ -68,6 +68,26 @@ Create an overlay, and return a factory instance. :: :: +### on() + +`on(event: string, callback: (value: unknown) => void): void`{lang="ts-type"} + +#### Parameters + +::field-group + ::field{name="event" type="string" required} + The event to listen to. + :: + + ::field{name="callback" type="(value: unknown) => void" required} + The callback to invoke when the event is emitted. + :: +:: + +::warning +Emits and its callback arguments are infer from component but it's not working for component which have more than 5 emits. +:: + ### open() `open(id: symbol, props?: ComponentProps): OpenedOverlay`{lang="ts-type"} @@ -252,15 +272,27 @@ const modalB = overlay.create(ModalB) const slideoverA = overlay.create(SlideoverA) +modalB.on('close', (value) => { + console.log(value) +}) + const openModalA = () => { // Open modalA, but override the title prop modalA.open({ title: 'Hello' }) } const openModalB = async () => { - // Open modalB, and wait for its result + // Open modalB const modalBInstance = modalB.open() + /// Open the slideover when modalB closes + /// - Using the callback argument of the `on` method + modalB.on('close', (input) => { + slideoverA.open({ input }) + }) + + /// - Using the promise returned by `open` + // Wait for opening result (resolved automatically when the overlay is closed) const input = await modalBInstance // Pass the result from modalB to the slideover, and open it From 3b127c8b2b6ef77a910b12cb036d9a781057d830 Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER Date: Mon, 20 Oct 2025 08:02:14 +0000 Subject: [PATCH 3/4] docs: update docs --- .../content/docs/3.composables/use-overlay.md | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/3.composables/use-overlay.md b/docs/content/docs/3.composables/use-overlay.md index a26ac14e75..487b5668d4 100644 --- a/docs/content/docs/3.composables/use-overlay.md +++ b/docs/content/docs/3.composables/use-overlay.md @@ -178,6 +178,43 @@ In-memory list of all overlays that were created. ## Instance API +### on() + +`on(event: string, callback: (value: unknown) => void): void`{lang="ts-type"} + +Listen to overlay's component emits. + +#### Parameters + +::field-group + ::field{name="event" type="string" required} + The event to listen to. + :: + + ::field{name="callback" type="(value: unknown) => void" required} + The callback to invoke when the event is emitted. + :: +:: + +```vue + +``` + ### open() `open(props?: ComponentProps): Promise>`{lang="ts-type"} @@ -272,7 +309,7 @@ const modalB = overlay.create(ModalB) const slideoverA = overlay.create(SlideoverA) -modalB.on('close', (value) => { +modalA.on('close', (value) => { console.log(value) }) @@ -329,3 +366,9 @@ const modal = overlay.create(LazyModalExample, { }) ``` + +### Limitation for components with more than 5 emits + +Because of TypeScript limitation for infer overloaded functions in conditional types (cf: https://github.com/microsoft/TypeScript/issues/32164). + +Because of this limitation, you could expect typing issues for components with more than 5 defined emits. This limitation is set 5 as we use a trick to make work for most cases. \ No newline at end of file From f7a2b45c4855542b6b48cbe74d7aa33d821c8f9e Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER Date: Tue, 21 Oct 2025 19:01:15 +0200 Subject: [PATCH 4/4] Update src/runtime/composables/useOverlay.ts Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- src/runtime/composables/useOverlay.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/runtime/composables/useOverlay.ts b/src/runtime/composables/useOverlay.ts index 23ae68c9f8..2327897e6a 100644 --- a/src/runtime/composables/useOverlay.ts +++ b/src/runtime/composables/useOverlay.ts @@ -156,7 +156,6 @@ function _useOverlay() { const close = (id: symbol, value?: any): void => { const overlay = getOverlay(id) - console.log('close', id) overlay.isOpen = false // Resolve the promise if it exists