From ee36e7195c329da584852996118c1465c8d2abb2 Mon Sep 17 00:00:00 2001 From: andrre-ls <43184224+andrre-ls@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:50:03 +0000 Subject: [PATCH 1/2] toaster --- src/foundations/ui/modal/modal.tsx | 12 + .../examples/toaster-description.preview.tsx | 25 ++ .../examples/toaster-drawer.preview.tsx | 49 ++++ .../examples/toaster-duration.preview.tsx | 43 +++ .../examples/toaster-variants.preview.tsx | 47 ++++ .../ui/toaster/examples/toaster.preview.tsx | 23 ++ src/foundations/ui/toaster/page.mdx | 57 ++++ src/foundations/ui/toaster/toaster.tsx | 264 ++++++++++++++++++ src/layouts/page.astro | 2 +- src/layouts/shell.astro | 2 + src/styles/markdown.css | 4 + 11 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 src/foundations/ui/toaster/examples/toaster-description.preview.tsx create mode 100644 src/foundations/ui/toaster/examples/toaster-drawer.preview.tsx create mode 100644 src/foundations/ui/toaster/examples/toaster-duration.preview.tsx create mode 100644 src/foundations/ui/toaster/examples/toaster-variants.preview.tsx create mode 100644 src/foundations/ui/toaster/examples/toaster.preview.tsx create mode 100644 src/foundations/ui/toaster/page.mdx create mode 100644 src/foundations/ui/toaster/toaster.tsx diff --git a/src/foundations/ui/modal/modal.tsx b/src/foundations/ui/modal/modal.tsx index b952694..f46e326 100644 --- a/src/foundations/ui/modal/modal.tsx +++ b/src/foundations/ui/modal/modal.tsx @@ -24,6 +24,18 @@ const useDialogElement = ( ) => { const ref = useRef(null); + // Emit modal-open or modal-close custom event when the dialog is opened or closed, + // so other components can react to it + useEffect(() => { + const openEvent = new CustomEvent( + open ? 'ui:modal-open' : 'ui:modal-close', + { + detail: { origin: ref.current }, + } + ); + window.dispatchEvent(openEvent); + }, [open]); + useLayoutEffect(() => { const element = ref.current; if (!element) return; diff --git a/src/foundations/ui/toaster/examples/toaster-description.preview.tsx b/src/foundations/ui/toaster/examples/toaster-description.preview.tsx new file mode 100644 index 0000000..bde1821 --- /dev/null +++ b/src/foundations/ui/toaster/examples/toaster-description.preview.tsx @@ -0,0 +1,25 @@ +import { Button } from '@/foundations/ui/button/button'; +import { toast } from '@/foundations/ui/toaster/toaster'; + +const ToasterDescriptionPreview = () => { + return ( +
+ +
+ ); +}; + +export default ToasterDescriptionPreview; diff --git a/src/foundations/ui/toaster/examples/toaster-drawer.preview.tsx b/src/foundations/ui/toaster/examples/toaster-drawer.preview.tsx new file mode 100644 index 0000000..7bcfe0b --- /dev/null +++ b/src/foundations/ui/toaster/examples/toaster-drawer.preview.tsx @@ -0,0 +1,49 @@ +import { Button } from '@/foundations/ui/button/button'; +import { Drawer } from '@/foundations/ui/drawer/drawer'; +import { toast } from '@/foundations/ui/toaster/toaster'; + +const ToasterDrawerPreview = () => { + return ( +
+ + + + + + +

This is a drawer content.

+ +
+ +
+
+
+
+ ); +}; + +export default ToasterDrawerPreview; diff --git a/src/foundations/ui/toaster/examples/toaster-duration.preview.tsx b/src/foundations/ui/toaster/examples/toaster-duration.preview.tsx new file mode 100644 index 0000000..829662a --- /dev/null +++ b/src/foundations/ui/toaster/examples/toaster-duration.preview.tsx @@ -0,0 +1,43 @@ +import { Button } from '@/foundations/ui/button/button'; +import { toast } from '@/foundations/ui/toaster/toaster'; + +const ToasterDurationPreview = () => { + return ( +
+ + + +
+ ); +}; + +export default ToasterDurationPreview; diff --git a/src/foundations/ui/toaster/examples/toaster-variants.preview.tsx b/src/foundations/ui/toaster/examples/toaster-variants.preview.tsx new file mode 100644 index 0000000..ccbd136 --- /dev/null +++ b/src/foundations/ui/toaster/examples/toaster-variants.preview.tsx @@ -0,0 +1,47 @@ +import { Button } from '@/foundations/ui/button/button'; +import { toast } from '@/foundations/ui/toaster/toaster'; + +const ToasterVariantsPreview = () => { + return ( +
+ + + +
+ ); +}; + +export default ToasterVariantsPreview; diff --git a/src/foundations/ui/toaster/examples/toaster.preview.tsx b/src/foundations/ui/toaster/examples/toaster.preview.tsx new file mode 100644 index 0000000..823c839 --- /dev/null +++ b/src/foundations/ui/toaster/examples/toaster.preview.tsx @@ -0,0 +1,23 @@ +import { Button } from '@/foundations/ui/button/button'; +import { toast } from '@/foundations/ui/toaster/toaster'; + +const ToasterPreview = () => { + return ( +
+ +
+ ); +}; + +export default ToasterPreview; diff --git a/src/foundations/ui/toaster/page.mdx b/src/foundations/ui/toaster/page.mdx new file mode 100644 index 0000000..ea74190 --- /dev/null +++ b/src/foundations/ui/toaster/page.mdx @@ -0,0 +1,57 @@ +--- +title: Toaster +description: A component for displaying transient messages to users, such as notifications or alerts. + +preview: toaster +files: + - src/foundations/ui/toaster/toaster.tsx + +dependencies: + - name: motion + href: https://motion.dev + +folder: UI +--- + +> This component relies on custom events emitted by the `Modal` component. Ensure your `Modal` implementation dispatches the `ui:modal-open` and `ui:modal-close` events accordingly. + +## Usage + +Render the `Toaster` component at the root of your application. + +```tsx +import { Toaster } from "@/components/ui/toaster"; + +function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + + ); +} +``` + +Then, you can use the `toast` function to display messages. + +```tsx +import { toast } from "@/components/ui/toaster"; + +toast({ title: "This is a toast message!" }); +``` + +## Example + +### With Description + + + +### Variants + + + +### Duration + + diff --git a/src/foundations/ui/toaster/toaster.tsx b/src/foundations/ui/toaster/toaster.tsx new file mode 100644 index 0000000..77ab32e --- /dev/null +++ b/src/foundations/ui/toaster/toaster.tsx @@ -0,0 +1,264 @@ +import { CheckCircleIcon, XCircleIcon, XIcon } from '@phosphor-icons/react'; +import { AnimatePresence, motion } from 'motion/react'; +import type { ComponentPropsWithoutRef } from 'react'; +import { useEffect, useSyncExternalStore } from 'react'; +import { useTopLayer } from '@/foundations/hooks/use-top-layer/use-top-layer'; +import { cn } from '@/lib/utils/classnames'; + +const DEFAULT_TOAST_DURATION_MS = 7000; + +type ToastVariant = 'default' | 'positive' | 'negative'; + +type Toast = { + id: string; + title: string; + description?: string; + duration?: number; + variant?: ToastVariant; +}; + +type ToastStore = { + toasts: Toast[]; + subscribe: (listener: () => void) => () => void; + add: (config: Omit) => void; + remove: (id: string) => void; + pauseAll: () => void; + resumeAll: () => void; +}; + +const createToastStore = (): ToastStore => { + let toasts: Toast[] = []; + const listeners = new Set<() => void>(); + + const timers = new Map< + string, + { + startTime: number; + remainingTime: number; + timeoutId: ReturnType | null; + } + >(); + + const getToastId = () => { + return Date.now().toString() + Math.random().toString(36).slice(2, 9); + }; + + const notifyListeners = () => { + listeners.forEach((listener) => { + listener(); + }); + }; + + return { + get toasts() { + return toasts; + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + add(toastData) { + const id = getToastId(); + const newToast = { ...toastData, id }; + + toasts = [...toasts, newToast]; + notifyListeners(); + + const duration = toastData.duration ?? DEFAULT_TOAST_DURATION_MS; + + const timeout = + duration !== Infinity + ? setTimeout(() => this.remove(id), duration) + : null; + + timers.set(id, { + startTime: Date.now(), + remainingTime: duration, + timeoutId: timeout, + }); + }, + remove(id) { + toasts = toasts.filter((toast) => toast.id !== id); + notifyListeners(); + + const timer = timers.get(id); + if (timer?.timeoutId) { + clearTimeout(timer.timeoutId); + } + timers.delete(id); + }, + pauseAll() { + timers.forEach((timer) => { + if (timer.timeoutId) { + clearTimeout(timer.timeoutId); + timer.timeoutId = null; + timer.remainingTime -= Date.now() - timer.startTime; + } + }); + }, + resumeAll() { + timers.forEach((timer, id) => { + if (timer.timeoutId === null && timer.remainingTime > 0) { + timer.startTime = Date.now(); + + if (timer.remainingTime !== Infinity) { + timer.timeoutId = setTimeout( + () => this.remove(id), + timer.remainingTime + ); + } + } + }); + }, + }; +}; + +// Create a singleton toast store that can be shared across the application. +// The store is attached to the global object to ensure it's shared across different modules and components. +// +// The main reason behind this is Astro's island architecture, where different parts of the UI can be rendered and hydrated independently, +// leading to multiple instances of the toast store. In other frameworks, this is not necessary but also doesn't cause any issues. +const STORE_KEY = '__significa_toast_store__'; +const toastStore: ToastStore = + ((globalThis as Record)[STORE_KEY] as ToastStore) ?? + (() => { + const store = createToastStore(); + (globalThis as Record)[STORE_KEY] = store; + return store; + })(); + +// Hook to use the toast store +const useToastStore = () => { + return useSyncExternalStore( + toastStore.subscribe, + () => toastStore.toasts, + () => toastStore.toasts + ); +}; + +const toast = (toast: Omit) => { + toastStore.add(toast); +}; + +const Toaster = ({ className }: { className?: string }) => { + const toasts = useToastStore(); + const ref = useTopLayer(true); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + // Modals use the DOM top-layer (dialogs, drawers, etc.), where stacking order is + // determined by open order and DOM position. To appear above a modal, the toaster + // must be the last element in the DOM and opened after the modal. + // To ensure this, we listen to modal open events and toggle the popover state of the toaster, + // which moves it to the end of the top-layer and top of the stacking context. + const onModalOpen = () => { + element?.togglePopover(); + setTimeout(() => element?.togglePopover(), 0); + }; + + window.addEventListener('ui:modal-open', onModalOpen); + + return () => { + window.removeEventListener('ui:modal-open', onModalOpen); + }; + }, [ref]); + + return ( +
+ + {toasts.map((toast, index) => { + return ( + toastStore.pauseAll()} + onMouseLeave={() => toastStore.resumeAll()} + style={{ zIndex: toasts.length - index }} + initial={{ height: 0 }} + animate={{ height: 'auto' }} + exit={{ height: 0 }} + transition={{ type: 'spring', bounce: 0.2, duration: 0.5 }} + className="pointer-events-auto box-border *:my-1.5" + > + toastStore.remove(toast.id)} + /> + + ); + })} + +
+ ); +}; + +type ToasterItemProps = { + toast: Toast; + onDismiss: () => void; +}; + +const ToasterItem = ({ toast, onDismiss }: ToasterItemProps) => { + const { title, description, variant = 'default' } = toast; + + const Icon = { + default: null, + positive: CheckCircleIcon, + negative: XCircleIcon, + }[variant]; + + return ( + + {Icon && } +
+

{title}

+ {description && ( +

{description}

+ )} +
+ +
+ ); +}; + +const ToastCloseButton = ({ + onClick, +}: Omit, 'children' | 'type'>) => { + return ( + + ); +}; + +export { Toaster, toast }; diff --git a/src/layouts/page.astro b/src/layouts/page.astro index 4de5bcc..b4f8cae 100644 --- a/src/layouts/page.astro +++ b/src/layouts/page.astro @@ -37,7 +37,7 @@ const items = await getNavigationItems(pages);
diff --git a/src/layouts/shell.astro b/src/layouts/shell.astro index 0433512..03443f4 100644 --- a/src/layouts/shell.astro +++ b/src/layouts/shell.astro @@ -3,6 +3,7 @@ import { ClientRouter } from "astro:transitions"; import Posthog from "@/components/posthog.astro"; import "@/styles/global.css"; +import { Toaster } from "@/foundations/ui/toaster/toaster"; type Props = { meta?: { @@ -47,6 +48,7 @@ const description = + {import.meta.env.PROD && } diff --git a/src/styles/markdown.css b/src/styles/markdown.css index 964994b..5687621 100644 --- a/src/styles/markdown.css +++ b/src/styles/markdown.css @@ -77,5 +77,9 @@ &:is(code) { @apply text-[0.95em] font-mono bg-foreground/4 text-foreground/80 rounded-sm border px-1 py-0.5 font-[0.95em]; } + + &:is(blockquote) { + @apply rounded-md; + } } } From 29c9c33ccbe3a6d26ba814b67625d51ae44a1005 Mon Sep 17 00:00:00 2001 From: andrre-ls <43184224+andrre-ls@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:02:22 +0000 Subject: [PATCH 2/2] fix pr review --- src/foundations/ui/modal/modal.tsx | 3 +++ src/foundations/ui/toaster/toaster.tsx | 24 +++++++++++++++--------- src/styles/markdown.css | 6 +----- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/foundations/ui/modal/modal.tsx b/src/foundations/ui/modal/modal.tsx index f46e326..5c4656c 100644 --- a/src/foundations/ui/modal/modal.tsx +++ b/src/foundations/ui/modal/modal.tsx @@ -27,6 +27,9 @@ const useDialogElement = ( // Emit modal-open or modal-close custom event when the dialog is opened or closed, // so other components can react to it useEffect(() => { + const origin = ref.current; + if (!origin) return; + const openEvent = new CustomEvent( open ? 'ui:modal-open' : 'ui:modal-close', { diff --git a/src/foundations/ui/toaster/toaster.tsx b/src/foundations/ui/toaster/toaster.tsx index 77ab32e..e21b65b 100644 --- a/src/foundations/ui/toaster/toaster.tsx +++ b/src/foundations/ui/toaster/toaster.tsx @@ -92,20 +92,26 @@ const createToastStore = (): ToastStore => { if (timer.timeoutId) { clearTimeout(timer.timeoutId); timer.timeoutId = null; - timer.remainingTime -= Date.now() - timer.startTime; + + const elapsed = Date.now() - timer.startTime; + timer.remainingTime = Math.max(0, timer.remainingTime - elapsed); } }); }, resumeAll() { timers.forEach((timer, id) => { - if (timer.timeoutId === null && timer.remainingTime > 0) { - timer.startTime = Date.now(); + if (timer.timeoutId === null) { + if (timer.remainingTime > 0) { + timer.startTime = Date.now(); - if (timer.remainingTime !== Infinity) { - timer.timeoutId = setTimeout( - () => this.remove(id), - timer.remainingTime - ); + if (timer.remainingTime !== Infinity) { + timer.timeoutId = setTimeout( + () => this.remove(id), + timer.remainingTime + ); + } + } else { + this.remove(id); } } }); @@ -223,7 +229,7 @@ const ToasterItem = ({ toast, onDismiss }: ToasterItemProps) => { variant === 'positive' && 'border-green-200 bg-green-50 text-green-900', variant === 'negative' && 'border-red-200 bg-red-50 text-red-900' )} - role="alert" + role="status" aria-live="polite" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} diff --git a/src/styles/markdown.css b/src/styles/markdown.css index 5687621..7e5c595 100644 --- a/src/styles/markdown.css +++ b/src/styles/markdown.css @@ -55,7 +55,7 @@ } &:is(blockquote) { - @apply bg-foreground/4 border-foreground/8 my-6 border-l-4 px-4 py-2; + @apply bg-foreground/4 border-foreground/8 my-6 border-l-4 px-4 py-2 rounded-md; } &:is(img) { @@ -77,9 +77,5 @@ &:is(code) { @apply text-[0.95em] font-mono bg-foreground/4 text-foreground/80 rounded-sm border px-1 py-0.5 font-[0.95em]; } - - &:is(blockquote) { - @apply rounded-md; - } } }