From ed8b66ca6678cea33910118dc02cfb2d0335ad09 Mon Sep 17 00:00:00 2001 From: NatHacks Date: Sun, 22 Feb 2026 20:39:55 +0100 Subject: [PATCH] feat: add toast navigation - Add `< >` nav arrows embedded inside the pill to cycle through multiple simultaneous toasts per position - Add `navigation` prop on `Toaster` to opt-in (default false) - Auto-reset selection to newest toast when a new one arrives --- src/icons.tsx | 12 +++++++++++ src/sileo.tsx | 38 +++++++++++++++++++++++++++++++++- src/styles.css | 40 ++++++++++++++++++++++++++++++++++-- src/toast.tsx | 55 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/src/icons.tsx b/src/icons.tsx index afb30dc..175bdb7 100644 --- a/src/icons.tsx +++ b/src/icons.tsx @@ -66,3 +66,15 @@ export const Check = () => ( ); + +export const ChevronLeft = () => ( + + + +); + +export const ChevronRight = () => ( + + + +); diff --git a/src/sileo.tsx b/src/sileo.tsx index 5c2ff6a..021e5b8 100644 --- a/src/sileo.tsx +++ b/src/sileo.tsx @@ -26,6 +26,8 @@ import { import { ArrowRight, Check, + ChevronLeft, + ChevronRight, CircleAlert, LifeBuoy, LoaderCircle, @@ -68,6 +70,9 @@ interface SileoProps { onMouseEnter?: MouseEventHandler; onMouseLeave?: MouseEventHandler; onDismiss?: () => void; + navIndex?: number; + navTotal?: number; + onNavigate?: (dir: -1 | 1) => void; } /* ---------------------------------- Icons --------------------------------- */ @@ -136,6 +141,9 @@ export const Sileo = memo(function Sileo({ onMouseEnter, onMouseLeave, onDismiss, + navIndex, + navTotal, + onNavigate, }: SileoProps) { const next: View = useMemo( () => ({ title, description, state, icon, styles, button, fill }), @@ -385,6 +393,8 @@ export const Sileo = memo(function Sileo({ /* ------------------------------ Derived values ---------------------------- */ + const showNav = navTotal !== undefined && navTotal > 1; + const minExpanded = HEIGHT * MIN_EXPAND_RATIO; const rawExpanded = hasDesc ? Math.max(minExpanded, HEIGHT + contentHeight) @@ -398,7 +408,8 @@ export const Sileo = memo(function Sileo({ const expanded = open ? rawExpanded : frozenExpandedRef.current; const svgHeight = hasDesc ? Math.max(expanded, minExpanded) : HEIGHT; const expandedContent = Math.max(0, expanded - HEIGHT); - const resolvedPillWidth = Math.max(pillWidth || HEIGHT, HEIGHT); + const navExtra = showNav ? 50 : 0; + const resolvedPillWidth = Math.max(pillWidth || HEIGHT, HEIGHT) + navExtra; const pillHeight = HEIGHT + blur * 3; const pillX = @@ -548,6 +559,7 @@ export const Sileo = memo(function Sileo({ if (exiting || !onDismiss) return; const target = e.target as HTMLElement; if (target.closest("[data-sileo-button]")) return; + if (target.closest("[data-sileo-nav]")) return; pointerStartRef.current = e.clientY; e.currentTarget.setPointerCapture(e.pointerId); const el = buttonRef.current; @@ -656,6 +668,30 @@ export const Sileo = memo(function Sileo({ )} + {showNav && ( +
+ + +
+ )} {hasDesc && ( diff --git a/src/styles.css b/src/styles.css index c5a428a..342a698 100644 --- a/src/styles.css +++ b/src/styles.css @@ -149,14 +149,14 @@ overflow: hidden; left: var(--_px, 0px); transform: var(--_ht); - max-width: var(--_pw); + width: var(--_pw); } [data-sileo-toast][data-ready="true"] [data-sileo-header] { transition: transform var(--sileo-duration) var(--sileo-spring-easing), left var(--sileo-duration) var(--sileo-spring-easing), - max-width var(--sileo-duration) var(--sileo-spring-easing); + width var(--sileo-duration) var(--sileo-spring-easing); } [data-sileo-header][data-edge="top"] { @@ -448,6 +448,42 @@ align-items: center; } +/* ------------------------------ Navigation -------------------------------- */ + +[data-sileo-nav] { + display: flex; + align-items: center; + gap: 0.125rem; + margin-left: auto; + flex-shrink: 0; + color: #fff; + pointer-events: auto; + user-select: none; +} + +[data-sileo-nav] button { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border: 0; + border-radius: 9999px; + background: transparent; + cursor: pointer; + color: inherit; + padding: 0; +} + +[data-sileo-nav] button:not(:disabled):hover { + background-color: rgba(255, 255, 255, 0.12); +} + +[data-sileo-nav] button:disabled { + opacity: 0.3; + cursor: default; +} + @media (prefers-reduced-motion: no-preference) { [data-sileo-toast][data-ready="true"]:hover, [data-sileo-toast][data-ready="true"][data-exiting="true"] { diff --git a/src/toast.tsx b/src/toast.tsx index 64e7a2b..b5dce28 100644 --- a/src/toast.tsx +++ b/src/toast.tsx @@ -48,6 +48,7 @@ export interface SileoToasterProps { offset?: SileoOffsetValue | SileoOffsetConfig; options?: Partial; theme?: "light" | "dark" | "system"; + navigation?: boolean; } /* ------------------------------ Global State ------------------------------ */ @@ -132,7 +133,7 @@ const createToast = (options: InternalSileoOptions) => { const live = store.toasts.filter((t) => !t.exiting); const merged = mergeOptions(options); - const id = merged.id ?? "sileo-default"; + const id = merged.id ?? generateId(); const prev = live.find((t) => t.id === id); const item = buildSileoItem(merged, id, prev?.position); @@ -252,15 +253,18 @@ export function Toaster({ offset, options, theme, + navigation = false, }: SileoToasterProps) { const resolvedTheme = useResolvedTheme(theme); const [toasts, setToasts] = useState(store.toasts); const [activeId, setActiveId] = useState(); + const [selectedIds, setSelectedIds] = useState>>({}); const hoverRef = useRef(false); const timersRef = useRef(new Map()); const listRef = useRef(toasts); const latestRef = useRef(undefined); + const prevLiveIdsRef = useRef(new Set()); const handlersCache = useRef( new Map< string, @@ -325,6 +329,20 @@ export function Toaster({ if (!toastIds.has(id)) handlersCache.current.delete(id); } + const currentLive = toasts.filter((t) => !t.exiting); + const newToasts = currentLive.filter((t) => !prevLiveIdsRef.current.has(t.id)); + if (newToasts.length > 0) { + setSelectedIds((prev) => { + const next = { ...prev }; + for (const t of newToasts) { + const pos = t.position ?? store.position; + delete next[pos]; + } + return next; + }); + } + prevLiveIdsRef.current = new Set(currentLive.map((t) => t.id)); + schedule(toasts); }, [toasts, schedule]); @@ -420,6 +438,26 @@ export function Toaster({ return map; }, [toasts, position]); + const navigate = useCallback( + (pos: SileoPosition, dir: -1 | 1) => { + const items = activePositions.get(pos) ?? []; + const live = items.filter((t) => !t.exiting); + setSelectedIds((prev) => { + const rawSel = prev[pos]; + const curId = + rawSel && live.find((t) => t.id === rawSel) + ? rawSel + : live.at(-1)?.id; + const curIdx = live.findIndex((t) => t.id === curId); + const nextIdx = Math.max(0, Math.min(live.length - 1, curIdx + dir)); + const nextId = live[nextIdx]?.id; + if (!nextId || nextId === prev[pos]) return prev; + return { ...prev, [pos]: nextId }; + }); + }, + [activePositions], + ); + return ( <> {children} @@ -427,6 +465,15 @@ export function Toaster({ const pill = pillAlign(pos); const expand = expandDir(pos); + const live = items.filter((t) => !t.exiting); + const rawSel = selectedIds[pos]; + const selId = + rawSel && items.find((t) => t.id === rawSel) + ? rawSel + : (live.at(-1) ?? items.at(-1))?.id; + const displayItem = items.find((t) => t.id === selId); + const showNav = live.length > 1 && !displayItem?.exiting; + return (
{items.map((item) => { + const isSelected = item.id === selId; + if (!isSelected && !item.exiting) return null; const h = getHandlers(item.id); + const selIdx = live.findIndex((t) => t.id === selId); return ( navigate(pos, dir)} /> ); })}