Skip to content

Commit 03d8433

Browse files
committed
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 <dialog> showModal(). Zero fs-* dependencies. Only peer dep is Vue 3.5+.
1 parent 67ee992 commit 03d8433

7 files changed

Lines changed: 962 additions & 0 deletions

File tree

package-lock.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dialog/package.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "@script-development/fs-dialog",
3+
"version": "0.1.0",
4+
"description": "Component-agnostic dialog stack service for Vue 3 — LIFO management with error middleware, you bring the component",
5+
"license": "UNLICENSED",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/script-development/fs-packages.git",
9+
"directory": "packages/dialog"
10+
},
11+
"files": [
12+
"dist"
13+
],
14+
"type": "module",
15+
"main": "./dist/index.cjs",
16+
"module": "./dist/index.mjs",
17+
"types": "./dist/index.d.mts",
18+
"exports": {
19+
".": {
20+
"import": {
21+
"types": "./dist/index.d.mts",
22+
"default": "./dist/index.mjs"
23+
},
24+
"require": {
25+
"types": "./dist/index.d.cts",
26+
"default": "./dist/index.cjs"
27+
}
28+
}
29+
},
30+
"publishConfig": {
31+
"access": "public",
32+
"registry": "https://registry.npmjs.org"
33+
},
34+
"scripts": {
35+
"build": "tsdown",
36+
"typecheck": "tsc --noEmit",
37+
"lint:pkg": "publint && attw --pack",
38+
"test": "vitest run",
39+
"test:coverage": "vitest run --coverage"
40+
},
41+
"dependencies": {
42+
"vue-component-type-helpers": "^2.0.0"
43+
},
44+
"devDependencies": {
45+
"@vue/test-utils": "^2.4.6",
46+
"jsdom": "^29.0.1",
47+
"vue": "^3.5.0"
48+
},
49+
"peerDependencies": {
50+
"vue": "^3.5.0"
51+
}
52+
}

packages/dialog/src/index.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { Component, VNode } from "vue";
2+
import type { ComponentProps } from "vue-component-type-helpers";
3+
4+
import { Suspense, defineComponent, h, markRaw, onErrorCaptured, ref } from "vue";
5+
6+
type UnregisterMiddleware = () => void;
7+
8+
/** Error handler for dialog middleware chain. Return `false` to stop propagation. */
9+
export type DialogErrorHandler = (error: Error, context: { closeAll: () => void }) => boolean;
10+
11+
/** Public API of a dialog service instance. */
12+
export interface DialogService {
13+
/** Open a component in a new dialog on top of the stack. */
14+
open: <C extends Component>(component: C, props: ComponentProps<C>) => void;
15+
/** Close all dialogs in the stack. */
16+
closeAll: () => void;
17+
/** Register an error middleware handler. Returns an unregister function. */
18+
registerErrorMiddleware: (handler: DialogErrorHandler) => UnregisterMiddleware;
19+
/** Vue component that renders the dialog stack. Mount this in your template. */
20+
DialogContainerComponent: Component;
21+
}
22+
23+
interface DialogEntry {
24+
node: VNode;
25+
key: string;
26+
}
27+
28+
const DIALOG_STYLE = "padding:0;margin:auto;background:transparent;border:none";
29+
30+
const prepareVModelProps = (
31+
props: Record<string, unknown>,
32+
onClose: () => void,
33+
): Record<string, unknown> => {
34+
const prepared: Record<string, unknown> = { ...props, onClose };
35+
36+
for (const key of Object.keys(prepared)) {
37+
if (!key.startsWith("onUpdate:")) continue;
38+
39+
const modelPropName = key.slice("onUpdate:".length);
40+
const originalHandler = prepared[key] as (...args: unknown[]) => void;
41+
42+
prepared[key] = (value: unknown) => {
43+
prepared[modelPropName] = value;
44+
originalHandler(value);
45+
};
46+
}
47+
48+
return prepared;
49+
};
50+
51+
/**
52+
* Create a dialog service that manages a LIFO stack of dialogs.
53+
*
54+
* Each dialog is rendered in a native `<dialog>` element with `showModal()`.
55+
* The service handles body scroll lock, backdrop click detection, ESC key
56+
* prevention, v-model prop synchronization, and error middleware.
57+
*
58+
* Dialog content is wrapped in `Suspense` to support `defineAsyncComponent`.
59+
*/
60+
export const createDialogService = (): DialogService => {
61+
const dialogs = ref<DialogEntry[]>([]);
62+
const errorMiddleware: DialogErrorHandler[] = [];
63+
let dialogId = 0;
64+
65+
const updateBodyScroll = () => {
66+
document.body.style.overflowY = dialogs.value.length > 0 ? "hidden" : "auto";
67+
};
68+
69+
const closeFrom = (index: number) => {
70+
if (index < 0 || index >= dialogs.value.length) return;
71+
72+
dialogs.value.splice(index);
73+
updateBodyScroll();
74+
};
75+
76+
const closeAll = () => {
77+
dialogs.value.splice(0);
78+
updateBodyScroll();
79+
};
80+
81+
const open = <C extends Component>(component: C, props: ComponentProps<C>): void => {
82+
const key = `dialog-${dialogId++}`;
83+
const rawComponent = markRaw(component);
84+
85+
const index = dialogs.value.length;
86+
const onClose = () => closeFrom(index);
87+
const prepared = prepareVModelProps(props as Record<string, unknown>, onClose);
88+
89+
const node = h(
90+
"dialog",
91+
{
92+
key,
93+
style: DIALOG_STYLE,
94+
onCancel: (event: Event) => event.preventDefault(),
95+
onClick: (event: MouseEvent) => {
96+
if ((event.target as HTMLElement).tagName === "DIALOG") {
97+
onClose();
98+
}
99+
},
100+
onVnodeMounted: (vnode: VNode) => {
101+
const el = vnode.el as HTMLDialogElement | null;
102+
el?.showModal();
103+
},
104+
},
105+
h(Suspense, null, {
106+
default: () => h(rawComponent, prepared),
107+
fallback: () => null,
108+
}),
109+
);
110+
111+
dialogs.value.push({ node, key });
112+
updateBodyScroll();
113+
};
114+
115+
const registerErrorMiddleware = (handler: DialogErrorHandler): UnregisterMiddleware => {
116+
errorMiddleware.push(handler);
117+
118+
return () => {
119+
const index = errorMiddleware.indexOf(handler);
120+
if (index > -1) errorMiddleware.splice(index, 1);
121+
};
122+
};
123+
124+
const handleError = (error: unknown): boolean => {
125+
if (!(error instanceof Error)) return true;
126+
127+
for (const handler of errorMiddleware) {
128+
const shouldPropagate = handler(error, { closeAll });
129+
if (!shouldPropagate) return false;
130+
}
131+
132+
return true;
133+
};
134+
135+
const DialogContainerComponent = defineComponent({
136+
name: "DialogContainer",
137+
setup() {
138+
onErrorCaptured((error) => handleError(error));
139+
140+
return () => dialogs.value.map((dialog) => dialog.node);
141+
},
142+
});
143+
144+
return { open, closeAll, registerErrorMiddleware, DialogContainerComponent };
145+
};

0 commit comments

Comments
 (0)