import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, createSignal, omit, untrack } from "solid-js"; import { activeElement } from "../../internals/dom/active-element"; import { contains } from "../../internals/dom/contains"; import { ownerDocument } from "../../internals/dom/owner-document"; import { mergeRootProps } from "../../internals/merge-root-props"; import { useOpenChangeComplete } from "../../internals/popups/use-open-change-complete"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import { createStableCallback } from "../../internals/stable-callback"; import type { BaseUIComponentProps } from "../../internals/types"; import { useIsoLayoutEffect } from "../../internals/use-iso-layout-effect"; import { type SwipeDirection, useSwipeDismiss, } from "../../internals/use-swipe-dismiss"; import { useToastProviderContext } from "../provider/toast-provider-context"; import type { ToastObject } from "../use-toast-manager"; import { ToastRootContext, type ToastRootContextValue, type ToastRootState, } from "./toast-root-context"; export type { ToastRootState }; const TOAST_INDEX = "--toast-index"; const TOAST_OFFSET_Y = "--toast-offset-y"; const TOAST_HEIGHT = "--toast-height"; const SWIPE_MOVEMENT_X = "--toast-swipe-movement-x"; const SWIPE_MOVEMENT_Y = "--toast-swipe-movement-y"; function getToastStackIndices( toasts: ToastObject[], id: string, ): { domIndex: number; visibleIndex: number; offsetY: number } { let visibleIndex = 0; let offsetY = 0; let domIndex = -1; let visibleIdx = -1; let offset = 0; for (let i = 0; i < toasts.length; i++) { const item = toasts[i]; if (!item) continue; const isEnding = item.transitionStatus === "ending"; if (item.id === id) { domIndex = i; visibleIdx = isEnding ? -1 : visibleIndex; offset = offsetY; } offsetY += item.height ?? 0; if (!isEnding) { visibleIndex += 1; } } return { domIndex, visibleIndex: visibleIdx, offsetY: offset }; } export interface ToastRootProps extends BaseUIComponentProps<"div", ToastRootState> { as?: ValidComponent; toast: ToastObject; swipeDirection?: | "up" | "down" | "left" | "right" | Array<"up" | "down" | "left" | "right"> | undefined; } export function ToastRoot(props: ToastRootProps): JSX.Element { const store = useToastProviderContext(); // `equals: false` because the store mutates a toast's `height` / // `transitionStatus` IN PLACE (to keep the keyed `` row mounted — see // `updateToastInternal`). `find` would otherwise return the same object // reference and the memo, with default identity equality, would not // re-notify style/`data-*` readers when those fields change. Forcing // notification on every queue write keeps the CSS height var and transition // attributes live without resurrecting the row-rebuild feedback loop. const toast = createMemo( (): ToastObject => { const id = props.toast.id; return store.toasts().find((t) => t.id === id) ?? props.toast; }, { equals: false }, ); const stackIndices = createMemo(() => getToastStackIndices(store.toasts(), toast().id), ); const [titleId, setTitleId] = createSignal(undefined, { ownedWrite: true, }); const [descriptionId, setDescriptionId] = createSignal( undefined, { ownedWrite: true }, ); let rootEl: HTMLDivElement | null = null; const rootRef = { get current(): HTMLElement | null { return rootEl; }, set current(value: HTMLElement | null) { rootEl = value as HTMLDivElement | null; }, }; useOpenChangeComplete({ open: () => toast().transitionStatus !== "ending", ref: rootRef, onComplete() { if (toast().transitionStatus === "ending") { store.removeToast(toast().id); } }, }); // Matches React `ToastRoot.recalculateHeight` / Solid v1 `setHeights`. const recalculateHeight = createStableCallback(() => () => { const el = rootEl; if (!el) return; const previousHeight = el.style.height; el.style.height = "auto"; const height = el.offsetHeight; el.style.height = previousHeight; const current = untrack(toast); // Use the resolved (untracked) toast id rather than reading the reactive // `props.toast` getter directly — `recalculateHeight` runs from effect // and ref scopes, where a tracked read trips `[STRICT_READ_UNTRACKED]`. // `current.id` equals `props.toast.id` (the memo matches by id). store.updateToastInternal(current.id, { height, ...(current.transitionStatus === "starting" ? { transitionStatus: undefined } : {}), }); }); // `rootEl` is a plain let — track mount via ref + layout effect keyed on // the toast's id. Split-effect applies re-run on every compute // RE-EXECUTION (not when the compute's return value changes), so the // compute must read a value that only notifies on a genuine id change. // Reading `toast().id` directly would subscribe to the `toast` memo, which // returns a fresh object on every queue write and thus notifies on each // one — and because the apply calls `recalculateHeight` (which WRITES the // queue), that is a self-feeding loop. In happy-dom the height is constant // so `updateToastInternal`'s no-op guard halts it, but in a real browser // sub-pixel measurement oscillation defeats the guard and the effect spins // at 100% CPU. Gate through a memo so the apply only fires when the id // actually changes (i.e. once, at mount). const toastId = createMemo(() => toast().id); useIsoLayoutEffect( () => toastId(), () => { if (rootEl) recalculateHeight(); }, ); const isHighPriority = (): boolean => toast().priority === "high"; function handleKeyDown(event: KeyboardEvent): void { if (event.key !== "Escape") return; const el = rootEl; if (!el) return; if (!contains(el, activeElement(ownerDocument(el)) as Element | null)) return; store.closeToast(toast().id); } const initialSwipeDirection = props.swipeDirection ?? ["down", "right"]; const swipeDirections: SwipeDirection[] = Array.isArray(initialSwipeDirection) ? initialSwipeDirection : [initialSwipeDirection]; const swipeEnabled = swipeDirections.length > 0; const swipe = useSwipeDismiss({ enabled: () => swipeEnabled, directions: swipeDirections, elementRef: () => rootEl, movementCssVars: { x: SWIPE_MOVEMENT_X, y: SWIPE_MOVEMENT_Y }, onDismiss(_event, _details) { store.closeToast(toast().id); }, }); const visibleIndex = createMemo(() => stackIndices().visibleIndex); const contextValue: ToastRootContextValue = { toast, titleId, setTitleId, descriptionId, setDescriptionId, expanded: store.expanded, visibleIndex, recalculateHeight, }; const userClassStyle = resolveClassStyle(props, () => ({ transitionStatus: toast().transitionStatus, expanded: store.expanded(), limited: Boolean(toast().limited), type: toast().type, swiping: swipe.swiping(), swipeDirection: swipe.swipeDirection(), })); const userHtmlProps = omit( props, "as", "class", "style", "attrs", "toast", "swipeDirection", "children", ) as Record; function forwardMouseEventToViewport( type: "mouseenter" | "mouseleave", ): void { const vp = store.viewport(); if (vp) vp.dispatchEvent(new Event(type)); } const internalProps = { ref: (el: HTMLDivElement | null) => { rootEl = el; if (el) recalculateHeight(); }, onMouseEnter: () => forwardMouseEventToViewport("mouseenter"), onMouseLeave: () => forwardMouseEventToViewport("mouseleave"), tabIndex: 0, "aria-modal": false, get role() { return isHighPriority() ? "alertdialog" : "dialog"; }, get "aria-labelledby"() { return titleId() ?? ""; }, get "aria-describedby"() { return descriptionId() ?? ""; }, get "aria-hidden"() { return isHighPriority() && !store.focused() ? "true" : undefined; }, onKeyDown: handleKeyDown, get onPointerDown() { return swipe.getPointerProps().onPointerDown; }, get onPointerMove() { return swipe.getPointerProps().onPointerMove; }, get onPointerUp() { return swipe.getPointerProps().onPointerUp; }, get onPointerCancel() { return swipe.getPointerProps().onPointerCancel; }, get style(): JSX.CSSProperties { const dragStyles = swipe.getDragStyles(); const current = toast(); const { domIndex, visibleIndex, offsetY } = stackIndices(); const index = current.transitionStatus === "ending" ? domIndex : visibleIndex; return { ...dragStyles, [TOAST_INDEX]: index, [TOAST_OFFSET_Y]: `${offsetY}px`, ...(current.height != null && current.height > 0 ? { [TOAST_HEIGHT]: `${current.height}px` } : {}), }; }, get "data-starting-style"(): "" | undefined { return toast().transitionStatus === "starting" ? "" : undefined; }, get "data-ending-style"(): "" | undefined { return toast().transitionStatus === "ending" ? "" : undefined; }, get "data-swipe-direction"() { return swipe.swipeDirection(); }, get "data-expanded"(): "" | undefined { return store.expanded() ? "" : undefined; }, get "data-limited"(): "" | undefined { return toast().limited ? "" : undefined; }, get "data-swiping"(): "" | undefined { return swipe.swiping() ? "" : undefined; }, get "data-type"() { return toast().type; }, get inert(): boolean | undefined { return toast().limited ? true : undefined; }, }; const merged = mergeRootProps( mergeRootProps(internalProps, userClassStyle), userHtmlProps, ); const Tag = dynamic(() => props.as ?? "div"); return ( {props.children} ); }