;
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)}
/>
);
})}