Skip to content

Commit f91578d

Browse files
committed
feat: add @script-development/fs-toast package
Component-agnostic toast queue service for Vue 3. Generic factory manages FIFO queue — consumer provides the toast component, mounts the container, handles dismiss. Zero fs-* dependencies. Only peer dep is Vue 3.5+.
1 parent 7043f4a commit f91578d

7 files changed

Lines changed: 388 additions & 1 deletion

File tree

package-lock.json

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

packages/toast/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-toast",
3+
"version": "0.1.0",
4+
"description": "Component-agnostic toast queue service for Vue 3 — FIFO management, 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/toast"
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/toast/src/index.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Component, VNode } from "vue";
2+
import type { ComponentProps } from "vue-component-type-helpers";
3+
4+
import { defineComponent, h, ref } from "vue";
5+
6+
/** Public API of a toast service instance. */
7+
export interface ToastService<C extends Component> {
8+
/** Display a toast with the given props. Returns a unique ID for programmatic hiding. */
9+
show: (props: Omit<ComponentProps<C>, "onClose">) => string;
10+
/** Remove a specific toast by ID. No-op if the ID doesn't exist. */
11+
hide: (id: string) => void;
12+
/** Vue component that renders the toast queue. Mount this wherever you want toasts to appear. */
13+
ToastContainerComponent: Component;
14+
}
15+
16+
/**
17+
* Create a toast service for a given Vue component.
18+
*
19+
* The service manages a FIFO queue — when the queue exceeds `maxToasts`,
20+
* the oldest toast is removed. Each toast component receives an `onClose`
21+
* prop that removes it from the queue when called.
22+
*
23+
* @param component - The Vue component to render for each toast.
24+
* @param maxToasts - Maximum number of visible toasts (default: 4, minimum: 1).
25+
*/
26+
export const createToastService = <C extends Component>(
27+
component: C,
28+
maxToasts = 4,
29+
): ToastService<C> => {
30+
const validatedMaxToasts = Math.max(1, Math.floor(maxToasts));
31+
const toasts = ref<{ node: VNode; id: string }[]>([]);
32+
let toastId = 0;
33+
34+
const hide = (id: string) => {
35+
const index = toasts.value.findIndex((toast) => toast.id === id);
36+
if (index === -1) return;
37+
38+
toasts.value.splice(index, 1);
39+
};
40+
41+
const show = (props: Omit<ComponentProps<C>, "onClose">): string => {
42+
if (toasts.value.length >= validatedMaxToasts && toasts.value[0]) {
43+
hide(toasts.value[0].id);
44+
}
45+
46+
const id = `toast-${toastId++}`;
47+
const toastHider = () => hide(id);
48+
49+
toasts.value.push({ node: h(component, { key: id, ...props, onClose: toastHider }), id });
50+
51+
return id;
52+
};
53+
54+
const ToastContainerComponent = defineComponent({
55+
name: "ToastContainer",
56+
render() {
57+
return toasts.value.map((toast) => toast.node);
58+
},
59+
});
60+
61+
return { show, hide, ToastContainerComponent };
62+
};

packages/toast/tests/toast.spec.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// @vitest-environment jsdom
2+
import { createToastService } from "../src/index";
3+
import { shallowMount } from "@vue/test-utils";
4+
import { describe, expect, it } from "vitest";
5+
import { defineComponent, h, nextTick } from "vue";
6+
7+
const TestToast = defineComponent({
8+
props: { message: String },
9+
emits: ["close"],
10+
render() {
11+
return h("div", { class: "toast" }, this.message);
12+
},
13+
});
14+
15+
describe("toast service", () => {
16+
describe("createToastService", () => {
17+
it("should return all expected methods and properties", () => {
18+
const toastService = createToastService(TestToast);
19+
20+
expect(toastService).toHaveProperty("show");
21+
expect(toastService).toHaveProperty("hide");
22+
expect(toastService).toHaveProperty("ToastContainerComponent");
23+
expect(typeof toastService.show).toBe("function");
24+
expect(typeof toastService.hide).toBe("function");
25+
});
26+
27+
it("should return a valid Vue component", () => {
28+
const toastService = createToastService(TestToast);
29+
30+
expect(toastService.ToastContainerComponent).toHaveProperty("render");
31+
expect(toastService.ToastContainerComponent.name).toBe("ToastContainer");
32+
});
33+
});
34+
35+
describe("show", () => {
36+
it("should add toast to the container", async () => {
37+
const toastService = createToastService(TestToast);
38+
const wrapper = shallowMount(toastService.ToastContainerComponent);
39+
40+
toastService.show({ message: "Test message" });
41+
await nextTick();
42+
43+
expect(wrapper.text()).toContain("Test message");
44+
});
45+
46+
it("should return toast id", () => {
47+
const toastService = createToastService(TestToast);
48+
49+
const id = toastService.show({ message: "Test" });
50+
51+
expect(typeof id).toBe("string");
52+
expect(id.length).toBeGreaterThan(0);
53+
});
54+
55+
it("should add multiple toasts", async () => {
56+
const toastService = createToastService(TestToast);
57+
const wrapper = shallowMount(toastService.ToastContainerComponent);
58+
59+
toastService.show({ message: "Toast 1" });
60+
toastService.show({ message: "Toast 2" });
61+
toastService.show({ message: "Toast 3" });
62+
await nextTick();
63+
64+
expect(wrapper.findAll(".toast")).toHaveLength(3);
65+
expect(wrapper.text()).toContain("Toast 1");
66+
expect(wrapper.text()).toContain("Toast 2");
67+
expect(wrapper.text()).toContain("Toast 3");
68+
});
69+
70+
it("should remove oldest toast when exceeding maximum", async () => {
71+
const toastService = createToastService(TestToast, 2);
72+
const wrapper = shallowMount(toastService.ToastContainerComponent);
73+
74+
toastService.show({ message: "Toast 1" });
75+
toastService.show({ message: "Toast 2" });
76+
toastService.show({ message: "Toast 3" });
77+
toastService.show({ message: "Toast 4" });
78+
await nextTick();
79+
80+
expect(wrapper.findAll(".toast")).toHaveLength(2);
81+
expect(wrapper.text()).not.toContain("Toast 1");
82+
expect(wrapper.text()).not.toContain("Toast 2");
83+
expect(wrapper.text()).toContain("Toast 3");
84+
expect(wrapper.text()).toContain("Toast 4");
85+
});
86+
87+
it("should use default maxToasts of 4", async () => {
88+
const toastService = createToastService(TestToast);
89+
const wrapper = shallowMount(toastService.ToastContainerComponent);
90+
91+
for (let i = 1; i <= 6; i++) {
92+
toastService.show({ message: `Toast ${i}` });
93+
}
94+
await nextTick();
95+
96+
expect(wrapper.findAll(".toast")).toHaveLength(4);
97+
expect(wrapper.text()).not.toContain("Toast 1");
98+
expect(wrapper.text()).toContain("Toast 6");
99+
});
100+
101+
it("should clamp maxToasts to minimum of 1 when 0 is provided", async () => {
102+
const toastService = createToastService(TestToast, 0);
103+
const wrapper = shallowMount(toastService.ToastContainerComponent);
104+
105+
toastService.show({ message: "Toast 1" });
106+
toastService.show({ message: "Toast 2" });
107+
await nextTick();
108+
109+
expect(wrapper.findAll(".toast")).toHaveLength(1);
110+
expect(wrapper.text()).toContain("Toast 2");
111+
});
112+
113+
it("should clamp maxToasts to minimum of 1 when negative is provided", async () => {
114+
const toastService = createToastService(TestToast, -5);
115+
const wrapper = shallowMount(toastService.ToastContainerComponent);
116+
117+
toastService.show({ message: "Toast 1" });
118+
toastService.show({ message: "Toast 2" });
119+
await nextTick();
120+
121+
expect(wrapper.findAll(".toast")).toHaveLength(1);
122+
expect(wrapper.text()).toContain("Toast 2");
123+
});
124+
125+
it("should floor decimal maxToasts values", async () => {
126+
const toastService = createToastService(TestToast, 2.9);
127+
const wrapper = shallowMount(toastService.ToastContainerComponent);
128+
129+
toastService.show({ message: "Toast 1" });
130+
toastService.show({ message: "Toast 2" });
131+
toastService.show({ message: "Toast 3" });
132+
await nextTick();
133+
134+
expect(wrapper.findAll(".toast")).toHaveLength(2);
135+
expect(wrapper.text()).not.toContain("Toast 1");
136+
expect(wrapper.text()).toContain("Toast 3");
137+
});
138+
});
139+
140+
describe("hide", () => {
141+
it("should remove toast by id", async () => {
142+
const toastService = createToastService(TestToast);
143+
const wrapper = shallowMount(toastService.ToastContainerComponent);
144+
const id = toastService.show({ message: "Toast to hide" });
145+
await nextTick();
146+
147+
toastService.hide(id);
148+
await nextTick();
149+
150+
expect(wrapper.findAll(".toast")).toHaveLength(0);
151+
});
152+
153+
it("should only remove specified toast", async () => {
154+
const toastService = createToastService(TestToast);
155+
const wrapper = shallowMount(toastService.ToastContainerComponent);
156+
toastService.show({ message: "Toast 1" });
157+
const id2 = toastService.show({ message: "Toast 2" });
158+
toastService.show({ message: "Toast 3" });
159+
await nextTick();
160+
161+
toastService.hide(id2);
162+
await nextTick();
163+
164+
expect(wrapper.findAll(".toast")).toHaveLength(2);
165+
expect(wrapper.text()).toContain("Toast 1");
166+
expect(wrapper.text()).not.toContain("Toast 2");
167+
expect(wrapper.text()).toContain("Toast 3");
168+
});
169+
170+
it("should do nothing when hiding non-existent toast", async () => {
171+
const toastService = createToastService(TestToast);
172+
const wrapper = shallowMount(toastService.ToastContainerComponent);
173+
toastService.show({ message: "Toast 1" });
174+
await nextTick();
175+
176+
expect(() => toastService.hide("non-existent")).not.toThrow();
177+
expect(wrapper.findAll(".toast")).toHaveLength(1);
178+
});
179+
});
180+
181+
describe("onClose prop", () => {
182+
it("should pass onClose handler to toast component", async () => {
183+
const ClosableToast = defineComponent({
184+
props: { message: String, onClose: Function },
185+
emits: ["close"],
186+
render() {
187+
return h("div", { class: "toast" }, [
188+
this.message,
189+
h("button", { onClick: this.onClose }, "Close"),
190+
]);
191+
},
192+
});
193+
const toastService = createToastService(ClosableToast);
194+
const wrapper = shallowMount(toastService.ToastContainerComponent);
195+
toastService.show({ message: "Closable toast" });
196+
await nextTick();
197+
198+
await wrapper.find("button").trigger("click");
199+
await nextTick();
200+
201+
expect(wrapper.findAll(".toast")).toHaveLength(0);
202+
});
203+
});
204+
205+
describe("isolation", () => {
206+
it("should create independent toast services", async () => {
207+
const service1 = createToastService(TestToast);
208+
const service2 = createToastService(TestToast);
209+
const wrapper1 = shallowMount(service1.ToastContainerComponent);
210+
const wrapper2 = shallowMount(service2.ToastContainerComponent);
211+
212+
service1.show({ message: "Service 1 toast" });
213+
await nextTick();
214+
215+
expect(wrapper1.text()).toContain("Service 1 toast");
216+
expect(wrapper2.text()).not.toContain("Service 1 toast");
217+
});
218+
});
219+
});

packages/toast/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "src"
6+
},
7+
"include": ["src"]
8+
}

packages/toast/tsdown.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from "tsdown";
2+
3+
export default defineConfig({
4+
entry: ["src/index.ts"],
5+
format: ["esm", "cjs"],
6+
dts: true,
7+
clean: true,
8+
sourcemap: true,
9+
});

0 commit comments

Comments
 (0)