@robosystems/core 0.11.4 → 0.12.0
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/demos/runtime.d.ts +66 -0
- package/demos/runtime.js +278 -0
- package/hooks/use-toast.d.ts +12 -11
- package/hooks/use-toast.js +91 -91
- package/package.json +1 -1
- package/ui-components/layout/LiveDemo.d.ts +22 -0
- package/ui-components/layout/LiveDemo.js +44 -0
- package/ui-components/layout/index.d.ts +1 -0
- package/ui-components/layout/index.js +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface Rect {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
w: number;
|
|
5
|
+
h: number;
|
|
6
|
+
}
|
|
7
|
+
export interface DemoTheme {
|
|
8
|
+
/** Selector for the sidebar that holds `.pill` and the `.nv[data-k]` items. */
|
|
9
|
+
side: string;
|
|
10
|
+
/** Resting item colour, as RGB. */
|
|
11
|
+
rest: [number, number, number];
|
|
12
|
+
/** Resting colour of an indented `.nv.sub` item; defaults to `rest`. */
|
|
13
|
+
restSub?: [number, number, number];
|
|
14
|
+
/** Colour of the item under the pill. */
|
|
15
|
+
active: [number, number, number];
|
|
16
|
+
}
|
|
17
|
+
export interface DemoContext {
|
|
18
|
+
$: (id: string) => HTMLElement;
|
|
19
|
+
root: ShadowRoot;
|
|
20
|
+
stage: HTMLElement;
|
|
21
|
+
rel: (el: Element) => Rect;
|
|
22
|
+
ring: (hl: HTMLElement, target: Element, p: number, pad?: number) => void;
|
|
23
|
+
nav: (k: string, from?: string, m?: number, scope?: ParentNode) => void;
|
|
24
|
+
}
|
|
25
|
+
export interface DemoLayout {
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
css?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface DemoDefinition extends DemoLayout {
|
|
31
|
+
total: number;
|
|
32
|
+
poster?: number;
|
|
33
|
+
html: string;
|
|
34
|
+
setup: (ctx: DemoContext) => (t: number) => void;
|
|
35
|
+
mobile?: DemoLayout;
|
|
36
|
+
}
|
|
37
|
+
export interface MountOptions {
|
|
38
|
+
autoplay?: boolean;
|
|
39
|
+
phone?: boolean;
|
|
40
|
+
/** The app's base CSS: its tokens, window chrome and shared components. */
|
|
41
|
+
css?: string;
|
|
42
|
+
theme?: DemoTheme;
|
|
43
|
+
}
|
|
44
|
+
export interface DemoHandle {
|
|
45
|
+
seek: (t: number) => void;
|
|
46
|
+
destroy: () => void;
|
|
47
|
+
}
|
|
48
|
+
export declare const clamp01: (x: number) => number;
|
|
49
|
+
export declare const seg: (t: number, a: number, b: number) => number;
|
|
50
|
+
export declare const eo: (x: number) => number;
|
|
51
|
+
export declare const eio: (x: number) => number;
|
|
52
|
+
export declare const typed: (s: string, t: number, t0: number, cps?: number) => string;
|
|
53
|
+
export declare const spin: (t: number) => string;
|
|
54
|
+
export declare const money: (v: number, dp?: number) => string;
|
|
55
|
+
export declare function rise(el: HTMLElement, p: number, dy?: number): void;
|
|
56
|
+
export declare const dip: (t: number, at: number, d?: number) => number;
|
|
57
|
+
export declare function swap(el: HTMLElement, t: number, at: number, a?: string | null, b?: string | null, cls?: [string, string]): boolean;
|
|
58
|
+
export declare function steps(el: HTMLElement, t: number, times: number[], texts?: string[] | null, classes?: string[] | null): number;
|
|
59
|
+
export declare function blurIn(el: HTMLElement, p: number, dy?: number): void;
|
|
60
|
+
export declare function push(el: HTMLElement, t: number, total: number, ox: number, oy: number, amount?: number): void;
|
|
61
|
+
export declare function pointer(ctx: DemoContext, cur: HTMLElement, el: Element, t: number, t0: number, t1: number, { from, out, }?: {
|
|
62
|
+
from?: [number, number];
|
|
63
|
+
out?: number;
|
|
64
|
+
}): boolean;
|
|
65
|
+
export declare function variant(def: DemoDefinition, phone: boolean): DemoDefinition;
|
|
66
|
+
export declare function mount(host: HTMLElement, def: DemoDefinition, { autoplay, phone, css, theme }?: MountOptions): DemoHandle;
|
package/demos/runtime.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The runtime behind the landing-page demos in each app's /public/demos.
|
|
3
|
+
*
|
|
4
|
+
* A demo module default-exports { width, height, total, poster, css, html,
|
|
5
|
+
* setup(ctx), mobile? } where setup returns seek(t), a pure function of time.
|
|
6
|
+
* The same module runs live on the landing page (mount, in a shadow root,
|
|
7
|
+
* looping while visible) and frame by frame under the content machine's
|
|
8
|
+
* renderer (render.html), so the page and the social cut share one source.
|
|
9
|
+
*
|
|
10
|
+
* This file compiles to dependency-free ESM that a browser loads as-is: each
|
|
11
|
+
* app commits a copy at public/demos/runtime.js (npm run sync:demos) and a
|
|
12
|
+
* test there fails when the copy drifts from the installed package. The app's
|
|
13
|
+
* own kit.js wraps mount() with its window chrome, base CSS and sidebar theme.
|
|
14
|
+
*/
|
|
15
|
+
export const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
|
16
|
+
export const seg = (t, a, b) => clamp01((t - a) / (b - a));
|
|
17
|
+
export const eo = (x) => 1 - Math.pow(1 - clamp01(x), 3);
|
|
18
|
+
export const eio = (x) => {
|
|
19
|
+
x = clamp01(x);
|
|
20
|
+
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
|
21
|
+
};
|
|
22
|
+
export const typed = (s, t, t0, cps = 34) => s.slice(0, Math.max(0, Math.floor((t - t0) * cps)));
|
|
23
|
+
export const spin = (t) => '◐◓◑◒'[Math.floor(t * 8) % 4];
|
|
24
|
+
export const money = (v, dp = 2) => '$' +
|
|
25
|
+
v.toLocaleString('en-US', {
|
|
26
|
+
minimumFractionDigits: dp,
|
|
27
|
+
maximumFractionDigits: dp,
|
|
28
|
+
});
|
|
29
|
+
export function rise(el, p, dy = 40) {
|
|
30
|
+
el.style.opacity = String(p);
|
|
31
|
+
el.style.transform = `translateY(${(1 - p) * dy}px)`;
|
|
32
|
+
}
|
|
33
|
+
/* Opacity for an element whose content changes at `at`: dips through 0.15 over `d` seconds. */
|
|
34
|
+
export const dip = (t, at, d = 0.3) => Math.min(1, 0.15 + 0.85 * (Math.abs(t - at) / (d / 2)));
|
|
35
|
+
/*
|
|
36
|
+
* Change a label or badge without a one-frame pop: before `at` it shows `a`,
|
|
37
|
+
* after it `b` (text, and optional class names), with an opacity dip across
|
|
38
|
+
* the change. With `a` null only the dip applies, for text set elsewhere.
|
|
39
|
+
*/
|
|
40
|
+
export function swap(el, t, at, a, b, cls) {
|
|
41
|
+
const after = t >= at;
|
|
42
|
+
if (a != null)
|
|
43
|
+
el.textContent = after ? (b !== null && b !== void 0 ? b : '') : a;
|
|
44
|
+
if (cls)
|
|
45
|
+
el.className = after ? cls[1] : cls[0];
|
|
46
|
+
el.style.opacity = String(dip(t, at));
|
|
47
|
+
return after;
|
|
48
|
+
}
|
|
49
|
+
/* A badge that moves through states at `times`: texts[k] and classes[k], dipping at each change. */
|
|
50
|
+
export function steps(el, t, times, texts, classes) {
|
|
51
|
+
let k = 0;
|
|
52
|
+
times.forEach((x) => (k += t >= x ? 1 : 0));
|
|
53
|
+
if (texts)
|
|
54
|
+
el.textContent = texts[k];
|
|
55
|
+
if (classes)
|
|
56
|
+
el.className = classes[k];
|
|
57
|
+
el.style.opacity = String(Math.min(1, ...times.map((x) => dip(t, x))));
|
|
58
|
+
return k;
|
|
59
|
+
}
|
|
60
|
+
/* Headline entrance: blur to sharp, rising, as p goes 0 to 1. */
|
|
61
|
+
export function blurIn(el, p, dy = 24) {
|
|
62
|
+
const e = eo(p);
|
|
63
|
+
el.style.opacity = String(clamp01(p * 1.4));
|
|
64
|
+
el.style.filter = e < 1 ? `blur(${(1 - e) * 14}px)` : '';
|
|
65
|
+
el.style.transform = `translateY(${(1 - e) * dy}px)`;
|
|
66
|
+
}
|
|
67
|
+
/*
|
|
68
|
+
* A slow camera push over the loop toward (ox, oy), in stage px, easing back
|
|
69
|
+
* before the loop wraps, so no screen sits still like a slide.
|
|
70
|
+
*/
|
|
71
|
+
export function push(el, t, total, ox, oy, amount = 0.04) {
|
|
72
|
+
const k = amount *
|
|
73
|
+
eio(seg(t, 0.4, total - 1.2)) *
|
|
74
|
+
(1 - eio(seg(t, total - 1.2, total)));
|
|
75
|
+
el.style.transformOrigin = `${ox}px ${oy}px`;
|
|
76
|
+
el.style.transform = `scale(${1 + k})`;
|
|
77
|
+
}
|
|
78
|
+
/* A drawn pointer that travels from `from` to the centre of `el`, presses, and fades out. */
|
|
79
|
+
export function pointer(ctx, cur, el, t, t0, t1, { from = [260, 160], out = 0.6, } = {}) {
|
|
80
|
+
if (t < t0 - 0.15 || t > t1 + out) {
|
|
81
|
+
cur.style.opacity = '0';
|
|
82
|
+
return t > t1;
|
|
83
|
+
}
|
|
84
|
+
const r = ctx.rel(el);
|
|
85
|
+
const tx = r.x + r.w * 0.55;
|
|
86
|
+
const ty = r.y + r.h * 0.55;
|
|
87
|
+
const m = eio(seg(t, t0, t1));
|
|
88
|
+
cur.style.left = tx + (1 - m) * from[0] + 'px';
|
|
89
|
+
cur.style.top = ty + (1 - m) * from[1] + 'px';
|
|
90
|
+
cur.style.opacity = String(seg(t, t0 - 0.15, t0) * (1 - seg(t, t1 + out - 0.2, t1 + out)));
|
|
91
|
+
const pressed = t > t1 && t < t1 + 0.18;
|
|
92
|
+
cur.style.transform = `scale(${pressed ? 0.85 : 1})`;
|
|
93
|
+
return t > t1;
|
|
94
|
+
}
|
|
95
|
+
/* The stage's own reset; everything else, including the tokens, comes from the app. */
|
|
96
|
+
const STAGE_CSS = `
|
|
97
|
+
:host { display: block; }
|
|
98
|
+
.stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; overflow: hidden; }
|
|
99
|
+
:where(.stage *) { box-sizing: border-box; margin: 0; padding: 0; }
|
|
100
|
+
`;
|
|
101
|
+
function makeCtx(root, stage, getScale, theme) {
|
|
102
|
+
const $ = (id) => root.getElementById(id);
|
|
103
|
+
const rel = (el) => {
|
|
104
|
+
const s = stage.getBoundingClientRect();
|
|
105
|
+
const r = el.getBoundingClientRect();
|
|
106
|
+
const k = getScale();
|
|
107
|
+
return {
|
|
108
|
+
x: (r.left - s.left) / k,
|
|
109
|
+
y: (r.top - s.top) / k,
|
|
110
|
+
w: r.width / k,
|
|
111
|
+
h: r.height / k,
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
/* ring `hl` (absolutely positioned in its offsetParent) around `target` */
|
|
115
|
+
const ring = (hl, target, p, pad = 6) => {
|
|
116
|
+
const parent = hl.offsetParent;
|
|
117
|
+
// a hidden scene has no layout to measure; skip rather than throw
|
|
118
|
+
if (!parent) {
|
|
119
|
+
hl.style.opacity = '0';
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const a = rel(target);
|
|
123
|
+
const b = rel(parent);
|
|
124
|
+
// a camera push scales the parent; undo it so the ring sits in its local px
|
|
125
|
+
const k = b.w / parent.offsetWidth || 1;
|
|
126
|
+
hl.style.left = (a.x - b.x) / k - pad + 'px';
|
|
127
|
+
hl.style.top = (a.y - b.y) / k - pad * 0.7 + 'px';
|
|
128
|
+
hl.style.width = a.w / k + pad * 2 + 'px';
|
|
129
|
+
hl.style.height = a.h / k + pad * 1.4 + 'px';
|
|
130
|
+
hl.style.opacity = String(p);
|
|
131
|
+
};
|
|
132
|
+
/* slide the pill of the sidebar inside `scope` to `k`, blending from `from` by m */
|
|
133
|
+
const nav = (k, from = k, m = 1, scope = root) => {
|
|
134
|
+
if (!theme)
|
|
135
|
+
return;
|
|
136
|
+
const side = scope.querySelector(theme.side);
|
|
137
|
+
if (!side)
|
|
138
|
+
return;
|
|
139
|
+
const item = (key) => side.querySelector(`[data-k="${key}"]`);
|
|
140
|
+
const y = (key) => { var _a, _b; return (_b = (_a = item(key)) === null || _a === void 0 ? void 0 : _a.offsetTop) !== null && _b !== void 0 ? _b : 0; };
|
|
141
|
+
const pill = side.querySelector('.pill');
|
|
142
|
+
if (pill)
|
|
143
|
+
pill.style.top = y(from) + (y(k) - y(from)) * eio(m) + 'px';
|
|
144
|
+
// the active item's colour blends across the move instead of switching
|
|
145
|
+
const w = eio(m);
|
|
146
|
+
side.querySelectorAll('.nv').forEach((d) => {
|
|
147
|
+
var _a;
|
|
148
|
+
const key = d.dataset.k;
|
|
149
|
+
const on = (key === k ? w : 0) + (key === from && from !== k ? 1 - w : 0);
|
|
150
|
+
const rest = d.classList.contains('sub')
|
|
151
|
+
? ((_a = theme.restSub) !== null && _a !== void 0 ? _a : theme.rest)
|
|
152
|
+
: theme.rest;
|
|
153
|
+
const c = rest.map((v, i) => Math.round(v + (theme.active[i] - v) * on));
|
|
154
|
+
d.classList.remove('on');
|
|
155
|
+
d.style.color = `rgb(${c.join(',')})`;
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
return { $, root, stage, rel, ring, nav };
|
|
159
|
+
}
|
|
160
|
+
/* The phone layout of a demo: its `mobile` block overrides the stage size and adds CSS. */
|
|
161
|
+
export function variant(def, phone) {
|
|
162
|
+
if (!phone || !def.mobile)
|
|
163
|
+
return def;
|
|
164
|
+
return Object.assign(Object.assign(Object.assign({}, def), def.mobile), { css: (def.css || '') + (def.mobile.css || '') });
|
|
165
|
+
}
|
|
166
|
+
/* Phones: screens narrower than Tailwind's sm breakpoint get a demo's phone layout. */
|
|
167
|
+
const PHONE = '(max-width: 639px)';
|
|
168
|
+
/*
|
|
169
|
+
* Mount a demo into `host` (a shadow root keeps its CSS off the page). With
|
|
170
|
+
* autoplay it scales to the host's width, uses the phone layout on narrow
|
|
171
|
+
* screens, loops while on screen, and holds the poster frame for reduced
|
|
172
|
+
* motion; without, it sits at native size for the renderer (`phone` picks the
|
|
173
|
+
* phone layout there).
|
|
174
|
+
*/
|
|
175
|
+
export function mount(host, def, { autoplay = true, phone = false, css = '', theme } = {}) {
|
|
176
|
+
var _a;
|
|
177
|
+
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
|
|
178
|
+
let scale = 1;
|
|
179
|
+
let v = def;
|
|
180
|
+
let stage;
|
|
181
|
+
let seek = () => { };
|
|
182
|
+
const build = (isPhone) => {
|
|
183
|
+
v = variant(def, isPhone);
|
|
184
|
+
root.innerHTML = `<style>${STAGE_CSS}${css}${v.css || ''}</style>
|
|
185
|
+
<div class="stage" style="width:${v.width}px;height:${v.height}px">${v.html}</div>`;
|
|
186
|
+
stage = root.querySelector('.stage');
|
|
187
|
+
const ctx = makeCtx(root, stage, () => scale, theme);
|
|
188
|
+
// each window's pill starts under the item its chrome marked active, so a
|
|
189
|
+
// demo that never calls nav() does not show it on the first item
|
|
190
|
+
if (theme)
|
|
191
|
+
root.querySelectorAll(theme.side).forEach((side) => {
|
|
192
|
+
const on = side.querySelector('.nv.on');
|
|
193
|
+
if ((on === null || on === void 0 ? void 0 : on.dataset.k) && side.parentNode)
|
|
194
|
+
ctx.nav(on.dataset.k, on.dataset.k, 1, side.parentNode);
|
|
195
|
+
});
|
|
196
|
+
const pose = v.setup(ctx);
|
|
197
|
+
// [data-loop] content fades in at the start of the loop and out at its end,
|
|
198
|
+
// so the wrap back to the first frame is a dissolve, not a jump.
|
|
199
|
+
const looped = [...root.querySelectorAll('[data-loop]')];
|
|
200
|
+
const total = v.total;
|
|
201
|
+
seek = (t) => {
|
|
202
|
+
pose(t);
|
|
203
|
+
const f = Math.min(seg(t, 0, 0.35), 1 - seg(t, total - 0.35, total));
|
|
204
|
+
looped.forEach((el) => (el.style.opacity = String(f)));
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
const poster = (_a = def.poster) !== null && _a !== void 0 ? _a : 0;
|
|
208
|
+
if (!autoplay) {
|
|
209
|
+
build(phone);
|
|
210
|
+
seek(poster);
|
|
211
|
+
return { seek: (t) => seek(t), destroy() { } };
|
|
212
|
+
}
|
|
213
|
+
const phoneQuery = matchMedia(PHONE);
|
|
214
|
+
const fit = () => {
|
|
215
|
+
scale = host.clientWidth / v.width || 1;
|
|
216
|
+
stage.style.transform = `scale(${scale})`;
|
|
217
|
+
};
|
|
218
|
+
build(phoneQuery.matches);
|
|
219
|
+
fit();
|
|
220
|
+
const ro = new ResizeObserver(fit);
|
|
221
|
+
ro.observe(host);
|
|
222
|
+
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
|
|
223
|
+
let t = poster;
|
|
224
|
+
let started = false;
|
|
225
|
+
let last = null;
|
|
226
|
+
let raf = 0;
|
|
227
|
+
let visible = false;
|
|
228
|
+
seek(t);
|
|
229
|
+
const loop = (now) => {
|
|
230
|
+
if (last != null)
|
|
231
|
+
t = (t + Math.min(0.1, (now - last) / 1000)) % def.total;
|
|
232
|
+
last = now;
|
|
233
|
+
seek(t);
|
|
234
|
+
raf = requestAnimationFrame(loop);
|
|
235
|
+
};
|
|
236
|
+
const update = () => {
|
|
237
|
+
cancelAnimationFrame(raf);
|
|
238
|
+
last = null;
|
|
239
|
+
if (reduce.matches) {
|
|
240
|
+
t = poster;
|
|
241
|
+
seek(t);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (visible && !document.hidden) {
|
|
245
|
+
if (!started) {
|
|
246
|
+
started = true;
|
|
247
|
+
t = 0;
|
|
248
|
+
}
|
|
249
|
+
raf = requestAnimationFrame(loop);
|
|
250
|
+
}
|
|
251
|
+
else
|
|
252
|
+
seek(t);
|
|
253
|
+
};
|
|
254
|
+
const relayout = () => {
|
|
255
|
+
build(phoneQuery.matches);
|
|
256
|
+
fit();
|
|
257
|
+
update();
|
|
258
|
+
};
|
|
259
|
+
const io = new IntersectionObserver(([e]) => {
|
|
260
|
+
visible = e.isIntersecting;
|
|
261
|
+
update();
|
|
262
|
+
}, { threshold: 0.2 });
|
|
263
|
+
io.observe(host);
|
|
264
|
+
document.addEventListener('visibilitychange', update);
|
|
265
|
+
reduce.addEventListener('change', update);
|
|
266
|
+
phoneQuery.addEventListener('change', relayout);
|
|
267
|
+
return {
|
|
268
|
+
seek: (x) => seek(x),
|
|
269
|
+
destroy() {
|
|
270
|
+
cancelAnimationFrame(raf);
|
|
271
|
+
io.disconnect();
|
|
272
|
+
ro.disconnect();
|
|
273
|
+
document.removeEventListener('visibilitychange', update);
|
|
274
|
+
reduce.removeEventListener('change', update);
|
|
275
|
+
phoneQuery.removeEventListener('change', relayout);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
package/hooks/use-toast.d.ts
CHANGED
|
@@ -4,25 +4,25 @@ export interface ToastMessage {
|
|
|
4
4
|
message: string;
|
|
5
5
|
duration?: number;
|
|
6
6
|
}
|
|
7
|
+
/** Test-only: clear the shared store between tests. */
|
|
8
|
+
export declare function resetToastsForTests(): void;
|
|
9
|
+
/** Renders the shared toasts, but only in the first mounted container. */
|
|
10
|
+
declare function SharedToastContainer(): import("react").JSX.Element;
|
|
7
11
|
/**
|
|
8
|
-
*
|
|
12
|
+
* Toast notifications on Flowbite React, shared across the app.
|
|
9
13
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
14
|
+
* Every call raises into one store, and the first mounted `ToastContainer`
|
|
15
|
+
* renders it, so a component can raise a toast without rendering a
|
|
16
|
+
* container of its own, and two containers never show a toast twice.
|
|
12
17
|
*
|
|
13
18
|
* @example
|
|
14
19
|
* ```tsx
|
|
15
20
|
* function MyComponent() {
|
|
16
|
-
* const { showSuccess,
|
|
17
|
-
*
|
|
18
|
-
* const handleSuccess = () => {
|
|
19
|
-
* showSuccess('Operation completed successfully!')
|
|
20
|
-
* }
|
|
21
|
-
*
|
|
21
|
+
* const { showSuccess, ToastContainer } = useToast()
|
|
22
22
|
* return (
|
|
23
23
|
* <>
|
|
24
24
|
* <ToastContainer />
|
|
25
|
-
* <button onClick={
|
|
25
|
+
* <button onClick={() => showSuccess('Saved')}>Save</button>
|
|
26
26
|
* </>
|
|
27
27
|
* )
|
|
28
28
|
* }
|
|
@@ -33,5 +33,6 @@ export declare function useToast(): {
|
|
|
33
33
|
showError: (message: string, duration?: number) => void;
|
|
34
34
|
showWarning: (message: string, duration?: number) => void;
|
|
35
35
|
showInfo: (message: string, duration?: number) => void;
|
|
36
|
-
ToastContainer:
|
|
36
|
+
ToastContainer: typeof SharedToastContainer;
|
|
37
37
|
};
|
|
38
|
+
export {};
|
package/hooks/use-toast.js
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Toast, ToastToggle } from 'flowbite-react';
|
|
4
|
-
import { useCallback, useEffect,
|
|
4
|
+
import { useCallback, useEffect, useId, useSyncExternalStore } from 'react';
|
|
5
5
|
import { HiCheckCircle, HiExclamation, HiInformationCircle, } from 'react-icons/hi';
|
|
6
|
+
let toasts = [];
|
|
7
|
+
let hosts = [];
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
const timeouts = new Map();
|
|
10
|
+
const EMPTY = [];
|
|
11
|
+
function emit() {
|
|
12
|
+
listeners.forEach((listener) => listener());
|
|
13
|
+
}
|
|
14
|
+
function subscribe(listener) {
|
|
15
|
+
listeners.add(listener);
|
|
16
|
+
return () => {
|
|
17
|
+
listeners.delete(listener);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function removeToast(id) {
|
|
21
|
+
const timeout = timeouts.get(id);
|
|
22
|
+
if (timeout) {
|
|
23
|
+
clearTimeout(timeout);
|
|
24
|
+
timeouts.delete(id);
|
|
25
|
+
}
|
|
26
|
+
toasts = toasts.filter((t) => t.id !== id);
|
|
27
|
+
emit();
|
|
28
|
+
}
|
|
29
|
+
function addToast(toast) {
|
|
30
|
+
const id = Math.random().toString(36).substring(2, 11);
|
|
31
|
+
const newToast = Object.assign({ id, duration: 5000 }, toast);
|
|
32
|
+
toasts = [...toasts, newToast];
|
|
33
|
+
emit();
|
|
34
|
+
if (newToast.duration && newToast.duration > 0) {
|
|
35
|
+
timeouts.set(id, setTimeout(() => removeToast(id), newToast.duration));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Test-only: clear the shared store between tests. */
|
|
39
|
+
export function resetToastsForTests() {
|
|
40
|
+
timeouts.forEach(clearTimeout);
|
|
41
|
+
timeouts.clear();
|
|
42
|
+
toasts = [];
|
|
43
|
+
hosts = [];
|
|
44
|
+
emit();
|
|
45
|
+
}
|
|
46
|
+
const ICONS = {
|
|
47
|
+
success: HiCheckCircle,
|
|
48
|
+
error: HiExclamation,
|
|
49
|
+
warning: HiExclamation,
|
|
50
|
+
info: HiInformationCircle,
|
|
51
|
+
};
|
|
52
|
+
const COLORS = {
|
|
53
|
+
success: 'success',
|
|
54
|
+
error: 'failure',
|
|
55
|
+
warning: 'warning',
|
|
56
|
+
info: 'info',
|
|
57
|
+
};
|
|
58
|
+
const ICON_CLASSES = {
|
|
59
|
+
success: 'bg-green-100 text-green-500 dark:bg-green-800 dark:text-green-200',
|
|
60
|
+
error: 'bg-red-100 text-red-500 dark:bg-red-800 dark:text-red-200',
|
|
61
|
+
warning: 'bg-orange-100 text-orange-500 dark:bg-orange-700 dark:text-orange-200',
|
|
62
|
+
info: 'bg-primary-100 text-primary-500 dark:bg-primary-800 dark:text-primary-200',
|
|
63
|
+
};
|
|
64
|
+
/** Renders the shared toasts, but only in the first mounted container. */
|
|
65
|
+
function SharedToastContainer() {
|
|
66
|
+
const hostId = useId();
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
hosts = [...hosts, hostId];
|
|
69
|
+
emit();
|
|
70
|
+
return () => {
|
|
71
|
+
hosts = hosts.filter((h) => h !== hostId);
|
|
72
|
+
emit();
|
|
73
|
+
};
|
|
74
|
+
}, [hostId]);
|
|
75
|
+
const current = useSyncExternalStore(subscribe, () => toasts, () => EMPTY);
|
|
76
|
+
const isHost = useSyncExternalStore(subscribe, () => hosts[0] === hostId, () => false);
|
|
77
|
+
if (!isHost)
|
|
78
|
+
return null;
|
|
79
|
+
return (_jsx("div", { className: "fixed top-4 right-4 z-50 flex flex-col gap-2", children: current.map((toast) => {
|
|
80
|
+
var _a;
|
|
81
|
+
const Icon = (_a = ICONS[toast.type]) !== null && _a !== void 0 ? _a : HiInformationCircle;
|
|
82
|
+
return (_jsxs(Toast, { className: "min-w-80", color: COLORS[toast.type], children: [_jsx("div", { className: `inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${ICON_CLASSES[toast.type]}`, children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsx("div", { className: "ml-3 text-sm font-normal", children: toast.message }), _jsx(ToastToggle, { onClick: () => removeToast(toast.id) })] }, toast.id));
|
|
83
|
+
}) }));
|
|
84
|
+
}
|
|
6
85
|
/**
|
|
7
|
-
*
|
|
86
|
+
* Toast notifications on Flowbite React, shared across the app.
|
|
8
87
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
88
|
+
* Every call raises into one store, and the first mounted `ToastContainer`
|
|
89
|
+
* renders it, so a component can raise a toast without rendering a
|
|
90
|
+
* container of its own, and two containers never show a toast twice.
|
|
11
91
|
*
|
|
12
92
|
* @example
|
|
13
93
|
* ```tsx
|
|
14
94
|
* function MyComponent() {
|
|
15
|
-
* const { showSuccess,
|
|
16
|
-
*
|
|
17
|
-
* const handleSuccess = () => {
|
|
18
|
-
* showSuccess('Operation completed successfully!')
|
|
19
|
-
* }
|
|
20
|
-
*
|
|
95
|
+
* const { showSuccess, ToastContainer } = useToast()
|
|
21
96
|
* return (
|
|
22
97
|
* <>
|
|
23
98
|
* <ToastContainer />
|
|
24
|
-
* <button onClick={
|
|
99
|
+
* <button onClick={() => showSuccess('Saved')}>Save</button>
|
|
25
100
|
* </>
|
|
26
101
|
* )
|
|
27
102
|
* }
|
|
28
103
|
* ```
|
|
29
104
|
*/
|
|
30
105
|
export function useToast() {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const newToast = Object.assign({ id, duration: 5000 }, toast);
|
|
36
|
-
setToasts((prev) => [...prev, newToast]);
|
|
37
|
-
if (newToast.duration && newToast.duration > 0) {
|
|
38
|
-
const timeoutId = setTimeout(() => {
|
|
39
|
-
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
40
|
-
delete timeoutRefs.current[id];
|
|
41
|
-
}, newToast.duration);
|
|
42
|
-
timeoutRefs.current[id] = timeoutId;
|
|
43
|
-
}
|
|
44
|
-
}, []);
|
|
45
|
-
const removeToast = useCallback((id) => {
|
|
46
|
-
if (timeoutRefs.current[id]) {
|
|
47
|
-
clearTimeout(timeoutRefs.current[id]);
|
|
48
|
-
delete timeoutRefs.current[id];
|
|
49
|
-
}
|
|
50
|
-
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
51
|
-
}, []);
|
|
52
|
-
const showSuccess = useCallback((message, duration) => {
|
|
53
|
-
addToast({ type: 'success', message, duration });
|
|
54
|
-
}, [addToast]);
|
|
55
|
-
const showError = useCallback((message, duration) => {
|
|
56
|
-
addToast({ type: 'error', message, duration });
|
|
57
|
-
}, [addToast]);
|
|
58
|
-
const showWarning = useCallback((message, duration) => {
|
|
59
|
-
addToast({ type: 'warning', message, duration });
|
|
60
|
-
}, [addToast]);
|
|
61
|
-
const showInfo = useCallback((message, duration) => {
|
|
62
|
-
addToast({ type: 'info', message, duration });
|
|
63
|
-
}, [addToast]);
|
|
64
|
-
// Cleanup all timeouts on unmount
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
const timeouts = timeoutRefs.current;
|
|
67
|
-
return () => {
|
|
68
|
-
Object.values(timeouts).forEach(clearTimeout);
|
|
69
|
-
};
|
|
70
|
-
}, []);
|
|
71
|
-
const ToastContainer = useCallback(() => (_jsx("div", { className: "fixed top-4 right-4 z-50 flex flex-col gap-2", children: toasts.map((toast) => {
|
|
72
|
-
const getIcon = () => {
|
|
73
|
-
switch (toast.type) {
|
|
74
|
-
case 'success':
|
|
75
|
-
return HiCheckCircle;
|
|
76
|
-
case 'error':
|
|
77
|
-
return HiExclamation;
|
|
78
|
-
case 'warning':
|
|
79
|
-
return HiExclamation;
|
|
80
|
-
case 'info':
|
|
81
|
-
return HiInformationCircle;
|
|
82
|
-
default:
|
|
83
|
-
return HiInformationCircle;
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
const getColor = () => {
|
|
87
|
-
switch (toast.type) {
|
|
88
|
-
case 'success':
|
|
89
|
-
return 'success';
|
|
90
|
-
case 'error':
|
|
91
|
-
return 'failure';
|
|
92
|
-
case 'warning':
|
|
93
|
-
return 'warning';
|
|
94
|
-
case 'info':
|
|
95
|
-
return 'info';
|
|
96
|
-
default:
|
|
97
|
-
return 'info';
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
const Icon = getIcon();
|
|
101
|
-
const color = getColor();
|
|
102
|
-
return (_jsxs(Toast, { className: "min-w-80", color: color, children: [_jsx("div", { className: `inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${toast.type === 'success'
|
|
103
|
-
? 'bg-green-100 text-green-500 dark:bg-green-800 dark:text-green-200'
|
|
104
|
-
: toast.type === 'error'
|
|
105
|
-
? 'bg-red-100 text-red-500 dark:bg-red-800 dark:text-red-200'
|
|
106
|
-
: toast.type === 'warning'
|
|
107
|
-
? 'bg-orange-100 text-orange-500 dark:bg-orange-700 dark:text-orange-200'
|
|
108
|
-
: 'bg-primary-100 text-primary-500 dark:bg-primary-800 dark:text-primary-200'}`, children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsx("div", { className: "ml-3 text-sm font-normal", children: toast.message }), _jsx(ToastToggle, { onClick: () => removeToast(toast.id) })] }, toast.id));
|
|
109
|
-
}) })), [toasts, removeToast]);
|
|
106
|
+
const showSuccess = useCallback((message, duration) => addToast({ type: 'success', message, duration }), []);
|
|
107
|
+
const showError = useCallback((message, duration) => addToast({ type: 'error', message, duration }), []);
|
|
108
|
+
const showWarning = useCallback((message, duration) => addToast({ type: 'warning', message, duration }), []);
|
|
109
|
+
const showInfo = useCallback((message, duration) => addToast({ type: 'info', message, duration }), []);
|
|
110
110
|
return {
|
|
111
111
|
showSuccess,
|
|
112
112
|
showError,
|
|
113
113
|
showWarning,
|
|
114
114
|
showInfo,
|
|
115
|
-
ToastContainer,
|
|
115
|
+
ToastContainer: SharedToastContainer,
|
|
116
116
|
};
|
|
117
117
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface LiveDemoProps {
|
|
2
|
+
/** Demo module name under /public/demos (e.g. "hero", "inbox"). */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Stage aspect ratio as width / height, reserved before the demo loads. */
|
|
5
|
+
aspect: number;
|
|
6
|
+
/**
|
|
7
|
+
* The phone layout's aspect ratio (the demo module's `mobile` width / height),
|
|
8
|
+
* used below the sm breakpoint, where kit.js mounts that layout.
|
|
9
|
+
*/
|
|
10
|
+
phoneAspect?: number;
|
|
11
|
+
/** What the animation shows, for screen readers. */
|
|
12
|
+
label: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* An animated product demo from the app's /public/demos, mounted in a shadow root so its
|
|
17
|
+
* styles stay off the page. The demos are plain ES modules shared with the
|
|
18
|
+
* content machine's renderer, which shoots the same files for the social cuts;
|
|
19
|
+
* see demos/runtime.ts in this package. They loop only while on screen and hold one frame
|
|
20
|
+
* for reduced motion.
|
|
21
|
+
*/
|
|
22
|
+
export declare function LiveDemo({ name, aspect, label, phoneAspect, className, }: LiveDemoProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* An animated product demo from the app's /public/demos, mounted in a shadow root so its
|
|
6
|
+
* styles stay off the page. The demos are plain ES modules shared with the
|
|
7
|
+
* content machine's renderer, which shoots the same files for the social cuts;
|
|
8
|
+
* see demos/runtime.ts in this package. They loop only while on screen and hold one frame
|
|
9
|
+
* for reduced motion.
|
|
10
|
+
*/
|
|
11
|
+
export function LiveDemo({ name, aspect, label, phoneAspect, className = '', }) {
|
|
12
|
+
const host = useRef(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
let handle;
|
|
15
|
+
let cancelled = false;
|
|
16
|
+
const load = async () => {
|
|
17
|
+
// Served from /public at runtime, never bundled.
|
|
18
|
+
const base = '/demos/';
|
|
19
|
+
const [kit, demo] = await Promise.all([
|
|
20
|
+
import(
|
|
21
|
+
/* webpackIgnore: true */ /* turbopackIgnore: true */ base + 'kit.js'),
|
|
22
|
+
import(
|
|
23
|
+
/* webpackIgnore: true */ /* turbopackIgnore: true */ base +
|
|
24
|
+
name +
|
|
25
|
+
'.js'),
|
|
26
|
+
]);
|
|
27
|
+
if (cancelled || !host.current)
|
|
28
|
+
return;
|
|
29
|
+
handle = kit.mount(host.current, demo.default);
|
|
30
|
+
};
|
|
31
|
+
load().catch((err) => {
|
|
32
|
+
// The page stands without the demo, but a broken module should still show up.
|
|
33
|
+
console.error(`LiveDemo "${name}" failed to load`, err);
|
|
34
|
+
});
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
handle === null || handle === void 0 ? void 0 : handle.destroy();
|
|
38
|
+
};
|
|
39
|
+
}, [name]);
|
|
40
|
+
return (_jsx("div", { ref: host, role: "img", "aria-label": label, className: `relative aspect-(--aspect) w-full overflow-hidden max-sm:aspect-(--phone-aspect) ${className}`, style: {
|
|
41
|
+
'--aspect': aspect,
|
|
42
|
+
'--phone-aspect': phoneAspect !== null && phoneAspect !== void 0 ? phoneAspect : aspect,
|
|
43
|
+
} }));
|
|
44
|
+
}
|
|
@@ -2,6 +2,7 @@ export { ACCOUNT_SETTINGS_PATH, useAccountSettingsLink, type AccountSettingsLink
|
|
|
2
2
|
export { CoreNavbar } from './CoreNavbar';
|
|
3
3
|
export { CoreSidebar, type SidebarItemData } from './CoreSidebar';
|
|
4
4
|
export { LandingFooter, type FooterLink, type LandingFooterProps, } from './LandingFooter';
|
|
5
|
+
export { LiveDemo, type LiveDemoProps } from './LiveDemo';
|
|
5
6
|
export { PageContainer } from './PageContainer';
|
|
6
7
|
export { PageHeader, type PageHeaderProps } from './PageHeader';
|
|
7
8
|
export { SettingsContainer } from './SettingsContainer';
|
|
@@ -2,6 +2,7 @@ export { ACCOUNT_SETTINGS_PATH, useAccountSettingsLink, } from './account-settin
|
|
|
2
2
|
export { CoreNavbar } from './CoreNavbar';
|
|
3
3
|
export { CoreSidebar } from './CoreSidebar';
|
|
4
4
|
export { LandingFooter, } from './LandingFooter';
|
|
5
|
+
export { LiveDemo } from './LiveDemo';
|
|
5
6
|
export { PageContainer } from './PageContainer';
|
|
6
7
|
export { PageHeader } from './PageHeader';
|
|
7
8
|
export { SettingsContainer } from './SettingsContainer';
|