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
21 changes: 20 additions & 1 deletion 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/toast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@script-development/fs-toast",
"version": "0.1.0",
"description": "Component-agnostic toast queue service for Vue 3 — FIFO management, you bring the component",
"license": "UNLICENSED",
"repository": {
"type": "git",
"url": "https://github.com/script-development/fs-packages.git",
"directory": "packages/toast"
},
"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"
}
}
62 changes: 62 additions & 0 deletions packages/toast/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Component, VNode } from "vue";
import type { ComponentProps } from "vue-component-type-helpers";

import { defineComponent, h, ref } from "vue";

/** Public API of a toast service instance. */
export interface ToastService<C extends Component> {
/** Display a toast with the given props. Returns a unique ID for programmatic hiding. */
show: (props: Omit<ComponentProps<C>, "onClose">) => string;
/** Remove a specific toast by ID. No-op if the ID doesn't exist. */
hide: (id: string) => void;
/** Vue component that renders the toast queue. Mount this wherever you want toasts to appear. */
ToastContainerComponent: Component;
}

/**
* Create a toast service for a given Vue component.
*
* The service manages a FIFO queue — when the queue exceeds `maxToasts`,
* the oldest toast is removed. Each toast component receives an `onClose`
* prop that removes it from the queue when called.
*
* @param component - The Vue component to render for each toast.
* @param maxToasts - Maximum number of visible toasts (default: 4, minimum: 1).
*/
export const createToastService = <C extends Component>(
component: C,
maxToasts = 4,
): ToastService<C> => {
const validatedMaxToasts = Math.max(1, Math.floor(maxToasts));
const toasts = ref<{ node: VNode; id: string }[]>([]);
let toastId = 0;

const hide = (id: string) => {
const index = toasts.value.findIndex((toast) => toast.id === id);
if (index === -1) return;

toasts.value.splice(index, 1);
};

const show = (props: Omit<ComponentProps<C>, "onClose">): string => {
if (toasts.value.length >= validatedMaxToasts && toasts.value[0]) {
hide(toasts.value[0].id);
}

const id = `toast-${toastId++}`;
const toastHider = () => hide(id);

toasts.value.push({ node: h(component, { key: id, ...props, onClose: toastHider }), id });

return id;
};

const ToastContainerComponent = defineComponent({
name: "ToastContainer",
render() {
return toasts.value.map((toast) => toast.node);
},
});

return { show, hide, ToastContainerComponent };
};
219 changes: 219 additions & 0 deletions packages/toast/tests/toast.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import { createToastService } from "../src/index";
import { shallowMount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { defineComponent, h, nextTick } from "vue";

const TestToast = defineComponent({
props: { message: String },
emits: ["close"],
render() {
return h("div", { class: "toast" }, this.message);
},
});

describe("toast service", () => {
describe("createToastService", () => {
it("should return all expected methods and properties", () => {
const toastService = createToastService(TestToast);

expect(toastService).toHaveProperty("show");
expect(toastService).toHaveProperty("hide");
expect(toastService).toHaveProperty("ToastContainerComponent");
expect(typeof toastService.show).toBe("function");
expect(typeof toastService.hide).toBe("function");
});

it("should return a valid Vue component", () => {
const toastService = createToastService(TestToast);

expect(toastService.ToastContainerComponent).toHaveProperty("render");
expect(toastService.ToastContainerComponent.name).toBe("ToastContainer");
});
});

describe("show", () => {
it("should add toast to the container", async () => {
const toastService = createToastService(TestToast);
const wrapper = shallowMount(toastService.ToastContainerComponent);

toastService.show({ message: "Test message" });
await nextTick();

expect(wrapper.text()).toContain("Test message");
});

it("should return toast id", () => {
const toastService = createToastService(TestToast);

const id = toastService.show({ message: "Test" });

expect(typeof id).toBe("string");
expect(id.length).toBeGreaterThan(0);
});

it("should add multiple toasts", async () => {
const toastService = createToastService(TestToast);
const wrapper = shallowMount(toastService.ToastContainerComponent);

toastService.show({ message: "Toast 1" });
toastService.show({ message: "Toast 2" });
toastService.show({ message: "Toast 3" });
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(3);
expect(wrapper.text()).toContain("Toast 1");
expect(wrapper.text()).toContain("Toast 2");
expect(wrapper.text()).toContain("Toast 3");
});

it("should remove oldest toast when exceeding maximum", async () => {
const toastService = createToastService(TestToast, 2);
const wrapper = shallowMount(toastService.ToastContainerComponent);

toastService.show({ message: "Toast 1" });
toastService.show({ message: "Toast 2" });
toastService.show({ message: "Toast 3" });
toastService.show({ message: "Toast 4" });
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(2);
expect(wrapper.text()).not.toContain("Toast 1");
expect(wrapper.text()).not.toContain("Toast 2");
expect(wrapper.text()).toContain("Toast 3");
expect(wrapper.text()).toContain("Toast 4");
});

it("should use default maxToasts of 4", async () => {
const toastService = createToastService(TestToast);
const wrapper = shallowMount(toastService.ToastContainerComponent);

for (let i = 1; i <= 6; i++) {
toastService.show({ message: `Toast ${i}` });
}
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(4);
expect(wrapper.text()).not.toContain("Toast 1");
expect(wrapper.text()).toContain("Toast 6");
});

it("should clamp maxToasts to minimum of 1 when 0 is provided", async () => {
const toastService = createToastService(TestToast, 0);
const wrapper = shallowMount(toastService.ToastContainerComponent);

toastService.show({ message: "Toast 1" });
toastService.show({ message: "Toast 2" });
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(1);
expect(wrapper.text()).toContain("Toast 2");
});

it("should clamp maxToasts to minimum of 1 when negative is provided", async () => {
const toastService = createToastService(TestToast, -5);
const wrapper = shallowMount(toastService.ToastContainerComponent);

toastService.show({ message: "Toast 1" });
toastService.show({ message: "Toast 2" });
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(1);
expect(wrapper.text()).toContain("Toast 2");
});

it("should floor decimal maxToasts values", async () => {
const toastService = createToastService(TestToast, 2.9);
const wrapper = shallowMount(toastService.ToastContainerComponent);

toastService.show({ message: "Toast 1" });
toastService.show({ message: "Toast 2" });
toastService.show({ message: "Toast 3" });
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(2);
expect(wrapper.text()).not.toContain("Toast 1");
expect(wrapper.text()).toContain("Toast 3");
});
});

describe("hide", () => {
it("should remove toast by id", async () => {
const toastService = createToastService(TestToast);
const wrapper = shallowMount(toastService.ToastContainerComponent);
const id = toastService.show({ message: "Toast to hide" });
await nextTick();

toastService.hide(id);
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(0);
});

it("should only remove specified toast", async () => {
const toastService = createToastService(TestToast);
const wrapper = shallowMount(toastService.ToastContainerComponent);
toastService.show({ message: "Toast 1" });
const id2 = toastService.show({ message: "Toast 2" });
toastService.show({ message: "Toast 3" });
await nextTick();

toastService.hide(id2);
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(2);
expect(wrapper.text()).toContain("Toast 1");
expect(wrapper.text()).not.toContain("Toast 2");
expect(wrapper.text()).toContain("Toast 3");
});

it("should do nothing when hiding non-existent toast", async () => {
const toastService = createToastService(TestToast);
const wrapper = shallowMount(toastService.ToastContainerComponent);
toastService.show({ message: "Toast 1" });
await nextTick();

expect(() => toastService.hide("non-existent")).not.toThrow();
expect(wrapper.findAll(".toast")).toHaveLength(1);
});
});

describe("onClose prop", () => {
it("should pass onClose handler to toast component", async () => {
const ClosableToast = defineComponent({
props: { message: String, onClose: Function },
emits: ["close"],
render() {
return h("div", { class: "toast" }, [
this.message,
h("button", { onClick: this.onClose }, "Close"),
]);
},
});
const toastService = createToastService(ClosableToast);
const wrapper = shallowMount(toastService.ToastContainerComponent);
toastService.show({ message: "Closable toast" });
await nextTick();

await wrapper.find("button").trigger("click");
await nextTick();

expect(wrapper.findAll(".toast")).toHaveLength(0);
});
});

describe("isolation", () => {
it("should create independent toast services", async () => {
const service1 = createToastService(TestToast);
const service2 = createToastService(TestToast);
const wrapper1 = shallowMount(service1.ToastContainerComponent);
const wrapper2 = shallowMount(service2.ToastContainerComponent);

service1.show({ message: "Service 1 toast" });
await nextTick();

expect(wrapper1.text()).toContain("Service 1 toast");
expect(wrapper2.text()).not.toContain("Service 1 toast");
});
});
});
8 changes: 8 additions & 0 deletions packages/toast/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
9 changes: 9 additions & 0 deletions packages/toast/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "tsdown";

export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,
});
Loading