diff --git a/docs/packages/toast.md b/docs/packages/toast.md index 114dfef..c51c6ce 100644 --- a/docs/packages/toast.md +++ b/docs/packages/toast.md @@ -125,3 +125,34 @@ This catches toast-related bugs at build time, not at runtime. | `show(props)` | `(props) => string` | Show a toast, returns unique ID | | `hide(id)` | `(id: string) => void` | Remove a toast by ID | | `ToastContainerComponent` | `Component` | Mount this in your app root | + +## Top-Layer Behavior (0.2.0+) + +The `ToastContainerComponent` promotes itself to the browser **top layer** via the [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) whenever at least one toast is queued. This keeps toasts visible above any open `.showModal()` backdrop — without top-layer promotion, no `z-index` value can pierce a modal's stacking context. + +The container declares `popover="manual"` and calls `.showPopover()` on the first toast / `.hidePopover()` after the last toast clears. Defensive try/catch guards swallow `InvalidStateError` so rapid show/hide cycles don't surface uncaught errors. + +### CSS Specificity (Migration from 0.1.1) + +The UA stylesheet applies `position: fixed; inset: 0; margin: auto; width: fit-content; height: fit-content` to any element matching `[popover]:popover-open` — selector specificity `(0,2,0)`. Consumer fallthrough classes like `.toast-stack { position: fixed; top: 1rem; right: 1rem }` (`(0,1,0)`) do **not** override these UA rules. + +If you applied positioning via fallthrough classes in 0.1.1, raise selector specificity in 0.2.0 by qualifying with `[popover]`: + +```css +/* Beats UA :popover-open */ +[popover].toast-stack { + position: fixed; + top: 1rem; + right: 1rem; + inset: auto; + margin: 0; + width: auto; + height: auto; +} +``` + +`fs-toast` deliberately ships **no** inline `style` resets so consumer CSS retains full control. + +### Browser Baseline + +Popover API support: Chrome ≥ 114, Firefox ≥ 125, Safari ≥ 17. On older browsers the container's defensive try/catch swallows the missing-method error — toasts still render in normal DOM, just without top-layer promotion (so they will render below modal backdrops on those browsers). diff --git a/package-lock.json b/package-lock.json index d7a37bc..a3e0418 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10552,7 +10552,7 @@ }, "packages/dialog": { "name": "@script-development/fs-dialog", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "vue-component-type-helpers": "^3.2.7" @@ -10662,7 +10662,7 @@ }, "packages/toast": { "name": "@script-development/fs-toast", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "vue-component-type-helpers": "^3.2.7" diff --git a/packages/toast/CHANGELOG.md b/packages/toast/CHANGELOG.md index 7bc5d2f..199bf12 100644 --- a/packages/toast/CHANGELOG.md +++ b/packages/toast/CHANGELOG.md @@ -1,5 +1,45 @@ # @script-development/fs-toast +## 0.2.0 + +### Minor Changes + +- Promote `ToastContainerComponent` to the browser top layer via the [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) so toasts remain visible above `.showModal()` backdrops (closes [#71](https://github.com/script-development/fs-packages/issues/71)). The container declares `popover="manual"` and calls `.showPopover()` when the queue gains its first toast / `.hidePopover()` when the queue empties. The single-root container output from 0.1.1 is preserved — fallthrough class/style attributes still land on the root `
`. + +### Migration — CSS Specificity + +The UA stylesheet applies the following rules to any element with `[popover]:popover-open`: + +```css +[popover]:popover-open { + position: fixed; + inset: 0; + margin: auto; + width: fit-content; + height: fit-content; +} +``` + +The selector specificity is `(0,2,0)`. A consumer fallthrough class such as `.toast-stack { position: fixed; top: 1rem; right: 1rem }` (specificity `(0,1,0)`) does **not** override it. To restore custom positioning while a toast is queued, raise selector specificity by qualifying with `[popover]` or use `!important`: + +```css +[popover].toast-stack { + position: fixed; + top: 1rem; + right: 1rem; + inset: auto; + margin: 0; + width: auto; + height: auto; +} +``` + +`fs-toast` deliberately ships **no** inline `style` resets — inline style would block consumer overrides entirely. Override at the CSS layer instead. + +### Browser Baseline + +The Popover API requires Chrome ≥ 114, Firefox ≥ 125, Safari ≥ 17. Older browsers fall through the container's defensive try/catch — the toast queue still renders, just without top-layer promotion (and therefore without modal coexistence). + ## 0.1.1 ### Patch Changes diff --git a/packages/toast/package.json b/packages/toast/package.json index 38417fc..8b9f077 100644 --- a/packages/toast/package.json +++ b/packages/toast/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-toast", - "version": "0.1.1", + "version": "0.2.0", "description": "Component-agnostic toast queue service for Vue 3 — FIFO management, you bring the component", "homepage": "https://packages.script.nl/packages/toast", "license": "MIT", diff --git a/packages/toast/src/index.ts b/packages/toast/src/index.ts index bc9aa1c..b03fc15 100644 --- a/packages/toast/src/index.ts +++ b/packages/toast/src/index.ts @@ -1,7 +1,7 @@ import type {Component, VNode} from 'vue'; import type {ComponentProps} from 'vue-component-type-helpers'; -import {defineComponent, h, ref} from 'vue'; +import {defineComponent, h, onMounted, ref, watch} from 'vue'; /** Public API of a toast service instance. */ export interface ToastService { @@ -20,6 +20,11 @@ export interface ToastService { * the oldest toast is removed. Each toast component receives an `onClose` * prop that removes it from the queue when called. * + * The container promotes itself to the browser top layer (via the Popover API + * with `popover="manual"`) whenever at least one toast is queued, so toasts + * remain visible above `.showModal()` backdrops. The container demotes + * back to the normal stacking context when the queue empties. + * * @param component - The Vue component to render for each toast. * @param maxToasts - Maximum number of visible toasts (default: 4, minimum: 1). */ @@ -50,12 +55,51 @@ export const createToastService = (component: C, maxToasts const ToastContainerComponent = defineComponent({ name: 'ToastContainer', - render() { - return h( - 'div', - null, - toasts.value.map((toast) => toast.node), + setup() { + const containerRef = ref(null); + let isOpen = false; + + const showContainer = () => { + const el = containerRef.value; + if (!el || isOpen) return; + try { + el.showPopover(); + isOpen = true; + } catch { + // Popover API unsupported, or element already open under a different + // code path — leave isOpen false so a later attempt can retry. + } + }; + + const hideContainer = () => { + const el = containerRef.value; + if (!el || !isOpen) return; + try { + el.hidePopover(); + } catch { + // Popover API unsupported, or element already closed — fall through. + } + isOpen = false; + }; + + onMounted(() => { + if (toasts.value.length > 0) showContainer(); + }); + + watch( + () => toasts.value.length, + (length) => { + if (length > 0) showContainer(); + else hideContainer(); + }, ); + + return () => + h( + 'div', + {ref: containerRef, popover: 'manual'}, + toasts.value.map((toast) => toast.node), + ); }, }); diff --git a/packages/toast/tests/toast.spec.ts b/packages/toast/tests/toast.spec.ts index 6dd65b5..7e9bd32 100644 --- a/packages/toast/tests/toast.spec.ts +++ b/packages/toast/tests/toast.spec.ts @@ -1,10 +1,24 @@ import {shallowMount} from '@vue/test-utils'; -import {describe, expect, it} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {defineComponent, h, nextTick} from 'vue'; // @vitest-environment happy-dom import {createToastService} from '../src/index'; +// happy-dom does not implement the Popover API. Stub the three relevant methods +// on HTMLElement.prototype so the container's calls execute without throwing. +// We attach spies per-test (see beforeEach) so call counts are isolated. +// Per orders: do NOT switch the test runtime to jsdom — stub here. +type PopoverHTMLElement = HTMLElement & {showPopover: () => void; hidePopover: () => void}; + +const installPopoverStubs = () => { + const proto = HTMLElement.prototype as Partial; + proto.showPopover ??= function () {}; + proto.hidePopover ??= function () {}; +}; + +installPopoverStubs(); + const TestToast = defineComponent({ props: {message: String}, emits: ['close'], @@ -28,7 +42,7 @@ describe('toast service', () => { it('should return a valid Vue component', () => { const toastService = createToastService(TestToast); - expect(toastService.ToastContainerComponent).toHaveProperty('render'); + expect(toastService.ToastContainerComponent).toHaveProperty('setup'); expect(toastService.ToastContainerComponent.name).toBe('ToastContainer'); }); }); @@ -225,4 +239,185 @@ describe('toast service', () => { expect(wrapper2.text()).not.toContain('Service 1 toast'); }); }); + + describe('top-layer promotion', () => { + // Per-test spies — the prototype stubs from installPopoverStubs() get + // wrapped fresh each test so call counts don't leak across `it` blocks. + let showSpy: ReturnType; + let hideSpy: ReturnType; + + beforeEach(() => { + showSpy = vi.spyOn(HTMLElement.prototype, 'showPopover'); + hideSpy = vi.spyOn(HTMLElement.prototype, 'hidePopover'); + }); + + afterEach(() => { + // Restore spies so each test sees a clean prototype — vi.spyOn on a + // shared prototype accumulates wrappers across tests otherwise. + vi.restoreAllMocks(); + }); + + it('should render container with popover="manual" attribute', () => { + const toastService = createToastService(TestToast); + const wrapper = shallowMount(toastService.ToastContainerComponent); + + expect(wrapper.attributes('popover')).toBe('manual'); + }); + + it('should preserve single-root output (fragment fix from 0.1.1)', () => { + const toastService = createToastService(TestToast); + const wrapper = shallowMount(toastService.ToastContainerComponent, { + attrs: {class: 'toast-stack', 'data-test': 'fallthrough'}, + }); + + // Single root with popover attr AND fallthrough attrs landing on it. + expect(wrapper.element.tagName).toBe('DIV'); + expect(wrapper.attributes('popover')).toBe('manual'); + expect(wrapper.attributes('class')).toBe('toast-stack'); + expect(wrapper.attributes('data-test')).toBe('fallthrough'); + }); + + it('should call showPopover when first toast is added', async () => { + const toastService = createToastService(TestToast); + shallowMount(toastService.ToastContainerComponent); + + expect(showSpy).not.toHaveBeenCalled(); + + toastService.show({message: 'First'}); + await nextTick(); + + expect(showSpy).toHaveBeenCalledTimes(1); + }); + + it('should call hidePopover when last toast is removed', async () => { + const toastService = createToastService(TestToast); + shallowMount(toastService.ToastContainerComponent); + + const id = toastService.show({message: 'Only'}); + await nextTick(); + expect(hideSpy).not.toHaveBeenCalled(); + + toastService.hide(id); + await nextTick(); + + expect(hideSpy).toHaveBeenCalledTimes(1); + }); + + it('should not call showPopover repeatedly while popover is already open', async () => { + const toastService = createToastService(TestToast); + shallowMount(toastService.ToastContainerComponent); + + toastService.show({message: 'First'}); + await nextTick(); + toastService.show({message: 'Second'}); + await nextTick(); + toastService.show({message: 'Third'}); + await nextTick(); + + expect(showSpy).toHaveBeenCalledTimes(1); + }); + + it('should not call hidePopover when intermediate toast is removed', async () => { + const toastService = createToastService(TestToast); + shallowMount(toastService.ToastContainerComponent); + + toastService.show({message: 'First'}); + const middleId = toastService.show({message: 'Middle'}); + toastService.show({message: 'Last'}); + await nextTick(); + + toastService.hide(middleId); + await nextTick(); + + expect(hideSpy).not.toHaveBeenCalled(); + }); + + it('should call showPopover again after a hide -> show cycle', async () => { + const toastService = createToastService(TestToast); + shallowMount(toastService.ToastContainerComponent); + + const firstId = toastService.show({message: 'First'}); + await nextTick(); + expect(showSpy).toHaveBeenCalledTimes(1); + + toastService.hide(firstId); + await nextTick(); + expect(hideSpy).toHaveBeenCalledTimes(1); + + toastService.show({message: 'Second'}); + await nextTick(); + expect(showSpy).toHaveBeenCalledTimes(2); + }); + + it('should swallow InvalidStateError thrown by showPopover (already open)', async () => { + showSpy.mockImplementation(() => { + throw new DOMException('Already open', 'InvalidStateError'); + }); + const toastService = createToastService(TestToast); + shallowMount(toastService.ToastContainerComponent); + + expect(() => { + toastService.show({message: 'First'}); + }).not.toThrow(); + await nextTick(); + + expect(showSpy).toHaveBeenCalledTimes(1); + }); + + it('should swallow InvalidStateError thrown by hidePopover (already closed)', async () => { + hideSpy.mockImplementation(() => { + throw new DOMException('Already hidden', 'InvalidStateError'); + }); + const toastService = createToastService(TestToast); + shallowMount(toastService.ToastContainerComponent); + + const id = toastService.show({message: 'Only'}); + await nextTick(); + + expect(() => { + toastService.hide(id); + }).not.toThrow(); + await nextTick(); + + expect(hideSpy).toHaveBeenCalledTimes(1); + }); + + it('should retry showPopover on next transition when first attempt threw', async () => { + // First show throws (e.g. transient state); the failure leaves isOpen false + // so a subsequent 0 -> 1 transition retries. + showSpy.mockImplementationOnce(() => { + throw new DOMException('Transient', 'InvalidStateError'); + }); + const toastService = createToastService(TestToast); + shallowMount(toastService.ToastContainerComponent); + + const firstId = toastService.show({message: 'First'}); + await nextTick(); + expect(showSpy).toHaveBeenCalledTimes(1); + + // Cycle back to empty so the next show triggers another transition. + // Because the prior showPopover threw, isOpen is false — hideContainer + // must early-return and NOT call hidePopover (the popover never opened). + toastService.hide(firstId); + await nextTick(); + expect(hideSpy).not.toHaveBeenCalled(); + + toastService.show({message: 'Second'}); + await nextTick(); + expect(showSpy).toHaveBeenCalledTimes(2); + }); + + it('should call showPopover on mount when toasts already exist', async () => { + // Cover the onMounted branch: if a service already has toasts when the + // container mounts (e.g. show() called before mount), the container + // promotes itself on mount. + const toastService = createToastService(TestToast); + toastService.show({message: 'Pre-mount'}); + + shallowMount(toastService.ToastContainerComponent); + await nextTick(); + + expect(showSpy).toHaveBeenCalledTimes(1); + }); + }); });