@twentyfirstmedia/ui-core 0.1.1 → 0.2.1

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.
Files changed (84) hide show
  1. package/dist/Icon.d.ts +1 -1
  2. package/dist/components/admin.d.ts.map +1 -1
  3. package/dist/components/admin.js +2 -2
  4. package/dist/components/admin.js.map +1 -1
  5. package/dist/components/charts.js +4 -4
  6. package/dist/components/charts.js.map +1 -1
  7. package/dist/components/data-display.d.ts +2 -0
  8. package/dist/components/data-display.d.ts.map +1 -1
  9. package/dist/components/data-display.js +32 -8
  10. package/dist/components/data-display.js.map +1 -1
  11. package/dist/components/developer.d.ts.map +1 -1
  12. package/dist/components/developer.js +4 -2
  13. package/dist/components/developer.js.map +1 -1
  14. package/dist/components/feedback.d.ts +1 -1
  15. package/dist/components/feedback.d.ts.map +1 -1
  16. package/dist/components/feedback.js +9 -5
  17. package/dist/components/feedback.js.map +1 -1
  18. package/dist/components/forms.d.ts.map +1 -1
  19. package/dist/components/forms.js +3 -1
  20. package/dist/components/forms.js.map +1 -1
  21. package/dist/components/index.d.ts +1 -1
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/components/index.js +1 -1
  24. package/dist/components/index.js.map +1 -1
  25. package/dist/components/overlays.d.ts.map +1 -1
  26. package/dist/components/overlays.js +5 -4
  27. package/dist/components/overlays.js.map +1 -1
  28. package/dist/components/primitives.d.ts.map +1 -1
  29. package/dist/components/primitives.js +3 -1
  30. package/dist/components/primitives.js.map +1 -1
  31. package/dist/components/shell.d.ts +11 -1
  32. package/dist/components/shell.d.ts.map +1 -1
  33. package/dist/components/shell.js +41 -4
  34. package/dist/components/shell.js.map +1 -1
  35. package/dist/components/types.d.ts +54 -2
  36. package/dist/components/types.d.ts.map +1 -1
  37. package/dist/components/workspace.d.ts +2 -2
  38. package/dist/components/workspace.d.ts.map +1 -1
  39. package/dist/components/workspace.js +9 -3
  40. package/dist/components/workspace.js.map +1 -1
  41. package/dist/cx.d.ts +7 -2
  42. package/dist/cx.d.ts.map +1 -1
  43. package/dist/cx.js +25 -3
  44. package/dist/cx.js.map +1 -1
  45. package/dist/index.d.ts +3 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/motion.d.ts +13 -0
  50. package/dist/motion.d.ts.map +1 -0
  51. package/dist/motion.js +44 -0
  52. package/dist/motion.js.map +1 -0
  53. package/dist/presence.d.ts +18 -0
  54. package/dist/presence.d.ts.map +1 -0
  55. package/dist/presence.js +49 -0
  56. package/dist/presence.js.map +1 -0
  57. package/dist/provider.d.ts +22 -0
  58. package/dist/provider.d.ts.map +1 -1
  59. package/dist/provider.js +18 -4
  60. package/dist/provider.js.map +1 -1
  61. package/dist/themes.css +1821 -102
  62. package/dist/toast-store.d.ts +2 -0
  63. package/dist/toast-store.d.ts.map +1 -1
  64. package/dist/toast-store.js +28 -1
  65. package/dist/toast-store.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/components/admin.tsx +7 -3
  68. package/src/components/charts.tsx +4 -4
  69. package/src/components/data-display.tsx +48 -8
  70. package/src/components/developer.tsx +4 -2
  71. package/src/components/feedback.tsx +9 -4
  72. package/src/components/forms.tsx +4 -1
  73. package/src/components/index.ts +1 -1
  74. package/src/components/overlays.tsx +15 -5
  75. package/src/components/primitives.tsx +4 -1
  76. package/src/components/shell.tsx +103 -4
  77. package/src/components/types.ts +54 -2
  78. package/src/components/workspace.tsx +8 -1
  79. package/src/cx.ts +22 -5
  80. package/src/index.ts +3 -0
  81. package/src/motion.ts +48 -0
  82. package/src/presence.ts +58 -0
  83. package/src/provider.tsx +44 -5
  84. package/src/toast-store.ts +28 -1
@@ -1,8 +1,9 @@
1
1
  import type { ChangeEvent, CSSProperties, MouseEvent, ReactNode } from "react";
2
2
  import type { IconName } from "../icons.js";
3
3
  import type { ToastOptions } from "../toast-store.js";
4
+ import type { Surface } from "../provider.js";
4
5
 
5
- export type { IconName, ToastOptions };
6
+ export type { IconName, ToastOptions, Surface };
6
7
 
7
8
  /* Props of every component, ported from the reference (design-system/components/index.d.ts). */
8
9
 
@@ -160,6 +161,8 @@ export interface CardProps extends BaseProps {
160
161
  onClick?: () => void;
161
162
  children?: ReactNode;
162
163
  eyebrow?: ReactNode;
164
+ /** `"glass"`: translucent glass over what is behind (maps, 3D, video, photos); `"solid"` opts out inside a glass `UIProvider`. */
165
+ surface?: Surface;
163
166
  }
164
167
  export interface EventCardProps extends BaseProps {
165
168
  kind?: "meeting" | "deadline" | "review" | "launch" | "incident";
@@ -177,6 +180,8 @@ export interface PanelProps extends BaseProps {
177
180
  subtitle?: string;
178
181
  action?: ReactNode;
179
182
  children?: ReactNode;
183
+ /** `"glass"`: translucent glass over what is behind (maps, 3D, video, photos); `"solid"` opts out inside a glass `UIProvider`. */
184
+ surface?: Surface;
180
185
  }
181
186
  export interface PageHeaderProps extends BaseProps {
182
187
  crumbs?: string[];
@@ -211,6 +216,39 @@ export interface SideRailProps extends BaseProps {
211
216
  top?: RailItem[];
212
217
  bottom?: RailItem[];
213
218
  }
219
+ export interface FloatingLayoutProps extends BaseProps {
220
+ /** What the UI floats over, filling the layout: a map, a 3D canvas, a video, a photo. */
221
+ background?: ReactNode;
222
+ /** Bar along the top edge (search, filters, title). */
223
+ top?: ReactNode;
224
+ /** Column on the start side (layers, lists, settings). */
225
+ start?: ReactNode;
226
+ /** Column on the end side (details of the selected thing). */
227
+ end?: ReactNode;
228
+ /** Bar along the bottom edge (dock, playback, a summary). */
229
+ bottom?: ReactNode;
230
+ /** Height of the layout (default 640). Below 720px of width the slots stack: top, start, end, bottom. */
231
+ height?: number | string;
232
+ /** Glass for every surface in the slots (default) or solid surfaces. */
233
+ surface?: Surface;
234
+ /** Accessible name of the region. */
235
+ label?: string;
236
+ children?: ReactNode;
237
+ }
238
+ export interface AmbientProps extends BaseProps {
239
+ /** Colours of the soft fields (any CSS colour); default the kit's `--ambient-1…4`. */
240
+ colors?: string[];
241
+ /** A photo (or, with `video`, a video) instead of the fields, under a scrim that keeps it calm. */
242
+ src?: string;
243
+ /** Treat `src` as a video (muted, looping, still with motion off). */
244
+ video?: boolean;
245
+ /** Let the fields drift slowly (default true). Still with motion off; paused off screen. */
246
+ animate?: boolean;
247
+ /** Cover the window behind the page instead of the nearest container. */
248
+ fixed?: boolean;
249
+ /** Your own layer (a canvas, WebGL, a map) instead of fields or media. */
250
+ children?: ReactNode;
251
+ }
214
252
  export interface DockItem {
215
253
  label: string;
216
254
  icon: IconName;
@@ -220,6 +258,8 @@ export interface DockItem {
220
258
  export interface DockProps extends BaseProps {
221
259
  items: DockItem[];
222
260
  label?: string;
261
+ /** `"glass"`: translucent glass over what is behind (maps, 3D, video, photos); `"solid"` opts out inside a glass `UIProvider`. */
262
+ surface?: Surface;
223
263
  }
224
264
  export interface TableColumn {
225
265
  key: string;
@@ -282,7 +322,10 @@ export interface StaggerProps extends BaseProps {
282
322
  as?: string;
283
323
  className?: string;
284
324
  style?: CSSProperties;
325
+ /** Extra delay before the first child, in ms. */
285
326
  delay?: number;
327
+ /** Below the fold, wait until the group scrolls into view (default true). */
328
+ reveal?: boolean;
286
329
  children?: ReactNode;
287
330
  }
288
331
  export interface CountUpProps extends BaseProps {
@@ -404,6 +447,8 @@ export interface ModalProps extends BaseProps {
404
447
  onClose?: () => void;
405
448
  style?: CSSProperties;
406
449
  children?: ReactNode;
450
+ /** Optional: with `open` the component stays mounted while it animates out; without it, render it only while open. */
451
+ open?: boolean;
407
452
  }
408
453
  /** Mounts a demo page with a "Replay motion" button that re-runs every entrance. */
409
454
  export interface SubNavItem {
@@ -428,6 +473,8 @@ export interface DrawerProps extends BaseProps, DrawerSide {
428
473
  width?: number | string;
429
474
  inline?: boolean;
430
475
  children?: ReactNode;
476
+ /** Optional: with `open` the component stays mounted while it animates out; without it, render it only while open. */
477
+ open?: boolean;
431
478
  }
432
479
  export interface PaletteItem {
433
480
  id?: string | number;
@@ -450,6 +497,8 @@ export interface CommandPaletteProps extends BaseProps {
450
497
  empty?: ReactNode;
451
498
  /** Called with the query as the user types, for searching on a server. */
452
499
  onQueryChange?: (query: string) => void;
500
+ /** Optional: with `open` the component stays mounted while it animates out; without it, render it only while open. */
501
+ open?: boolean;
453
502
  }
454
503
  export interface DrawerSide {
455
504
  /** Edge the sheet slides in from (default right). */
@@ -1787,8 +1836,11 @@ export interface MapViewProps extends BaseProps {
1787
1836
  renderPopup?: (p: MapPoint) => ReactNode;
1788
1837
  legend?: ReactNode;
1789
1838
  controls?: ReactNode;
1790
- height?: number;
1839
+ /** Height in px (default 320) or any CSS length, e.g. "100%" to fill a FloatingLayout. */
1840
+ height?: number | string;
1791
1841
  label?: string;
1842
+ /** `"glass"`: the legend, controls and popup float as glass over the map (for a live map in `background`). */
1843
+ surface?: Surface;
1792
1844
  className?: string;
1793
1845
  }
1794
1846
 
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
+ import { leaving, usePresence } from "../presence.js";
4
5
  import { formatMoney } from "../format.js";
5
6
  import { Icon } from "../Icon.js";
6
7
  import { useScrollFocus, useTrap } from "../hooks.js";
@@ -65,9 +66,12 @@ export function SubNav(p: SubNavProps) {
65
66
  }
66
67
  export function Drawer(p: DrawerProps) {
67
68
  const ref = React.useRef<HTMLElement>(null);
69
+ const pr = usePresence(p.open ?? true);
68
70
  useTrap(ref, p.onClose);
71
+ if (!pr.mounted) return null;
69
72
  return (
70
73
  <div
74
+ {...leaving(pr.closing)}
71
75
  className={cx("tf-drawer-wrap", p.inline && "inline")}
72
76
  onMouseDown={(e) => {
73
77
  if (e.target === e.currentTarget && p.onClose) p.onClose?.();
@@ -120,7 +124,8 @@ export function Drawer(p: DrawerProps) {
120
124
  export function CommandPalette(p: CommandPaletteProps) {
121
125
  const q = React.useState(""),
122
126
  idx = React.useState(0),
123
- ref = React.useRef<HTMLDivElement>(null);
127
+ ref = React.useRef<HTMLDivElement>(null),
128
+ pr = usePresence(p.open ?? true);
124
129
  useTrap(ref, p.onClose);
125
130
  const ql = q[0].toLowerCase().trim(),
126
131
  all = p.items || [];
@@ -168,8 +173,10 @@ export function CommandPalette(p: CommandPaletteProps) {
168
173
  });
169
174
  groups[groups.length - 1].items.push([it, i]);
170
175
  });
176
+ if (!pr.mounted) return null;
171
177
  return (
172
178
  <div
179
+ {...leaving(pr.closing)}
173
180
  className={cx("tf-palette-wrap", p.inline && "inline")}
174
181
  onMouseDown={(e) => {
175
182
  if (e.target === e.currentTarget && p.onClose) p.onClose?.();
package/src/cx.ts CHANGED
@@ -3,17 +3,34 @@ export function cx(...parts: unknown[]): string {
3
3
  return parts.filter((x): x is string => typeof x === "string" && x !== "").join(" ");
4
4
  }
5
5
 
6
- /** True when the user asked for reduced motion (false on the server). */
7
- export function reduced(): boolean {
6
+ /**
7
+ * True when motion is off for `el` (or the page): the reader asked for reduced motion, or the
8
+ * nearest `data-motion` says "off". False on the server.
9
+ */
10
+ export function reduced(el?: Element | null): boolean {
8
11
  try {
9
- return (
10
- typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
11
- );
12
+ if (typeof window === "undefined") return false;
13
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return true;
14
+ const host = (el ?? document.documentElement).closest("[data-motion]");
15
+ return host?.getAttribute("data-motion") === "off";
12
16
  } catch {
13
17
  return false;
14
18
  }
15
19
  }
16
20
 
21
+ /** The entrance delay of the card `el` sits in (Stagger, `tf-in`), in ms. */
22
+ export function enterDelay(el?: Element | null): number {
23
+ try {
24
+ const host = el?.closest(".tf-stagger > *, .tf-in");
25
+ if (!host) return 0;
26
+ const d = getComputedStyle(host).animationDelay.split(",")[0]?.trim() ?? "";
27
+ const n = parseFloat(d);
28
+ return isNaN(n) ? 0 : d.endsWith("ms") ? n : n * 1000;
29
+ } catch {
30
+ return 0;
31
+ }
32
+ }
33
+
17
34
  /** Initials for avatars: first letters of up to two words. */
18
35
  export function initials(name?: string): string {
19
36
  return (
package/src/index.ts CHANGED
@@ -16,8 +16,11 @@ export {
16
16
  type Kit,
17
17
  type Theme,
18
18
  type Density,
19
+ type Motion,
19
20
  } from "./provider.js";
20
21
  export { Portal } from "./portal.js";
22
+ export { startViewTransition, useMotion } from "./motion.js";
23
+ export { usePresence, leaving, type Presence } from "./presence.js";
21
24
  export {
22
25
  THEME_VARS,
23
26
  THEME_PRESETS,
package/src/motion.ts ADDED
@@ -0,0 +1,48 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { reduced } from "./cx.js";
4
+ import { useUI, type Motion } from "./provider.js";
5
+
6
+ /**
7
+ * Runs `update` (a route change, a tab switch, new data) as a view transition: the old page fades
8
+ * out and the new one rises in, in the kit's motion. Falls back to a plain update where the
9
+ * browser has no view transitions or motion is off.
10
+ */
11
+ export async function startViewTransition(update: () => void | Promise<void>): Promise<void> {
12
+ const doc = typeof document === "undefined" ? undefined : document;
13
+ const start = (
14
+ doc as
15
+ | (Document & {
16
+ startViewTransition?: (cb: () => void | Promise<void>) => { finished: Promise<void> };
17
+ })
18
+ | undefined
19
+ )?.startViewTransition;
20
+ if (!doc || !start || reduced()) {
21
+ await update();
22
+ return;
23
+ }
24
+ try {
25
+ await start.call(doc, update).finished;
26
+ } catch {
27
+ // an interrupted transition still ran the update
28
+ }
29
+ }
30
+
31
+ /**
32
+ * The motion level in effect: "off" while the reader asks for reduced motion or the nearest
33
+ * provider turns motion off, otherwise the provider's level ("standard" by default).
34
+ */
35
+ export function useMotion(): Motion {
36
+ const { motion } = useUI();
37
+ const [off, setOff] = useState(false);
38
+ useEffect(() => {
39
+ if (typeof window === "undefined" || !window.matchMedia) return;
40
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
41
+ const on = () => setOff(mq.matches);
42
+ on();
43
+ mq.addEventListener("change", on);
44
+ return () => mq.removeEventListener("change", on);
45
+ }, []);
46
+ if (off) return "off";
47
+ return motion ?? "standard";
48
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { durationToken, reduced } from "./cx.js";
4
+
5
+ export interface Presence {
6
+ /** Render the layer: while open and while it plays its exit. */
7
+ mounted: boolean;
8
+ /** The layer is leaving: it carries `data-closing` (exit animation) and is inert. */
9
+ closing: boolean;
10
+ }
11
+
12
+ /**
13
+ * Keeps a layer (menu, popover, dialog, drawer, toast) in the page for `--dur-exit` after it
14
+ * closes, so it can animate out. With motion off, reduced motion or no exit duration it leaves at
15
+ * once, as before.
16
+ */
17
+ export function usePresence(open: boolean): Presence {
18
+ const [mounted, setMounted] = useState(open);
19
+ const [closing, setClosing] = useState(false);
20
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
21
+ useEffect(() => {
22
+ if (timer.current) {
23
+ clearTimeout(timer.current);
24
+ timer.current = null;
25
+ }
26
+ if (open) {
27
+ setMounted(true);
28
+ setClosing(false);
29
+ return;
30
+ }
31
+ if (!mounted) return;
32
+ const ms = reduced() ? 0 : durationToken("--dur-exit", 0);
33
+ if (ms <= 0) {
34
+ setMounted(false);
35
+ setClosing(false);
36
+ return;
37
+ }
38
+ setClosing(true);
39
+ timer.current = setTimeout(() => {
40
+ timer.current = null;
41
+ setMounted(false);
42
+ setClosing(false);
43
+ }, ms);
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only the open state drives this
45
+ }, [open]);
46
+ useEffect(
47
+ () => () => {
48
+ if (timer.current) clearTimeout(timer.current);
49
+ },
50
+ [],
51
+ );
52
+ return { mounted: mounted || open, closing: closing && !open };
53
+ }
54
+
55
+ /** Props for the layer's root while it leaves. */
56
+ export function leaving(closing: boolean): { "data-closing"?: ""; inert?: boolean } {
57
+ return closing ? { "data-closing": "", inert: true } : {};
58
+ }
package/src/provider.tsx CHANGED
@@ -13,6 +13,13 @@ import { themeStyle, type ThemeInput, type ThemeValues } from "./theme.js";
13
13
  export type Kit = "halo" | "pro";
14
14
  export type Theme = "light" | "dark" | "system";
15
15
  export type Density = "compact" | "comfortable";
16
+ /** How surfaces paint: solid (the default) or glass, translucent over what is behind them. */
17
+ export type Surface = "solid" | "glass";
18
+ /**
19
+ * How much things move: "standard" (the kit's own motion), "expressive" (longer, staggered page
20
+ * entrances, more travel; Pro stays crisp) or "off". A reduced-motion setting always means "off".
21
+ */
22
+ export type Motion = "off" | "standard" | "expressive";
16
23
 
17
24
  export interface UIProviderProps {
18
25
  /** Which kit's CSS applies (sets `data-kit`). Only matters when both kits are loaded (`themes.css`). */
@@ -33,6 +40,19 @@ export interface UIProviderProps {
33
40
  * per mode. For a fixed theme, CSS is simpler (see `themeCss`); this is for runtime brands.
34
41
  */
35
42
  tokens?: ThemeValues | ThemeInput;
43
+ /**
44
+ * `"glass"` turns every top-level surface (Panel, Card, Dock, map legend and controls) and every
45
+ * floating layer (menus, popovers, dialogs, drawers) inside into translucent glass, for UI that
46
+ * floats over a map, a 3D scene, video or photos (sets `data-surface`). Single surfaces take
47
+ * `surface="glass"` themselves; `surface="solid"` on one opts it out.
48
+ */
49
+ surface?: Surface;
50
+ /**
51
+ * Motion level for everything inside (sets `data-motion`): `"standard"` (default), `"expressive"`
52
+ * for orchestrated page entrances, or `"off"`, e.g. for a setting in your app. The reader's
53
+ * reduced-motion setting always turns motion off.
54
+ */
55
+ motion?: Motion;
36
56
  className?: string;
37
57
  style?: CSSProperties;
38
58
  children?: ReactNode;
@@ -42,6 +62,8 @@ export interface UIContextValue {
42
62
  kit: Kit | undefined;
43
63
  theme: Theme | undefined;
44
64
  density: Density | undefined;
65
+ surface: Surface | undefined;
66
+ motion: Motion | undefined;
45
67
  /** Element portals render into (null: document.body). */
46
68
  portalRoot: HTMLElement | null;
47
69
  }
@@ -50,6 +72,8 @@ const UIContext = createContext<UIContextValue>({
50
72
  kit: undefined,
51
73
  theme: undefined,
52
74
  density: undefined,
75
+ surface: undefined,
76
+ motion: undefined,
53
77
  portalRoot: null,
54
78
  });
55
79
 
@@ -83,9 +107,16 @@ const isThemeInput = (t: ThemeValues | ThemeInput): t is ThemeInput =>
83
107
  "light" in t || "dark" in t || "radius" in t;
84
108
 
85
109
  export function UIProvider(p: UIProviderProps) {
110
+ const parent = useUI();
86
111
  const scope = p.scope ?? "document";
112
+ // a nested provider keeps what it does not set itself
113
+ const kit = p.kit ?? parent.kit;
114
+ const theme = p.theme ?? parent.theme;
115
+ const density = p.density ?? parent.density;
116
+ const surface = p.surface ?? parent.surface;
117
+ const motion = p.motion ?? parent.motion;
87
118
  const perMode = !!p.tokens && isThemeInput(p.tokens);
88
- const dark = useDarkMode(p.theme, p.kit, perMode);
119
+ const dark = useDarkMode(theme, kit, perMode);
89
120
  const vars = useMemo(() => {
90
121
  if (!p.tokens) return undefined;
91
122
  if (!isThemeInput(p.tokens)) return themeStyle(p.tokens);
@@ -118,13 +149,19 @@ export function UIProvider(p: UIProviderProps) {
118
149
  set("data-kit", p.kit);
119
150
  set("data-theme", p.theme);
120
151
  set("data-density", p.density);
121
- }, [scope, p.kit, p.theme, p.density]);
152
+ set("data-surface", p.surface);
153
+ set("data-motion", p.motion);
154
+ }, [scope, p.kit, p.theme, p.density, p.surface, p.motion]);
122
155
 
123
156
  const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
124
157
  const ctx = useMemo<UIContextValue>(
125
- () => ({ kit: p.kit, theme: p.theme, density: p.density, portalRoot }),
126
- [p.kit, p.theme, p.density, portalRoot],
158
+ () => ({ kit, theme, density, surface, motion, portalRoot }),
159
+ [kit, theme, density, surface, motion, portalRoot],
127
160
  );
161
+ // Derived colours (glass, hover shades, Pro's scale) are worked out where data-theme sits. A
162
+ // subtree with its own tokens needs data-theme too, or they would keep the outer values.
163
+ const subtreeTheme =
164
+ p.theme ?? (vars ? (theme ?? (kit === "pro" ? "dark" : "system")) : undefined);
128
165
  const content = p.children;
129
166
  if (scope === "subtree") {
130
167
  return (
@@ -133,8 +170,10 @@ export function UIProvider(p: UIProviderProps) {
133
170
  className={p.className}
134
171
  style={vars ? ({ ...vars, ...p.style } as CSSProperties) : p.style}
135
172
  data-kit={p.kit}
136
- data-theme={p.theme}
173
+ data-theme={subtreeTheme}
137
174
  data-density={p.density}
175
+ data-surface={p.surface}
176
+ data-motion={p.motion}
138
177
  >
139
178
  {content}
140
179
  <div ref={setPortalRoot} data-tf-portal="" />
@@ -16,6 +16,8 @@ export interface ToastData extends ToastOptions {
16
16
  id: string;
17
17
  tone: ToastTone;
18
18
  duration: number;
19
+ /** Animating out; removed after `--dur-exit`. */
20
+ leaving?: boolean;
19
21
  }
20
22
 
21
23
  let list: ToastData[] = [];
@@ -36,9 +38,34 @@ export function toast(o: ToastOptions | string): string {
36
38
  return t.id;
37
39
  }
38
40
  toast.dismiss = (id: string): void => {
39
- list = list.filter((t) => t.id !== id);
41
+ const ms = exitMs();
42
+ if (ms <= 0) {
43
+ list = list.filter((t) => t.id !== id);
44
+ emit();
45
+ return;
46
+ }
47
+ list = list.map((t) => (t.id === id ? { ...t, leaving: true } : t));
40
48
  emit();
49
+ setTimeout(() => {
50
+ list = list.filter((t) => t.id !== id);
51
+ emit();
52
+ }, ms);
41
53
  };
54
+
55
+ /** How long a toast takes to leave (0 without motion). */
56
+ function exitMs(): number {
57
+ try {
58
+ if (typeof window === "undefined") return 0;
59
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return 0;
60
+ if (document.documentElement.closest("[data-motion]")?.getAttribute("data-motion") === "off")
61
+ return 0;
62
+ const raw = getComputedStyle(document.documentElement).getPropertyValue("--dur-exit").trim();
63
+ const n = parseFloat(raw);
64
+ return isNaN(n) ? 0 : raw.endsWith("ms") ? n : n * 1000;
65
+ } catch {
66
+ return 0;
67
+ }
68
+ }
42
69
  toast.clear = (): void => {
43
70
  list = [];
44
71
  emit();