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
31 changes: 31 additions & 0 deletions docs/packages/toast.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dialog>.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).
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions packages/toast/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<dialog>.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 `<div>`.

### 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
Expand Down
2 changes: 1 addition & 1 deletion packages/toast/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
56 changes: 50 additions & 6 deletions packages/toast/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<C extends Component> {
Expand All @@ -20,6 +20,11 @@ export interface ToastService<C extends Component> {
* 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 `<dialog>.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).
*/
Expand Down Expand Up @@ -50,12 +55,51 @@ export const createToastService = <C extends Component>(component: C, maxToasts

const ToastContainerComponent = defineComponent({
name: 'ToastContainer',
render() {
return h(
'div',
null,
toasts.value.map((toast) => toast.node),
setup() {
const containerRef = ref<HTMLElement | null>(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),
);
},
});

Expand Down
Loading