From fa7b679a6154d0da776c84ad30c6c18fdfb6b65d Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Mon, 15 Jun 2026 22:40:57 +0100 Subject: [PATCH 01/11] feature-add: DisplayToast - implement toast queue and stacking --- .../01.atoms/toast/DisplayToastProvider.vue | 274 +++++++++ .../stories/DisplayToastProvider.stories.ts | 142 +++++ .../toast/tests/DisplayToastProvider.spec.ts | 256 ++++++++ app/composables/useToastQueue.ts | 32 + app/pages/ui/display-toast.vue | 558 ++++++++---------- app/types/components/display-toast.d.ts | 8 + 6 files changed, 972 insertions(+), 298 deletions(-) create mode 100644 app/components/01.atoms/toast/DisplayToastProvider.vue create mode 100644 app/components/01.atoms/toast/stories/DisplayToastProvider.stories.ts create mode 100644 app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts create mode 100644 app/composables/useToastQueue.ts diff --git a/app/components/01.atoms/toast/DisplayToastProvider.vue b/app/components/01.atoms/toast/DisplayToastProvider.vue new file mode 100644 index 00000000..10972985 --- /dev/null +++ b/app/components/01.atoms/toast/DisplayToastProvider.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/app/components/01.atoms/toast/stories/DisplayToastProvider.stories.ts b/app/components/01.atoms/toast/stories/DisplayToastProvider.stories.ts new file mode 100644 index 00000000..05f88c51 --- /dev/null +++ b/app/components/01.atoms/toast/stories/DisplayToastProvider.stories.ts @@ -0,0 +1,142 @@ +import type { Meta, StoryFn } from "@nuxtjs/storybook"; +import { useToastQueue } from "~/composables/useToastQueue"; +import DisplayToastProvider from "../DisplayToastProvider.vue"; +import type { DisplayToastTheme, DisplayToastPosition, DisplayToastAlignment } from "~/types/components"; + +type StoryArgs = { + position: DisplayToastPosition; + alignment: DisplayToastAlignment; + fullWidth: boolean; + maxVisible: number; + theme: DisplayToastTheme; + autoDismiss: boolean; + duration: number; +}; + +const themes = ["info", "success", "warning", "error"] as const; + +const messages: Record = { + info: { title: "Information", description: "This is an informational notification." }, + success: { title: "Success!", description: "Your action completed successfully." }, + warning: { title: "Warning", description: "Please review this before continuing." }, + error: { title: "Error", description: "Something went wrong. Please try again." }, +}; + +export default { + title: "Atoms/DisplayToastProvider", + component: DisplayToastProvider, + argTypes: { + position: { + control: { type: "select" }, + options: ["top", "bottom"], + description: "Vertical position of toasts", + table: { category: "Provider" }, + }, + alignment: { + control: { type: "select" }, + options: ["left", "center", "right"], + description: "Horizontal alignment of toasts", + table: { category: "Provider" }, + }, + fullWidth: { + control: "boolean", + description: "Toasts span full viewport width", + table: { category: "Provider" }, + }, + maxVisible: { + control: { type: "number", min: 1, max: 5 }, + description: "Max toasts shown simultaneously", + table: { category: "Provider" }, + }, + theme: { + control: { type: "select" }, + options: ["info", "success", "warning", "error"], + description: "Theme for triggered toast", + table: { category: "Toast" }, + }, + autoDismiss: { + control: "boolean", + description: "Auto-dismiss triggered toast", + table: { category: "Toast" }, + }, + duration: { + control: { type: "range", min: 1000, max: 10000, step: 500 }, + description: "Auto-dismiss duration in ms", + table: { category: "Toast" }, + }, + }, + args: { + position: "top", + alignment: "right", + fullWidth: false, + maxVisible: 1, + theme: "info", + autoDismiss: true, + duration: 4000, + }, +} as Meta; + +const Template: StoryFn = (args) => ({ + components: { DisplayToastProvider }, + setup() { + const { show, clear } = useToastQueue(); + + const triggerToast = () => { + show({ + appearance: { theme: args.theme }, + behavior: { autoDismiss: args.autoDismiss, duration: args.duration }, + content: messages[args.theme], + }); + }; + + const queueAll = () => { + themes.forEach((theme) => { + show({ + appearance: { theme }, + behavior: { autoDismiss: args.autoDismiss, duration: args.duration }, + content: messages[theme], + }); + }); + }; + + return { args, triggerToast, queueAll, clear }; + }, + template: ` +
+
+ + + +
+ +
+ `, +}); + +export const Default = Template.bind({}); + +export const BottomLeft = Template.bind({}); +BottomLeft.args = { + position: "bottom", + alignment: "left", + theme: "success", + autoDismiss: false, +}; + +export const Stacked = Template.bind({}); +Stacked.args = { + maxVisible: 3, + autoDismiss: false, +}; + +export const FullWidth = Template.bind({}); +FullWidth.args = { + fullWidth: true, + theme: "warning", + autoDismiss: false, +}; diff --git a/app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts b/app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts new file mode 100644 index 00000000..dad613fe --- /dev/null +++ b/app/components/01.atoms/toast/tests/DisplayToastProvider.spec.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi } from "vitest"; +import { nextTick } from "vue"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import DisplayToastProvider from "../DisplayToastProvider.vue"; +import { useToastQueue } from "~/composables/useToastQueue"; + +function item() { + return document.querySelector(".display-toast-provider-item"); +} + +function items() { + return document.querySelectorAll(".display-toast-provider-item"); +} + +function provider() { + return document.querySelector(".display-toast-provider"); +} + +describe("DisplayToastProvider", () => { + const { show, clear } = useToastQueue(); + + beforeEach(() => { + clear(); + }); + + afterEach(() => { + clear(); + document.body.innerHTML = ""; + }); + + // ─── Mount ──────────────────────────────────────────────────────────────── + + it("mounts without error", async () => { + const wrapper = await mountSuspended(DisplayToastProvider); + expect(wrapper.vm).toBeTruthy(); + }); + + it("renders the provider container", async () => { + await mountSuspended(DisplayToastProvider); + expect(provider()).not.toBeNull(); + }); + + // ─── Empty state ────────────────────────────────────────────────────────── + + it("renders no items when queue is empty", async () => { + await mountSuspended(DisplayToastProvider); + expect(items().length).toBe(0); + }); + + // ─── Show / queue ───────────────────────────────────────────────────────── + + it("promotes a pending entry to visible on show()", async () => { + await mountSuspended(DisplayToastProvider); + show({ content: { text: "Hello" } }); + await nextTick(); + expect(items().length).toBe(1); + }); + + it("renders the toast message text", async () => { + await mountSuspended(DisplayToastProvider); + show({ content: { text: "Toast message" } }); + await nextTick(); + expect(document.querySelector(".toast-message")!.textContent).toContain("Toast message"); + }); + + it("renders the toast title", async () => { + await mountSuspended(DisplayToastProvider); + show({ content: { title: "My Title" } }); + await nextTick(); + expect(document.querySelector("[data-test-id='toast-title']")!.textContent).toContain("My Title"); + }); + + it("renders the toast description", async () => { + await mountSuspended(DisplayToastProvider); + show({ content: { description: "My description" } }); + await nextTick(); + expect(document.querySelector("[data-test-id='toast-description']")!.textContent).toContain("My description"); + }); + + // ─── data-theme ─────────────────────────────────────────────────────────── + + it.each(["info", "success", "warning", "error"] as const)( + "sets data-theme='%s' from config", + async (theme) => { + await mountSuspended(DisplayToastProvider); + show({ appearance: { theme } }); + await nextTick(); + expect(item()!.getAttribute("data-theme")).toBe(theme); + } + ); + + it("defaults to data-theme='info'", async () => { + await mountSuspended(DisplayToastProvider); + show({}); + await nextTick(); + expect(item()!.getAttribute("data-theme")).toBe("info"); + }); + + // ─── ARIA ───────────────────────────────────────────────────────────────── + + it.each([ + { theme: "info" as const, role: "status", live: "polite" }, + { theme: "success" as const, role: "status", live: "polite" }, + { theme: "warning" as const, role: "alert", live: "assertive" }, + { theme: "error" as const, role: "alert", live: "assertive" }, + ])("sets role=$role and aria-live=$live for theme='$theme'", async ({ theme, role, live }) => { + await mountSuspended(DisplayToastProvider); + show({ appearance: { theme } }); + await nextTick(); + expect(item()!.getAttribute("role")).toBe(role); + expect(item()!.getAttribute("aria-live")).toBe(live); + }); + + it("sets aria-describedby on each item", async () => { + await mountSuspended(DisplayToastProvider); + show({}); + await nextTick(); + expect(item()!.getAttribute("aria-describedby")).not.toBeNull(); + }); + + it("sets tabindex='0' on each item", async () => { + await mountSuspended(DisplayToastProvider); + show({}); + await nextTick(); + expect(item()!.getAttribute("tabindex")).toBe("0"); + }); + + // ─── Position / alignment classes ──────────────────────────────────────── + + it.each(["top", "bottom"] as const)("applies %s class to provider", async (position) => { + await mountSuspended(DisplayToastProvider, { props: { position } }); + expect(provider()!.classList).toContain(position); + }); + + it.each(["left", "center", "right"] as const)( + "applies %s alignment class to provider", + async (alignment) => { + await mountSuspended(DisplayToastProvider, { props: { alignment } }); + expect(provider()!.classList).toContain(alignment); + } + ); + + it("applies full-width class when fullWidth is true", async () => { + await mountSuspended(DisplayToastProvider, { props: { fullWidth: true } }); + expect(provider()!.classList).toContain("full-width"); + }); + + it("does not apply alignment class when fullWidth is true", async () => { + await mountSuspended(DisplayToastProvider, { props: { fullWidth: true, alignment: "left" } }); + expect(provider()!.classList).not.toContain("left"); + }); + + // ─── maxVisible ─────────────────────────────────────────────────────────── + + it("shows only one item at a time when maxVisible=1", async () => { + await mountSuspended(DisplayToastProvider, { props: { maxVisible: 1 } }); + show({ content: { text: "First" } }); + show({ content: { text: "Second" } }); + await nextTick(); + expect(items().length).toBe(1); + expect(document.querySelector(".toast-message")!.textContent).toContain("First"); + }); + + it("shows up to maxVisible items simultaneously", async () => { + await mountSuspended(DisplayToastProvider, { props: { maxVisible: 2 } }); + show({ content: { text: "First" } }); + show({ content: { text: "Second" } }); + show({ content: { text: "Third" } }); + await nextTick(); + expect(items().length).toBe(2); + }); + + // ─── Dismiss ────────────────────────────────────────────────────────────── + + it("removes item when close button is clicked", async () => { + await mountSuspended(DisplayToastProvider); + show({ behavior: { autoDismiss: false } }); + await nextTick(); + (document.querySelector(".toast-action button") as HTMLElement).click(); + await nextTick(); + expect(items().length).toBe(0); + }); + + it("removes item when Escape is pressed", async () => { + await mountSuspended(DisplayToastProvider); + show({ behavior: { autoDismiss: false } }); + await nextTick(); + item()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await nextTick(); + expect(items().length).toBe(0); + }); + + // ─── Auto-dismiss timer ─────────────────────────────────────────────────── + + it("auto-dismisses after duration", async () => { + await mountSuspended(DisplayToastProvider); + show({ behavior: { autoDismiss: true, duration: 3000 } }); + await nextTick(); + expect(items().length).toBe(1); + vi.advanceTimersByTime(3000); + await nextTick(); + expect(items().length).toBe(0); + }); + + it("does not auto-dismiss when autoDismiss is false", async () => { + await mountSuspended(DisplayToastProvider); + show({ behavior: { autoDismiss: false } }); + await nextTick(); + vi.advanceTimersByTime(10000); + await nextTick(); + expect(items().length).toBe(1); + }); + + // ─── Queue progression ──────────────────────────────────────────────────── + + it("promotes next pending entry after current is dismissed", async () => { + await mountSuspended(DisplayToastProvider, { props: { maxVisible: 1 } }); + show({ content: { text: "First" }, behavior: { autoDismiss: false } }); + show({ content: { text: "Second" }, behavior: { autoDismiss: false } }); + await nextTick(); + expect(document.querySelector(".toast-message")!.textContent).toContain("First"); + + item()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await nextTick(); + expect(document.querySelector(".toast-message")!.textContent).toContain("Second"); + }); + + // ─── Progress bar ───────────────────────────────────────────────────────── + + it("renders progress bar when autoDismiss is true", async () => { + await mountSuspended(DisplayToastProvider); + show({ behavior: { autoDismiss: true } }); + await nextTick(); + expect(document.querySelector(".display-toast-provider-progress")).not.toBeNull(); + }); + + it("does not render progress bar when autoDismiss is false", async () => { + await mountSuspended(DisplayToastProvider); + show({ behavior: { autoDismiss: false } }); + await nextTick(); + expect(document.querySelector(".display-toast-provider-progress")).toBeNull(); + }); + + // ─── clear() ───────────────────────────────────────────────────────────── + + it("removes all items on clear()", async () => { + const { clear: clearQueue } = useToastQueue(); + await mountSuspended(DisplayToastProvider, { props: { maxVisible: 3 } }); + show({}); + show({}); + await nextTick(); + clearQueue(); + await nextTick(); + expect(items().length).toBe(0); + }); +}); diff --git a/app/composables/useToastQueue.ts b/app/composables/useToastQueue.ts new file mode 100644 index 00000000..3b3f1687 --- /dev/null +++ b/app/composables/useToastQueue.ts @@ -0,0 +1,32 @@ +import type { DisplayToastConfig, ToastQueueEntry } from "~/types/components"; + +const _queue = ref([]); + +export function useToastQueue() { + const show = (config: DisplayToastConfig): string => { + const id = `toast-${crypto.randomUUID()}`; + _queue.value = [..._queue.value, { id, config, status: "pending" }]; + return id; + }; + + const promote = (id: string) => { + const entry = _queue.value.find((e) => e.id === id); + if (entry) entry.status = "visible"; + }; + + const dismiss = (id: string) => { + _queue.value = _queue.value.filter((e) => e.id !== id); + }; + + const clear = () => { + _queue.value = []; + }; + + return { + queue: readonly(_queue), + show, + promote, + dismiss, + clear, + }; +} diff --git a/app/pages/ui/display-toast.vue b/app/pages/ui/display-toast.vue index ce09ebc5..085f9dfc 100644 --- a/app/pages/ui/display-toast.vue +++ b/app/pages/ui/display-toast.vue @@ -2,331 +2,293 @@
diff --git a/app/types/components/display-toast.d.ts b/app/types/components/display-toast.d.ts index 6b86920c..f79dec24 100644 --- a/app/types/components/display-toast.d.ts +++ b/app/types/components/display-toast.d.ts @@ -38,6 +38,14 @@ export interface DisplayToastProps { styleClassPassthrough?: string | string[] } +export type ToastQueueStatus = "pending" | "visible" + +export interface ToastQueueEntry { + id: string + config: DisplayToastConfig + status: ToastQueueStatus +} + export interface ToastSlots { default?(props?: Record): VNode[] customToastIcon?(props?: Record): VNode[] From bfde10f4a640511aea6e57c9fba84bdfbcac6d75 Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Mon, 15 Jun 2026 23:06:15 +0100 Subject: [PATCH 02/11] feature-add: DisplayToast - Animate closing queue away --- .../01.atoms/toast/DisplayToastProvider.vue | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/components/01.atoms/toast/DisplayToastProvider.vue b/app/components/01.atoms/toast/DisplayToastProvider.vue index 10972985..566ead06 100644 --- a/app/components/01.atoms/toast/DisplayToastProvider.vue +++ b/app/components/01.atoms/toast/DisplayToastProvider.vue @@ -4,6 +4,7 @@ tag="div" class="display-toast-provider" :class="[position, fullWidth ? 'full-width' : alignment]" + @before-leave="onBeforeLeave" @after-enter="onAfterEnter" >
{ else itemRefs.delete(id); }; +const onBeforeLeave = (el: Element) => { + const htmlEl = el as HTMLElement; + htmlEl.style.top = `${htmlEl.offsetTop}px`; + htmlEl.style.width = `${htmlEl.offsetWidth}px`; +}; + const onAfterEnter = (el: Element) => { (el as HTMLElement).focus(); }; @@ -206,6 +213,8 @@ onUnmounted(() => { } &.v-leave-active { + position: absolute; + @supports (animation-timing-function: linear(0, 1)) { animation: hideTop var(--_reveal) var(--spring-easing) forwards; } @@ -223,6 +232,8 @@ onUnmounted(() => { } &.v-leave-active { + position: absolute; + @supports (animation-timing-function: linear(0, 1)) { animation: hideBottom var(--_reveal) var(--spring-easing) forwards; } From 509bde23bdbb725a6eb07acb457fece0015a0bb1 Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Mon, 15 Jun 2026 23:17:26 +0100 Subject: [PATCH 03/11] bugfix: DisplayToast - Animate in now working correctly --- .../01.atoms/toast/DisplayToastProvider.vue | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/app/components/01.atoms/toast/DisplayToastProvider.vue b/app/components/01.atoms/toast/DisplayToastProvider.vue index 566ead06..b35dc6a7 100644 --- a/app/components/01.atoms/toast/DisplayToastProvider.vue +++ b/app/components/01.atoms/toast/DisplayToastProvider.vue @@ -136,6 +136,28 @@ onUnmounted(() => {