@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.
- package/dist/Icon.d.ts +1 -1
- package/dist/components/admin.d.ts.map +1 -1
- package/dist/components/admin.js +2 -2
- package/dist/components/admin.js.map +1 -1
- package/dist/components/charts.js +4 -4
- package/dist/components/charts.js.map +1 -1
- package/dist/components/data-display.d.ts +2 -0
- package/dist/components/data-display.d.ts.map +1 -1
- package/dist/components/data-display.js +32 -8
- package/dist/components/data-display.js.map +1 -1
- package/dist/components/developer.d.ts.map +1 -1
- package/dist/components/developer.js +4 -2
- package/dist/components/developer.js.map +1 -1
- package/dist/components/feedback.d.ts +1 -1
- package/dist/components/feedback.d.ts.map +1 -1
- package/dist/components/feedback.js +9 -5
- package/dist/components/feedback.js.map +1 -1
- package/dist/components/forms.d.ts.map +1 -1
- package/dist/components/forms.js +3 -1
- package/dist/components/forms.js.map +1 -1
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/index.js.map +1 -1
- package/dist/components/overlays.d.ts.map +1 -1
- package/dist/components/overlays.js +5 -4
- package/dist/components/overlays.js.map +1 -1
- package/dist/components/primitives.d.ts.map +1 -1
- package/dist/components/primitives.js +3 -1
- package/dist/components/primitives.js.map +1 -1
- package/dist/components/shell.d.ts +11 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +41 -4
- package/dist/components/shell.js.map +1 -1
- package/dist/components/types.d.ts +54 -2
- package/dist/components/types.d.ts.map +1 -1
- package/dist/components/workspace.d.ts +2 -2
- package/dist/components/workspace.d.ts.map +1 -1
- package/dist/components/workspace.js +9 -3
- package/dist/components/workspace.js.map +1 -1
- package/dist/cx.d.ts +7 -2
- package/dist/cx.d.ts.map +1 -1
- package/dist/cx.js +25 -3
- package/dist/cx.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/motion.d.ts +13 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +44 -0
- package/dist/motion.js.map +1 -0
- package/dist/presence.d.ts +18 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +49 -0
- package/dist/presence.js.map +1 -0
- package/dist/provider.d.ts +22 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +18 -4
- package/dist/provider.js.map +1 -1
- package/dist/themes.css +1821 -102
- package/dist/toast-store.d.ts +2 -0
- package/dist/toast-store.d.ts.map +1 -1
- package/dist/toast-store.js +28 -1
- package/dist/toast-store.js.map +1 -1
- package/package.json +1 -1
- package/src/components/admin.tsx +7 -3
- package/src/components/charts.tsx +4 -4
- package/src/components/data-display.tsx +48 -8
- package/src/components/developer.tsx +4 -2
- package/src/components/feedback.tsx +9 -4
- package/src/components/forms.tsx +4 -1
- package/src/components/index.ts +1 -1
- package/src/components/overlays.tsx +15 -5
- package/src/components/primitives.tsx +4 -1
- package/src/components/shell.tsx +103 -4
- package/src/components/types.ts +54 -2
- package/src/components/workspace.tsx +8 -1
- package/src/cx.ts +22 -5
- package/src/index.ts +3 -0
- package/src/motion.ts +48 -0
- package/src/presence.ts +58 -0
- package/src/provider.tsx +44 -5
- package/src/toast-store.ts +28 -1
package/src/components/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/presence.ts
ADDED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
126
|
-
[
|
|
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={
|
|
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="" />
|
package/src/toast-store.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|