@pablozaiden/webapp 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,44 @@
1
- import type { ThemePreference, WebAppConfigResponse } from "../../contracts";
1
+ import { useState } from "react";
2
+ import type { LogLevelName, WebAppConfigResponse } from "../../contracts";
2
3
  import { appJson } from "../api-client";
3
4
  import { Badge, Button, ErrorState, FormSection, LoadingState, SelectField } from "../components";
5
+ import { useLogLevel } from "../log-level";
6
+ import { isThemePreference, useTheme } from "../theme";
7
+
8
+ const LOG_LEVEL_NAMES = ["trace", "debug", "info", "warn", "error"] as const satisfies readonly LogLevelName[];
9
+
10
+ function isLogLevelName(value: string): value is LogLevelName {
11
+ return LOG_LEVEL_NAMES.some((level) => level === value);
12
+ }
4
13
 
5
14
  export interface AccountSectionProps {
6
15
  config: WebAppConfigResponse;
7
- theme: ThemePreference;
8
- setTheme: (theme: ThemePreference) => void;
9
- themeLoading: boolean;
10
- themeLoadError?: Error;
11
- retryThemeLoad: () => Promise<void>;
12
16
  refresh: () => Promise<void>;
13
17
  setError: (error: string | undefined) => void;
14
18
  }
15
19
 
16
- export function AccountSection({ config, theme, setTheme, themeLoading, themeLoadError, retryThemeLoad, refresh, setError }: AccountSectionProps) {
20
+ export function AccountSection({ config, refresh, setError }: AccountSectionProps) {
21
+ const { preference, setPreference, loading, error, retry } = useTheme();
22
+ const logLevel = useLogLevel();
23
+ const [logLevelSaving, setLogLevelSaving] = useState(false);
24
+
25
+ async function updateLogLevel(value: string) {
26
+ if (!isLogLevelName(value)) {
27
+ setError(`Unknown log level: ${value}`);
28
+ return;
29
+ }
30
+ try {
31
+ setError(undefined);
32
+ setLogLevelSaving(true);
33
+ await appJson<unknown>("/api/preferences/log-level", { method: "PUT", body: JSON.stringify({ level: value }) });
34
+ await refresh();
35
+ } catch (err) {
36
+ setError(err instanceof Error ? err.message : String(err));
37
+ } finally {
38
+ setLogLevelSaving(false);
39
+ }
40
+ }
41
+
17
42
  return (
18
43
  <>
19
44
  {config.currentUser ? (
@@ -22,29 +47,47 @@ export function AccountSection({ config, theme, setTheme, themeLoading, themeLoa
22
47
  </FormSection>
23
48
  ) : null}
24
49
  <FormSection title="Display Settings">
25
- <SelectField label="Theme" value={theme} onChange={(event) => {
26
- const next = event.currentTarget.value as ThemePreference;
27
- setTheme(next);
50
+ <SelectField label="Theme" value={preference} onChange={(event) => {
51
+ const next = event.currentTarget.value;
52
+ if (!isThemePreference(next)) {
53
+ setError(`Unknown theme preference: ${next}`);
54
+ return;
55
+ }
56
+ setPreference(next);
28
57
  void appJson("/api/preferences/theme", { method: "PUT", body: JSON.stringify({ theme: next }) }).catch((err) => setError(String(err)));
29
58
  }}>
30
59
  <option value="system">System</option>
31
60
  <option value="light">Light</option>
32
61
  <option value="dark">Dark</option>
33
62
  </SelectField>
34
- {themeLoading ? <LoadingState title="Loading saved theme" /> : null}
35
- {themeLoadError ? (
63
+ {loading ? <LoadingState title="Loading saved theme" /> : null}
64
+ {error ? (
36
65
  <ErrorState
37
- description={themeLoadError.message}
38
- action={<Button type="button" loading={themeLoading} onClick={() => void retryThemeLoad()}>Retry</Button>}
66
+ description={error.message}
67
+ action={<Button type="button" loading={loading} onClick={() => void retry()}>Retry</Button>}
39
68
  />
40
69
  ) : null}
41
70
  </FormSection>
42
71
 
43
72
  {config.currentUser?.isAdmin ? (
44
73
  <FormSection title="Developer Settings">
45
- <SelectField label={config.logLevel.fromEnv ? `Log level (${config.logLevel.level}, controlled by env)` : "Log level"} value={config.logLevel.level} disabled={config.logLevel.fromEnv} onChange={(event) => void appJson("/api/preferences/log-level", { method: "PUT", body: JSON.stringify({ level: event.currentTarget.value }) }).then(refresh)}>
46
- {["trace", "debug", "info", "warn", "error"].map((level) => <option key={level} value={level}>{level}</option>)}
47
- </SelectField>
74
+ {logLevel.error ? (
75
+ <ErrorState
76
+ description={logLevel.error.message}
77
+ action={<Button type="button" loading={logLevel.loading} onClick={() => void logLevel.retry()}>Retry</Button>}
78
+ />
79
+ ) : null}
80
+ {logLevel.loading && logLevel.level === undefined ? <LoadingState title="Loading log level" /> : null}
81
+ {logLevel.level !== undefined ? (
82
+ <SelectField
83
+ label={logLevel.fromEnv ? `Log level (${logLevel.level}, controlled by env)` : "Log level"}
84
+ value={logLevel.level}
85
+ disabled={logLevel.fromEnv === true || logLevel.loading || logLevelSaving}
86
+ onChange={(event) => void updateLogLevel(event.currentTarget.value)}
87
+ >
88
+ {LOG_LEVEL_NAMES.map((level) => <option key={level} value={level}>{level}</option>)}
89
+ </SelectField>
90
+ ) : null}
48
91
  </FormSection>
49
92
  ) : null}
50
93
  </>
@@ -1,5 +1,5 @@
1
1
  import { useState, type ReactNode } from "react";
2
- import type { ThemePreference, WebAppConfigResponse } from "../../contracts";
2
+ import type { WebAppConfigResponse } from "../../contracts";
3
3
  import { Button, DangerZone, FormSection } from "../components";
4
4
  import type { SettingsRow, SettingsScope, SettingsSection } from "../root-types";
5
5
  import { AccountSection } from "./account-section";
@@ -64,20 +64,15 @@ export interface SettingsViewProps {
64
64
  config: WebAppConfigResponse;
65
65
  refresh: () => Promise<void>;
66
66
  customSections: SettingsSection[];
67
- theme: ThemePreference;
68
- setTheme: (theme: ThemePreference) => void;
69
- themeLoading: boolean;
70
- themeLoadError?: Error;
71
- retryThemeLoad: () => Promise<void>;
72
67
  }
73
68
 
74
- export function SettingsView({ config, refresh, customSections, theme, setTheme, themeLoading, themeLoadError, retryThemeLoad }: SettingsViewProps) {
69
+ export function SettingsView({ config, refresh, customSections }: SettingsViewProps) {
75
70
  const [error, setError] = useState<string>();
76
71
 
77
72
  return (
78
73
  <div className="wapp-settings">
79
74
  {error ? <p className="wapp-error">{error}</p> : null}
80
- <AccountSection config={config} theme={theme} setTheme={setTheme} themeLoading={themeLoading} themeLoadError={themeLoadError} retryThemeLoad={retryThemeLoad} refresh={refresh} setError={setError} />
75
+ <AccountSection config={config} refresh={refresh} setError={setError} />
81
76
  <FormSection title="Security">
82
77
  <SecuritySection config={config} refresh={refresh} setError={setError} />
83
78
  <SessionsSection config={config} setError={setError} />
@@ -11,6 +11,18 @@
11
11
  --wapp-muted-2: #64748b;
12
12
  --wapp-danger: #991b1b;
13
13
  --wapp-danger-bg: #991b1b;
14
+ --wapp-toast-success: #166534;
15
+ --wapp-toast-success-surface: #f0fdf4;
16
+ --wapp-toast-success-border: #86efac;
17
+ --wapp-toast-error: #991b1b;
18
+ --wapp-toast-error-surface: #fef2f2;
19
+ --wapp-toast-error-border: #fca5a5;
20
+ --wapp-toast-warning: #92400e;
21
+ --wapp-toast-warning-surface: #fffbeb;
22
+ --wapp-toast-warning-border: #fcd34d;
23
+ --wapp-toast-info: #1e40af;
24
+ --wapp-toast-info-surface: #eff6ff;
25
+ --wapp-toast-info-border: #93c5fd;
14
26
  --wapp-primary: #111827;
15
27
  --wapp-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
16
28
  --wapp-overlay-bg: rgb(0 0 0 / 0.5);
@@ -49,6 +61,18 @@
49
61
  --wapp-muted-2: #71717a;
50
62
  --wapp-danger: #fca5a5;
51
63
  --wapp-danger-bg: #7f1d1d;
64
+ --wapp-toast-success: #bbf7d0;
65
+ --wapp-toast-success-surface: #14532d;
66
+ --wapp-toast-success-border: #166534;
67
+ --wapp-toast-error: #fecaca;
68
+ --wapp-toast-error-surface: #7f1d1d;
69
+ --wapp-toast-error-border: #991b1b;
70
+ --wapp-toast-warning: #fde68a;
71
+ --wapp-toast-warning-surface: #78350f;
72
+ --wapp-toast-warning-border: #92400e;
73
+ --wapp-toast-info: #bfdbfe;
74
+ --wapp-toast-info-surface: #1e3a8a;
75
+ --wapp-toast-info-border: #1e40af;
52
76
  --wapp-primary: #f9fafb;
53
77
  --wapp-shadow: none;
54
78
  }
@@ -1453,6 +1477,110 @@ textarea::placeholder {
1453
1477
  color: var(--wapp-danger);
1454
1478
  }
1455
1479
 
1480
+ .wapp-toast-viewport {
1481
+ position: fixed;
1482
+ right: max(1rem, var(--wapp-safe-area-right));
1483
+ bottom: max(1rem, var(--wapp-safe-area-bottom));
1484
+ z-index: 70;
1485
+ display: flex;
1486
+ flex-direction: column;
1487
+ align-items: stretch;
1488
+ gap: 0.5rem;
1489
+ width: min(24rem, calc(100vw - 2rem));
1490
+ max-height: calc(var(--wapp-viewport-height) - 2rem);
1491
+ overflow-y: auto;
1492
+ padding: 0.25rem;
1493
+ pointer-events: none;
1494
+ }
1495
+
1496
+ .wapp-toast {
1497
+ display: flex;
1498
+ align-items: flex-start;
1499
+ gap: 0.75rem;
1500
+ min-width: 0;
1501
+ border: 1px solid var(--wapp-border);
1502
+ border-radius: 0.75rem;
1503
+ background: var(--wapp-surface);
1504
+ color: var(--wapp-text);
1505
+ padding: 0.75rem;
1506
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.14);
1507
+ pointer-events: auto;
1508
+ animation: wapp-toast-enter 160ms ease-out;
1509
+ }
1510
+
1511
+ .wapp-toast-success {
1512
+ border-color: var(--wapp-toast-success-border);
1513
+ background: var(--wapp-toast-success-surface);
1514
+ color: var(--wapp-toast-success);
1515
+ }
1516
+
1517
+ .wapp-toast-error {
1518
+ border-color: var(--wapp-toast-error-border);
1519
+ background: var(--wapp-toast-error-surface);
1520
+ color: var(--wapp-toast-error);
1521
+ }
1522
+
1523
+ .wapp-toast-warning {
1524
+ border-color: var(--wapp-toast-warning-border);
1525
+ background: var(--wapp-toast-warning-surface);
1526
+ color: var(--wapp-toast-warning);
1527
+ }
1528
+
1529
+ .wapp-toast-info {
1530
+ border-color: var(--wapp-toast-info-border);
1531
+ background: var(--wapp-toast-info-surface);
1532
+ color: var(--wapp-toast-info);
1533
+ }
1534
+
1535
+ .wapp-toast-message {
1536
+ min-width: 0;
1537
+ flex: 1 1 auto;
1538
+ overflow-wrap: anywhere;
1539
+ color: inherit;
1540
+ font-size: 0.875rem;
1541
+ line-height: 1.35;
1542
+ }
1543
+
1544
+ .wapp-toast-dismiss {
1545
+ display: inline-flex;
1546
+ flex: 0 0 1.75rem;
1547
+ align-items: center;
1548
+ justify-content: center;
1549
+ width: 1.75rem;
1550
+ height: 1.75rem;
1551
+ border: 0;
1552
+ border-radius: 0.5rem;
1553
+ background: transparent;
1554
+ color: inherit;
1555
+ cursor: pointer;
1556
+ font-size: 1.25rem;
1557
+ line-height: 1;
1558
+ }
1559
+
1560
+ .wapp-toast-dismiss:hover,
1561
+ .wapp-toast-dismiss:focus-visible {
1562
+ background: rgb(0 0 0 / 0.1);
1563
+ outline: none;
1564
+ }
1565
+
1566
+ @keyframes wapp-toast-enter {
1567
+ from {
1568
+ opacity: 0;
1569
+ transform: translateY(0.5rem);
1570
+ }
1571
+
1572
+ to {
1573
+ opacity: 1;
1574
+ transform: translateY(0);
1575
+ }
1576
+ }
1577
+
1578
+ @media (prefers-reduced-motion: reduce) {
1579
+ .wapp-toast {
1580
+ animation: none;
1581
+ }
1582
+ }
1583
+
1456
1584
  .wapp-notice {
1457
1585
  color: var(--wapp-muted);
1458
1586
  }
@@ -1961,6 +2089,14 @@ textarea::placeholder {
1961
2089
  margin-top: 0.75rem;
1962
2090
  }
1963
2091
 
2092
+ :root[data-wapp-mobile] .wapp-toast-viewport {
2093
+ right: max(0.75rem, var(--wapp-safe-area-right));
2094
+ bottom: max(0.75rem, var(--wapp-safe-area-bottom));
2095
+ left: max(0.75rem, var(--wapp-safe-area-left));
2096
+ width: auto;
2097
+ max-height: calc(var(--wapp-viewport-height) - 1.5rem);
2098
+ }
2099
+
1964
2100
  @media (max-width: 640px) {
1965
2101
  .wapp-settings .wapp-list-row {
1966
2102
  display: grid;
@@ -0,0 +1,147 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import type { ThemePreference } from "../contracts";
3
+ import { appJson } from "./api-client";
4
+
5
+ const THEME_STORAGE_KEY = "webapp.theme";
6
+ const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
7
+
8
+ export type ResolvedTheme = "light" | "dark";
9
+
10
+ export interface WebAppThemeState {
11
+ preference: ThemePreference;
12
+ resolvedTheme: ResolvedTheme;
13
+ setPreference: (preference: ThemePreference) => void;
14
+ loading: boolean;
15
+ error?: Error;
16
+ retry: () => Promise<void>;
17
+ }
18
+
19
+ const ThemeContext = createContext<WebAppThemeState | null>(null);
20
+
21
+ export function isThemePreference(value: unknown): value is ThemePreference {
22
+ return value === "system" || value === "light" || value === "dark";
23
+ }
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ function readStoredPreference(): ThemePreference {
30
+ if (typeof window === "undefined") {
31
+ return "system";
32
+ }
33
+
34
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
35
+ return isThemePreference(stored) ? stored : "system";
36
+ }
37
+
38
+ function readSystemTheme(): ResolvedTheme {
39
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
40
+ return "light";
41
+ }
42
+ return window.matchMedia(THEME_MEDIA_QUERY).matches ? "dark" : "light";
43
+ }
44
+
45
+ function parseThemeResponse(value: unknown): ThemePreference {
46
+ if (!isRecord(value) || !isThemePreference(value.theme)) {
47
+ throw new Error("Theme preference response was invalid.");
48
+ }
49
+ return value.theme;
50
+ }
51
+
52
+ function toError(value: unknown): Error {
53
+ return value instanceof Error ? value : new Error(String(value));
54
+ }
55
+
56
+ export function useTheme(): WebAppThemeState {
57
+ const context = useContext(ThemeContext);
58
+ if (!context) {
59
+ throw new Error("useTheme must be used within the framework WebAppRoot.");
60
+ }
61
+ return context;
62
+ }
63
+
64
+ export function ThemeProvider({ userId, children }: { userId?: string; children: ReactNode }) {
65
+ const [preference, setPreferenceState] = useState<ThemePreference>(readStoredPreference);
66
+ const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
67
+ const [loading, setLoading] = useState(false);
68
+ const [error, setError] = useState<Error>();
69
+ const requestIdRef = useRef(0);
70
+
71
+ useEffect(() => {
72
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
73
+ return;
74
+ }
75
+
76
+ const query = window.matchMedia(THEME_MEDIA_QUERY);
77
+ const sync = () => setSystemTheme(query.matches ? "dark" : "light");
78
+ sync();
79
+ query.addEventListener("change", sync);
80
+ return () => query.removeEventListener("change", sync);
81
+ }, []);
82
+
83
+ const setPreference = useCallback((nextPreference: ThemePreference) => {
84
+ if (!isThemePreference(nextPreference)) {
85
+ throw new TypeError(`Unknown theme preference: ${String(nextPreference)}.`);
86
+ }
87
+ setPreferenceState(nextPreference);
88
+ }, []);
89
+
90
+ const resolvedTheme = preference === "system" ? systemTheme : preference;
91
+
92
+ useEffect(() => {
93
+ if (typeof document === "undefined") {
94
+ return;
95
+ }
96
+
97
+ const root = document.documentElement;
98
+ root.classList.toggle("dark", resolvedTheme === "dark");
99
+ root.style.colorScheme = resolvedTheme;
100
+ root.dataset.theme = preference;
101
+ root.dataset.resolvedTheme = resolvedTheme;
102
+ window.localStorage.setItem(THEME_STORAGE_KEY, preference);
103
+ }, [preference, resolvedTheme]);
104
+
105
+ const retry = useCallback(async () => {
106
+ const requestId = requestIdRef.current + 1;
107
+ requestIdRef.current = requestId;
108
+ if (!userId) {
109
+ setLoading(false);
110
+ setError(undefined);
111
+ return;
112
+ }
113
+
114
+ setLoading(true);
115
+ setError(undefined);
116
+ try {
117
+ const response = await appJson<unknown>("/api/preferences/theme");
118
+ if (requestId !== requestIdRef.current) {
119
+ return;
120
+ }
121
+ setPreference(parseThemeResponse(response));
122
+ } catch (value) {
123
+ if (requestId === requestIdRef.current) {
124
+ setError(toError(value));
125
+ }
126
+ } finally {
127
+ if (requestId === requestIdRef.current) {
128
+ setLoading(false);
129
+ }
130
+ }
131
+ }, [setPreference, userId]);
132
+
133
+ useEffect(() => {
134
+ void retry();
135
+ }, [retry]);
136
+
137
+ const state = useMemo<WebAppThemeState>(() => ({
138
+ preference,
139
+ resolvedTheme,
140
+ setPreference,
141
+ loading,
142
+ error,
143
+ retry,
144
+ }), [error, loading, preference, resolvedTheme, retry, setPreference]);
145
+
146
+ return <ThemeContext.Provider value={state}>{children}</ThemeContext.Provider>;
147
+ }
@@ -0,0 +1,218 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ export type ToastVariant = "success" | "error" | "warning" | "info";
5
+ export type ToastId = string;
6
+
7
+ export interface Toast {
8
+ id: ToastId;
9
+ message: string;
10
+ variant: ToastVariant;
11
+ duration: number;
12
+ }
13
+
14
+ export interface ToastOptions {
15
+ id?: ToastId;
16
+ duration?: number;
17
+ }
18
+
19
+ export interface ToastShowOptions extends ToastOptions {
20
+ variant?: ToastVariant;
21
+ }
22
+
23
+ export interface ToastService {
24
+ toasts: readonly Toast[];
25
+ show: (message: string, options?: ToastShowOptions) => ToastId;
26
+ success: (message: string, options?: ToastOptions) => ToastId;
27
+ error: (message: string, options?: ToastOptions) => ToastId;
28
+ warning: (message: string, options?: ToastOptions) => ToastId;
29
+ info: (message: string, options?: ToastOptions) => ToastId;
30
+ dismiss: (id: ToastId) => void;
31
+ dismissAll: () => void;
32
+ }
33
+
34
+ const DEFAULT_TOAST_DURATION_MS = 8_000;
35
+ const MAX_TOASTS = 5;
36
+
37
+ const ToastContext = createContext<ToastService | null>(null);
38
+
39
+ function isToastVariant(value: unknown): value is ToastVariant {
40
+ return value === "success" || value === "error" || value === "warning" || value === "info";
41
+ }
42
+
43
+ function normalizeMessage(message: string): string {
44
+ if (typeof message !== "string") {
45
+ throw new TypeError("Toast messages must be strings.");
46
+ }
47
+ return message;
48
+ }
49
+
50
+ function normalizeId(id: ToastId | undefined, nextId: () => ToastId): ToastId {
51
+ if (id === undefined) {
52
+ return nextId();
53
+ }
54
+ if (typeof id !== "string" || id.length === 0) {
55
+ throw new TypeError("Toast IDs must be non-empty strings.");
56
+ }
57
+ return id;
58
+ }
59
+
60
+ function normalizeDuration(duration: number | undefined): number {
61
+ if (duration === undefined) {
62
+ return DEFAULT_TOAST_DURATION_MS;
63
+ }
64
+ if (!Number.isFinite(duration) || duration < 0) {
65
+ throw new RangeError("Toast duration must be a finite, non-negative number.");
66
+ }
67
+ return duration;
68
+ }
69
+
70
+ function ToastViewport({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: ToastId) => void }) {
71
+ if (toasts.length === 0) {
72
+ return null;
73
+ }
74
+
75
+ return createPortal(
76
+ <div className="wapp-toast-viewport" role="region" aria-label="Notifications">
77
+ {toasts.map((toast) => {
78
+ const isError = toast.variant === "error";
79
+ return (
80
+ <div
81
+ key={toast.id}
82
+ className={`wapp-toast wapp-toast-${toast.variant}`}
83
+ data-toast-variant={toast.variant}
84
+ role={isError ? "alert" : "status"}
85
+ aria-live={isError ? "assertive" : "polite"}
86
+ aria-atomic="true"
87
+ >
88
+ <span className="wapp-toast-message">{toast.message}</span>
89
+ <button type="button" className="wapp-toast-dismiss" aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>
90
+ ×
91
+ </button>
92
+ </div>
93
+ );
94
+ })}
95
+ </div>,
96
+ document.body,
97
+ );
98
+ }
99
+
100
+ export function useToast(): ToastService {
101
+ const context = useContext(ToastContext);
102
+ if (!context) {
103
+ throw new Error("useToast must be used within the framework webapp runtime.");
104
+ }
105
+ return context;
106
+ }
107
+
108
+ export function ToastProvider({ children }: { children: ReactNode }) {
109
+ const [toasts, setToasts] = useState<Toast[]>([]);
110
+ const timersRef = useRef(new Map<ToastId, ReturnType<typeof setTimeout>>());
111
+ const nextIdRef = useRef(0);
112
+
113
+ const nextId = useCallback((): ToastId => {
114
+ nextIdRef.current += 1;
115
+ return `toast-${nextIdRef.current}`;
116
+ }, []);
117
+
118
+ const clearTimer = useCallback((id: ToastId) => {
119
+ const timer = timersRef.current.get(id);
120
+ if (timer === undefined) {
121
+ return;
122
+ }
123
+ clearTimeout(timer);
124
+ timersRef.current.delete(id);
125
+ }, []);
126
+
127
+ const dismiss = useCallback((id: ToastId) => {
128
+ clearTimer(id);
129
+ setToasts((current) => current.filter((toast) => toast.id !== id));
130
+ }, [clearTimer]);
131
+
132
+ const dismissAll = useCallback(() => {
133
+ for (const id of Array.from(timersRef.current.keys())) {
134
+ clearTimer(id);
135
+ }
136
+ setToasts([]);
137
+ }, [clearTimer]);
138
+
139
+ const scheduleDismiss = useCallback((toast: Toast) => {
140
+ clearTimer(toast.id);
141
+ if (toast.duration === 0) {
142
+ return;
143
+ }
144
+
145
+ const timer = setTimeout(() => {
146
+ if (timersRef.current.get(toast.id) !== timer) {
147
+ return;
148
+ }
149
+ timersRef.current.delete(toast.id);
150
+ setToasts((current) => current.filter((item) => item.id !== toast.id));
151
+ }, toast.duration);
152
+ timersRef.current.set(toast.id, timer);
153
+ }, [clearTimer]);
154
+
155
+ const show = useCallback((message: string, options?: ToastShowOptions): ToastId => {
156
+ const normalizedMessage = normalizeMessage(message);
157
+ const variant = options?.variant ?? "info";
158
+ if (!isToastVariant(variant)) {
159
+ throw new TypeError(`Unknown toast variant: ${String(variant)}.`);
160
+ }
161
+ const id = normalizeId(options?.id, nextId);
162
+ const duration = normalizeDuration(options?.duration);
163
+ const toast: Toast = { id, message: normalizedMessage, variant, duration };
164
+
165
+ clearTimer(id);
166
+ setToasts((current) => {
167
+ const existingIndex = current.findIndex((item) => item.id === id);
168
+ if (existingIndex >= 0) {
169
+ const next = [...current];
170
+ next[existingIndex] = toast;
171
+ return next;
172
+ }
173
+ const next = [...current, toast];
174
+ return next.length > MAX_TOASTS ? next.slice(next.length - MAX_TOASTS) : next;
175
+ });
176
+ scheduleDismiss(toast);
177
+ return id;
178
+ }, [clearTimer, nextId, scheduleDismiss]);
179
+
180
+ useEffect(() => {
181
+ const activeIds = new Set(toasts.map((toast) => toast.id));
182
+ for (const id of timersRef.current.keys()) {
183
+ if (activeIds.has(id)) {
184
+ continue;
185
+ }
186
+ clearTimer(id);
187
+ }
188
+ }, [clearTimer, toasts]);
189
+
190
+ useEffect(() => () => {
191
+ for (const timer of timersRef.current.values()) {
192
+ clearTimeout(timer);
193
+ }
194
+ timersRef.current.clear();
195
+ }, []);
196
+
197
+ const success = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "success" }), [show]);
198
+ const error = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "error" }), [show]);
199
+ const warning = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "warning" }), [show]);
200
+ const info = useCallback((message: string, options?: ToastOptions) => show(message, { ...options, variant: "info" }), [show]);
201
+ const service = useMemo<ToastService>(() => ({
202
+ toasts,
203
+ show,
204
+ success,
205
+ error,
206
+ warning,
207
+ info,
208
+ dismiss,
209
+ dismissAll,
210
+ }), [dismiss, dismissAll, error, info, show, success, toasts, warning]);
211
+
212
+ return (
213
+ <ToastContext.Provider value={service}>
214
+ {children}
215
+ <ToastViewport toasts={toasts} onDismiss={dismiss} />
216
+ </ToastContext.Provider>
217
+ );
218
+ }