diff --git a/package-lock.json b/package-lock.json index 854a732..c02c0d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2772,6 +2772,10 @@ "resolved": "packages/theme", "link": true }, + "node_modules/@script-development/fs-toast": { + "resolved": "packages/toast", + "link": true + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -7808,7 +7812,6 @@ "version": "2.2.12", "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", - "dev": true, "license": "MIT" }, "node_modules/w3c-xmlserializer": { @@ -8096,6 +8099,22 @@ "peerDependencies": { "vue": "^3.5.0" } + }, + "packages/toast": { + "name": "@script-development/fs-toast", + "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" + } } } } diff --git a/packages/toast/package.json b/packages/toast/package.json new file mode 100644 index 0000000..af8ecd6 --- /dev/null +++ b/packages/toast/package.json @@ -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" + } +} diff --git a/packages/toast/src/index.ts b/packages/toast/src/index.ts new file mode 100644 index 0000000..689ca18 --- /dev/null +++ b/packages/toast/src/index.ts @@ -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 { + /** Display a toast with the given props. Returns a unique ID for programmatic hiding. */ + show: (props: Omit, "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 = ( + component: C, + maxToasts = 4, +): ToastService => { + 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, "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 }; +}; diff --git a/packages/toast/tests/toast.spec.ts b/packages/toast/tests/toast.spec.ts new file mode 100644 index 0000000..3028058 --- /dev/null +++ b/packages/toast/tests/toast.spec.ts @@ -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"); + }); + }); +}); diff --git a/packages/toast/tsconfig.json b/packages/toast/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/toast/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/toast/tsdown.config.ts b/packages/toast/tsdown.config.ts new file mode 100644 index 0000000..aa2e8b8 --- /dev/null +++ b/packages/toast/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/toast/vitest.config.ts b/packages/toast/vitest.config.ts new file mode 100644 index 0000000..0a9486c --- /dev/null +++ b/packages/toast/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + name: "toast", + environment: "jsdom", + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + }, + }, +});