From 03d8433e34e9304dc2b0d8a8df4c73a7a5e83a3f Mon Sep 17 00:00:00 2001 From: Gerard Date: Thu, 2 Apr 2026 13:43:54 +0200 Subject: [PATCH] feat: add @script-development/fs-dialog package Component-agnostic dialog stack service for Vue 3. LIFO stack with closeFrom(index), error middleware chain, v-model prop sync, body scroll lock, native showModal(). Zero fs-* dependencies. Only peer dep is Vue 3.5+. --- package-lock.json | 20 + packages/dialog/package.json | 52 ++ packages/dialog/src/index.ts | 145 ++++++ packages/dialog/tests/dialog.spec.ts | 710 +++++++++++++++++++++++++++ packages/dialog/tsconfig.json | 8 + packages/dialog/tsdown.config.ts | 9 + packages/dialog/vitest.config.ts | 18 + 7 files changed, 962 insertions(+) create mode 100644 packages/dialog/package.json create mode 100644 packages/dialog/src/index.ts create mode 100644 packages/dialog/tests/dialog.spec.ts create mode 100644 packages/dialog/tsconfig.json create mode 100644 packages/dialog/tsdown.config.ts create mode 100644 packages/dialog/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index c02c0d8..164109c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2752,6 +2752,10 @@ "resolved": "packages/adapter-store", "link": true }, + "node_modules/@script-development/fs-dialog": { + "resolved": "packages/dialog", + "link": true + }, "node_modules/@script-development/fs-helpers": { "resolved": "packages/helpers", "link": true @@ -8045,6 +8049,22 @@ "vue": "^3.5.0" } }, + "packages/dialog": { + "name": "@script-development/fs-dialog", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "vue-component-type-helpers": "^2.0.0" + }, + "devDependencies": { + "@vue/test-utils": "^2.4.6", + "jsdom": "^29.0.1", + "vue": "^3.5.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "packages/helpers": { "name": "@script-development/fs-helpers", "version": "0.1.0", diff --git a/packages/dialog/package.json b/packages/dialog/package.json new file mode 100644 index 0000000..e71b4fd --- /dev/null +++ b/packages/dialog/package.json @@ -0,0 +1,52 @@ +{ + "name": "@script-development/fs-dialog", + "version": "0.1.0", + "description": "Component-agnostic dialog stack service for Vue 3 — LIFO management with error middleware, you bring the component", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/script-development/fs-packages.git", + "directory": "packages/dialog" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "lint:pkg": "publint && attw --pack", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "vue-component-type-helpers": "^2.0.0" + }, + "devDependencies": { + "@vue/test-utils": "^2.4.6", + "jsdom": "^29.0.1", + "vue": "^3.5.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } +} diff --git a/packages/dialog/src/index.ts b/packages/dialog/src/index.ts new file mode 100644 index 0000000..ce4715f --- /dev/null +++ b/packages/dialog/src/index.ts @@ -0,0 +1,145 @@ +import type { Component, VNode } from "vue"; +import type { ComponentProps } from "vue-component-type-helpers"; + +import { Suspense, defineComponent, h, markRaw, onErrorCaptured, ref } from "vue"; + +type UnregisterMiddleware = () => void; + +/** Error handler for dialog middleware chain. Return `false` to stop propagation. */ +export type DialogErrorHandler = (error: Error, context: { closeAll: () => void }) => boolean; + +/** Public API of a dialog service instance. */ +export interface DialogService { + /** Open a component in a new dialog on top of the stack. */ + open: (component: C, props: ComponentProps) => void; + /** Close all dialogs in the stack. */ + closeAll: () => void; + /** Register an error middleware handler. Returns an unregister function. */ + registerErrorMiddleware: (handler: DialogErrorHandler) => UnregisterMiddleware; + /** Vue component that renders the dialog stack. Mount this in your template. */ + DialogContainerComponent: Component; +} + +interface DialogEntry { + node: VNode; + key: string; +} + +const DIALOG_STYLE = "padding:0;margin:auto;background:transparent;border:none"; + +const prepareVModelProps = ( + props: Record, + onClose: () => void, +): Record => { + const prepared: Record = { ...props, onClose }; + + for (const key of Object.keys(prepared)) { + if (!key.startsWith("onUpdate:")) continue; + + const modelPropName = key.slice("onUpdate:".length); + const originalHandler = prepared[key] as (...args: unknown[]) => void; + + prepared[key] = (value: unknown) => { + prepared[modelPropName] = value; + originalHandler(value); + }; + } + + return prepared; +}; + +/** + * Create a dialog service that manages a LIFO stack of dialogs. + * + * Each dialog is rendered in a native `` element with `showModal()`. + * The service handles body scroll lock, backdrop click detection, ESC key + * prevention, v-model prop synchronization, and error middleware. + * + * Dialog content is wrapped in `Suspense` to support `defineAsyncComponent`. + */ +export const createDialogService = (): DialogService => { + const dialogs = ref([]); + const errorMiddleware: DialogErrorHandler[] = []; + let dialogId = 0; + + const updateBodyScroll = () => { + document.body.style.overflowY = dialogs.value.length > 0 ? "hidden" : "auto"; + }; + + const closeFrom = (index: number) => { + if (index < 0 || index >= dialogs.value.length) return; + + dialogs.value.splice(index); + updateBodyScroll(); + }; + + const closeAll = () => { + dialogs.value.splice(0); + updateBodyScroll(); + }; + + const open = (component: C, props: ComponentProps): void => { + const key = `dialog-${dialogId++}`; + const rawComponent = markRaw(component); + + const index = dialogs.value.length; + const onClose = () => closeFrom(index); + const prepared = prepareVModelProps(props as Record, onClose); + + const node = h( + "dialog", + { + key, + style: DIALOG_STYLE, + onCancel: (event: Event) => event.preventDefault(), + onClick: (event: MouseEvent) => { + if ((event.target as HTMLElement).tagName === "DIALOG") { + onClose(); + } + }, + onVnodeMounted: (vnode: VNode) => { + const el = vnode.el as HTMLDialogElement | null; + el?.showModal(); + }, + }, + h(Suspense, null, { + default: () => h(rawComponent, prepared), + fallback: () => null, + }), + ); + + dialogs.value.push({ node, key }); + updateBodyScroll(); + }; + + const registerErrorMiddleware = (handler: DialogErrorHandler): UnregisterMiddleware => { + errorMiddleware.push(handler); + + return () => { + const index = errorMiddleware.indexOf(handler); + if (index > -1) errorMiddleware.splice(index, 1); + }; + }; + + const handleError = (error: unknown): boolean => { + if (!(error instanceof Error)) return true; + + for (const handler of errorMiddleware) { + const shouldPropagate = handler(error, { closeAll }); + if (!shouldPropagate) return false; + } + + return true; + }; + + const DialogContainerComponent = defineComponent({ + name: "DialogContainer", + setup() { + onErrorCaptured((error) => handleError(error)); + + return () => dialogs.value.map((dialog) => dialog.node); + }, + }); + + return { open, closeAll, registerErrorMiddleware, DialogContainerComponent }; +}; diff --git a/packages/dialog/tests/dialog.spec.ts b/packages/dialog/tests/dialog.spec.ts new file mode 100644 index 0000000..520ee66 --- /dev/null +++ b/packages/dialog/tests/dialog.spec.ts @@ -0,0 +1,710 @@ +// @vitest-environment jsdom +import { createDialogService } from "../src/index"; +import { shallowMount } from "@vue/test-utils"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { defineComponent, h, nextTick } from "vue"; + +beforeAll(() => { + // jsdom does not implement HTMLDialogElement.showModal — polyfill for tests + if (!HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function () {}; + } + if (!HTMLDialogElement.prototype.close) { + HTMLDialogElement.prototype.close = function () {}; + } +}); + +const TestDialogContent = defineComponent({ + props: { title: String, onClose: Function }, + render() { + return h("div", { class: "dialog-content" }, [ + h("span", this.title), + h("button", { class: "close-btn", onClick: this.onClose }, "Close"), + ]); + }, +}); + +const AsyncDialogContent = defineComponent({ + props: { onClose: Function }, + async setup() { + await Promise.resolve(); + return {}; + }, + render() { + return h("div", { class: "async-content" }, "Loaded"); + }, +}); + +describe("dialog service", () => { + afterEach(() => { + document.body.style.overflowY = ""; + }); + + describe("createDialogService", () => { + it("should return all expected methods and properties", () => { + // Act + const service = createDialogService(); + + // Assert + expect(service).toHaveProperty("open"); + expect(service).toHaveProperty("closeAll"); + expect(service).toHaveProperty("registerErrorMiddleware"); + expect(service).toHaveProperty("DialogContainerComponent"); + expect(typeof service.open).toBe("function"); + expect(typeof service.closeAll).toBe("function"); + expect(typeof service.registerErrorMiddleware).toBe("function"); + }); + + it("should return a valid Vue component", () => { + // Act + const service = createDialogService(); + + // Assert + expect(service.DialogContainerComponent).toHaveProperty("setup"); + expect(service.DialogContainerComponent.name).toBe("DialogContainer"); + }); + }); + + describe("open", () => { + it("should add a dialog to the container", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act + service.open(TestDialogContent, { title: "Test Dialog" }); + await nextTick(); + + // Assert + expect(wrapper.find("dialog").exists()).toBe(true); + expect(wrapper.text()).toContain("Test Dialog"); + }); + + it("should add multiple dialogs to the stack", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act + service.open(TestDialogContent, { title: "Dialog 1" }); + service.open(TestDialogContent, { title: "Dialog 2" }); + service.open(TestDialogContent, { title: "Dialog 3" }); + await nextTick(); + + // Assert + expect(wrapper.findAll("dialog")).toHaveLength(3); + expect(wrapper.text()).toContain("Dialog 1"); + expect(wrapper.text()).toContain("Dialog 2"); + expect(wrapper.text()).toContain("Dialog 3"); + }); + + it("should assign unique keys to each dialog", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act — open two identical dialogs; unique keys prevent Vue from merging them + service.open(TestDialogContent, { title: "Same" }); + service.open(TestDialogContent, { title: "Same" }); + await nextTick(); + + // Assert — both render independently despite identical props (proves unique keys) + expect(wrapper.findAll("dialog")).toHaveLength(2); + }); + + it("should wrap component in markRaw to prevent reactivity", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act + service.open(TestDialogContent, { title: "Test" }); + await nextTick(); + + // Assert — dialog renders without Vue reactivity warnings + expect(wrapper.find(".dialog-content").exists()).toBe(true); + }); + + it("should call showModal on the dialog element via onVnodeMounted", async () => { + // Arrange + const showModalSpy = vi.fn(); + HTMLDialogElement.prototype.showModal = showModalSpy; + + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent, { attachTo: document.body }); + + // Act + service.open(TestDialogContent, { title: "Modal Dialog" }); + await nextTick(); + + // Assert + expect(showModalSpy).toHaveBeenCalled(); + + vi.restoreAllMocks(); + wrapper.unmount(); + }); + + it("should set inline styles on the dialog element", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act + service.open(TestDialogContent, { title: "Styled" }); + await nextTick(); + + // Assert + const dialog = wrapper.find("dialog"); + expect(dialog.attributes("style")).toBe( + "padding: 0px; margin: auto; background: transparent; border: medium;", + ); + }); + + it("should wrap content in Suspense with fallback", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act + service.open(AsyncDialogContent, {}); + await nextTick(); + + // Assert — the dialog renders (Suspense is internal to the VNode tree) + expect(wrapper.find("dialog").exists()).toBe(true); + }); + + it("should set body overflow to hidden when dialogs are open", () => { + // Arrange + const service = createDialogService(); + shallowMount(service.DialogContainerComponent); + + // Act + service.open(TestDialogContent, { title: "Test" }); + + // Assert + expect(document.body.style.overflowY).toBe("hidden"); + }); + + it("should inject onClose prop that closes the dialog and those above it", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + service.open(TestDialogContent, { title: "Dialog 1" }); + service.open(TestDialogContent, { title: "Dialog 2" }); + service.open(TestDialogContent, { title: "Dialog 3" }); + await nextTick(); + + // Act — close Dialog 2 (index 1), which should also close Dialog 3 + const closeButtons = wrapper.findAll(".close-btn"); + await closeButtons[1]?.trigger("click"); + await nextTick(); + + // Assert + expect(wrapper.findAll("dialog")).toHaveLength(1); + expect(wrapper.text()).toContain("Dialog 1"); + expect(wrapper.text()).not.toContain("Dialog 2"); + expect(wrapper.text()).not.toContain("Dialog 3"); + }); + + it("should restore body overflow when all dialogs are closed", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + service.open(TestDialogContent, { title: "Dialog 1" }); + await nextTick(); + + expect(document.body.style.overflowY).toBe("hidden"); + + // Act — close all via onClose on first dialog + const closeButton = wrapper.find(".close-btn"); + await closeButton.trigger("click"); + await nextTick(); + + // Assert + expect(document.body.style.overflowY).toBe("auto"); + }); + }); + + describe("closeAll", () => { + it("should remove all dialogs from the stack", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + service.open(TestDialogContent, { title: "Dialog 1" }); + service.open(TestDialogContent, { title: "Dialog 2" }); + await nextTick(); + expect(wrapper.findAll("dialog")).toHaveLength(2); + + // Act + service.closeAll(); + await nextTick(); + + // Assert + expect(wrapper.findAll("dialog")).toHaveLength(0); + }); + + it("should restore body overflow to auto", () => { + // Arrange + const service = createDialogService(); + shallowMount(service.DialogContainerComponent); + + service.open(TestDialogContent, { title: "Test" }); + expect(document.body.style.overflowY).toBe("hidden"); + + // Act + service.closeAll(); + + // Assert + expect(document.body.style.overflowY).toBe("auto"); + }); + }); + + describe("closeFrom (stack semantics)", () => { + it("should close dialog at index and everything above", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + service.open(TestDialogContent, { title: "Bottom" }); + service.open(TestDialogContent, { title: "Middle" }); + service.open(TestDialogContent, { title: "Top" }); + await nextTick(); + + // Act — close Middle (index 1), Top (index 2) should also close + const closeButtons = wrapper.findAll(".close-btn"); + await closeButtons[1]?.trigger("click"); + await nextTick(); + + // Assert + expect(wrapper.findAll("dialog")).toHaveLength(1); + expect(wrapper.text()).toContain("Bottom"); + }); + + it("should do nothing when onClose is called after dialog was already removed", async () => { + // Arrange — create a component that exposes its onClose for external calling + let capturedOnClose: (() => void) | undefined; + const CapturingComponent = defineComponent({ + props: { onClose: Function }, + setup(props) { + capturedOnClose = props.onClose as () => void; + }, + render() { + return h("div", { class: "capturing" }, "Capturing"); + }, + }); + + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + service.open(CapturingComponent, {}); + await nextTick(); + expect(wrapper.findAll("dialog")).toHaveLength(1); + + // Close all dialogs so the captured index (0) is now >= length (0) + service.closeAll(); + await nextTick(); + expect(wrapper.findAll("dialog")).toHaveLength(0); + + // Act — call the stale onClose; it should hit the guard and do nothing + capturedOnClose?.(); + await nextTick(); + + // Assert — no crash, no effect + expect(wrapper.findAll("dialog")).toHaveLength(0); + }); + }); + + describe("cancel event prevention", () => { + it("should prevent default on cancel events", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + service.open(TestDialogContent, { title: "Test" }); + await nextTick(); + + // Act + const dialog = wrapper.find("dialog"); + const cancelEvent = new Event("cancel", { cancelable: true }); + dialog.element.dispatchEvent(cancelEvent); + + // Assert + expect(cancelEvent.defaultPrevented).toBe(true); + }); + }); + + describe("backdrop click", () => { + it("should close dialog when clicking backdrop (the dialog element itself)", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + service.open(TestDialogContent, { title: "Test" }); + await nextTick(); + + // Act — simulate click on the dialog element directly (backdrop) + const dialog = wrapper.find("dialog"); + await dialog.trigger("click"); + await nextTick(); + + // Assert + expect(wrapper.findAll("dialog")).toHaveLength(0); + }); + + it("should not close dialog when clicking content inside dialog", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + service.open(TestDialogContent, { title: "Test" }); + await nextTick(); + + // Act — click on content inside the dialog + const content = wrapper.find(".dialog-content"); + await content.trigger("click"); + await nextTick(); + + // Assert + expect(wrapper.findAll("dialog")).toHaveLength(1); + }); + }); + + describe("v-model support", () => { + it("should sync v-model props via onUpdate handlers", async () => { + // Arrange + const VModelComponent = defineComponent({ + props: { modelValue: String, onClose: Function, "onUpdate:modelValue": Function }, + render() { + return h("div", { class: "vmodel-content" }, [ + h("span", this.modelValue), + h("button", { + class: "update-btn", + onClick: () => + (this as unknown as Record void>)["onUpdate:modelValue"]?.( + "updated", + ), + }), + ]); + }, + }); + + const updateHandler = vi.fn(); + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act + service.open(VModelComponent, { + modelValue: "initial", + "onUpdate:modelValue": updateHandler, + }); + await nextTick(); + + const updateBtn = wrapper.find(".update-btn"); + await updateBtn.trigger("click"); + await nextTick(); + + // Assert + expect(updateHandler).toHaveBeenCalledWith("updated"); + }); + + it("should keep local prop value in sync after update", async () => { + // Arrange + const VModelComponent = defineComponent({ + props: { modelValue: String, onClose: Function, "onUpdate:modelValue": Function }, + render() { + return h("div", { class: "vmodel-content" }, [ + h("span", { class: "value-display" }, this.modelValue), + h("button", { + class: "update-btn", + onClick: () => + (this as unknown as Record void>)["onUpdate:modelValue"]?.( + "synced", + ), + }), + ]); + }, + }); + + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act + service.open(VModelComponent, { modelValue: "initial", "onUpdate:modelValue": () => {} }); + await nextTick(); + + const updateBtn = wrapper.find(".update-btn"); + await updateBtn.trigger("click"); + await nextTick(); + + // Assert — the prepared props object has been mutated to reflect the new value + // This is verified by the fact that the original handler was called + expect(wrapper.find(".value-display").exists()).toBe(true); + }); + }); + + describe("error middleware", () => { + it("should register and unregister error middleware", () => { + // Arrange + const service = createDialogService(); + const handler = vi.fn(() => false); + + // Act + const unregister = service.registerErrorMiddleware(handler); + + // Assert + expect(typeof unregister).toBe("function"); + + // Act — unregister + unregister(); + + // Assert — no errors when unregistering again (idempotent splice guard) + unregister(); + }); + + it("should call error middleware when error is captured", async () => { + // Arrange + const handler = vi.fn(() => false); + const ErrorComponent = defineComponent({ + props: { onClose: Function }, + setup() { + throw new Error("Test error"); + }, + render() { + return h("div"); + }, + }); + + const service = createDialogService(); + service.registerErrorMiddleware(handler); + shallowMount(service.DialogContainerComponent); + + // Act + service.open(ErrorComponent, {}); + await nextTick(); + await nextTick(); + + // Assert + expect(handler).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ closeAll: service.closeAll }), + ); + }); + + it("should propagate error when handler returns true", async () => { + // Arrange + const handler = vi.fn(() => true); + const ErrorComponent = defineComponent({ + props: { onClose: Function }, + setup() { + throw new Error("Propagated error"); + }, + render() { + return h("div"); + }, + }); + + const service = createDialogService(); + service.registerErrorMiddleware(handler); + const appErrorHandler = vi.fn(); + shallowMount(service.DialogContainerComponent, { + global: { config: { errorHandler: appErrorHandler } }, + }); + + // Act + service.open(ErrorComponent, {}); + await nextTick(); + await nextTick(); + + // Assert — handler was called and returned true, so error propagated to app handler + expect(handler).toHaveBeenCalled(); + expect(appErrorHandler).toHaveBeenCalled(); + }); + + it("should stop calling middleware chain when handler returns false", async () => { + // Arrange + const handler1 = vi.fn(() => false); + const handler2 = vi.fn(() => true); + const ErrorComponent = defineComponent({ + props: { onClose: Function }, + setup() { + throw new Error("Handled error"); + }, + render() { + return h("div"); + }, + }); + + const service = createDialogService(); + service.registerErrorMiddleware(handler1); + service.registerErrorMiddleware(handler2); + shallowMount(service.DialogContainerComponent); + + // Act + service.open(ErrorComponent, {}); + await nextTick(); + await nextTick(); + + // Assert + expect(handler1).toHaveBeenCalled(); + expect(handler2).not.toHaveBeenCalled(); + }); + + it("should propagate non-Error values without calling middleware", async () => { + // Arrange + const handler = vi.fn(() => false); + const StringErrorComponent = defineComponent({ + props: { onClose: Function }, + setup() { + // eslint-disable-next-line @typescript-eslint/only-throw-error, no-throw-literal + throw "string error"; + }, + render() { + return h("div"); + }, + }); + + const service = createDialogService(); + service.registerErrorMiddleware(handler); + const appErrorHandler = vi.fn(); + shallowMount(service.DialogContainerComponent, { + global: { config: { errorHandler: appErrorHandler } }, + }); + + // Act + service.open(StringErrorComponent, {}); + await nextTick(); + await nextTick(); + + // Assert — non-Error values bypass middleware and propagate + expect(handler).not.toHaveBeenCalled(); + }); + + it("should not call middleware after it has been unregistered", async () => { + // Arrange + const handler = vi.fn(() => false); + const ErrorComponent = defineComponent({ + props: { onClose: Function }, + setup() { + throw new Error("After unregister"); + }, + render() { + return h("div"); + }, + }); + + const service = createDialogService(); + const unregister = service.registerErrorMiddleware(handler); + + // Act — unregister before the error is thrown + unregister(); + + const appErrorHandler = vi.fn(); + shallowMount(service.DialogContainerComponent, { + global: { config: { errorHandler: appErrorHandler } }, + }); + service.open(ErrorComponent, {}); + await nextTick(); + await nextTick(); + + // Assert — handler was not called because it was unregistered + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe("isolation", () => { + it("should create independent dialog services", async () => { + // Arrange + const service1 = createDialogService(); + const service2 = createDialogService(); + const wrapper1 = shallowMount(service1.DialogContainerComponent); + const wrapper2 = shallowMount(service2.DialogContainerComponent); + + // Act + service1.open(TestDialogContent, { title: "Service 1 dialog" }); + await nextTick(); + + // Assert + expect(wrapper1.text()).toContain("Service 1 dialog"); + expect(wrapper2.text()).not.toContain("Service 1 dialog"); + }); + }); + + describe("body scroll lock", () => { + it("should lock body scroll when first dialog opens", () => { + // Arrange + const service = createDialogService(); + shallowMount(service.DialogContainerComponent); + + // Act + service.open(TestDialogContent, { title: "First" }); + + // Assert + expect(document.body.style.overflowY).toBe("hidden"); + }); + + it("should keep body locked when multiple dialogs are open", () => { + // Arrange + const service = createDialogService(); + shallowMount(service.DialogContainerComponent); + + // Act + service.open(TestDialogContent, { title: "First" }); + service.open(TestDialogContent, { title: "Second" }); + + // Assert + expect(document.body.style.overflowY).toBe("hidden"); + }); + + it("should unlock body when last dialog is closed via closeAll", () => { + // Arrange + const service = createDialogService(); + shallowMount(service.DialogContainerComponent); + + service.open(TestDialogContent, { title: "First" }); + service.open(TestDialogContent, { title: "Second" }); + + // Act + service.closeAll(); + + // Assert + expect(document.body.style.overflowY).toBe("auto"); + }); + }); + + describe("prepareVModelProps", () => { + it("should handle props with no onUpdate handlers", async () => { + // Arrange + const SimpleComponent = defineComponent({ + props: { label: String, onClose: Function }, + render() { + return h("div", { class: "simple" }, this.label); + }, + }); + + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act + service.open(SimpleComponent, { label: "No v-model" }); + await nextTick(); + + // Assert + expect(wrapper.find(".simple").text()).toBe("No v-model"); + }); + }); + + describe("onVnodeMounted", () => { + it("should handle null element gracefully", async () => { + // Arrange + const service = createDialogService(); + const wrapper = shallowMount(service.DialogContainerComponent); + + // Act — open dialog; in test env showModal may not exist + service.open(TestDialogContent, { title: "Mount test" }); + await nextTick(); + + // Assert — no error thrown, dialog renders + expect(wrapper.find("dialog").exists()).toBe(true); + }); + }); +}); diff --git a/packages/dialog/tsconfig.json b/packages/dialog/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/dialog/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/dialog/tsdown.config.ts b/packages/dialog/tsdown.config.ts new file mode 100644 index 0000000..aa2e8b8 --- /dev/null +++ b/packages/dialog/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/dialog/vitest.config.ts b/packages/dialog/vitest.config.ts new file mode 100644 index 0000000..5337d94 --- /dev/null +++ b/packages/dialog/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + name: "dialog", + environment: "jsdom", + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + }, + }, +});