Skip to content
Merged
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
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions packages/dialog/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
145 changes: 145 additions & 0 deletions packages/dialog/src/index.ts
Original file line number Diff line number Diff line change
@@ -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: <C extends Component>(component: C, props: ComponentProps<C>) => 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<string, unknown>,
onClose: () => void,
): Record<string, unknown> => {
const prepared: Record<string, unknown> = { ...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 `<dialog>` 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<DialogEntry[]>([]);
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 = <C extends Component>(component: C, props: ComponentProps<C>): void => {
const key = `dialog-${dialogId++}`;
const rawComponent = markRaw(component);

const index = dialogs.value.length;
const onClose = () => closeFrom(index);
const prepared = prepareVModelProps(props as Record<string, unknown>, 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 };
};
Loading