@enigmax/primitives 0.11.0 → 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/dist/{chunk-42Y7OOJM.js → chunk-DD2X5B2J.js} +331 -1
- package/dist/{chunk-L4KA342C.js → chunk-YFM2FWHQ.js} +259 -7
- package/dist/index.d.ts +213 -1
- package/dist/index.js +1 -1
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +3 -3
- package/dist/react/index.d.ts +98 -4
- package/dist/react/index.js +2 -2
- package/dist/react-router/index.d.ts +2 -2
- package/dist/react-router/index.js +3 -3
- package/package.json +22 -11
- package/recipes/notifications/tailwind.tsx +60 -0
- package/recipes/relative-time/styles.css +24 -0
- package/recipes/toast/styles.css +165 -0
- package/recipes/toast/tailwind.tsx +64 -0
- package/registry.json +267 -0
- package/src/core/network.ts +138 -0
- package/src/core/notifications.ts +250 -0
- package/src/core/relative-time.ts +213 -0
- package/src/index.ts +15 -0
- package/src/react/button.tsx +13 -1
- package/src/react/index.ts +5 -0
- package/src/react/relative-time.ts +103 -0
- package/src/react/toaster.tsx +238 -0
- package/src/react/use-button.ts +4 -2
- package/src/react/use-network-state.ts +33 -0
- package/src/react/use-notifications.ts +43 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { defaultQueue } from "@/react/use-notifications";
|
|
4
|
+
import type { Notification, Notifications } from "@/core/notifications";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `<Toaster />` - the stack, the movement and the gestures. No colours.
|
|
9
|
+
*
|
|
10
|
+
* The queue underneath already owns ordering, dedupe by key, sticky errors and timers that
|
|
11
|
+
* HOLD while the tab is hidden. What this adds is everything you cannot express in a queue:
|
|
12
|
+
* a toast that is on its way out is still on screen, a pointer resting on the stack must
|
|
13
|
+
* stop the clock, and a swipe has to follow the finger before it decides anything.
|
|
14
|
+
*
|
|
15
|
+
* Every part carries a data attribute, so the theme is a stylesheet you own. `enigma add
|
|
16
|
+
* toast --copy` writes one.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type ToastPosition =
|
|
20
|
+
| "top-left" | "top-center" | "top-right"
|
|
21
|
+
| "bottom-left" | "bottom-center" | "bottom-right";
|
|
22
|
+
|
|
23
|
+
export interface ToasterProps {
|
|
24
|
+
/** Which queue to render. The shared one by default. */
|
|
25
|
+
queue?: Notifications;
|
|
26
|
+
position?: ToastPosition;
|
|
27
|
+
/**
|
|
28
|
+
* How long the exit animation lasts, in ms. The node stays mounted for exactly this
|
|
29
|
+
* long after it leaves the queue - a toast that vanishes the instant it is dismissed
|
|
30
|
+
* cannot animate out, and that is the only reason this component keeps its own list.
|
|
31
|
+
*/
|
|
32
|
+
exitDuration?: number;
|
|
33
|
+
/** Px a toast must travel before the release dismisses it. */
|
|
34
|
+
swipeThreshold?: number;
|
|
35
|
+
/** Render a toast yourself. The default renders title, body and the action. */
|
|
36
|
+
children?: (notification: Notification, controls: ToastControls) => ReactNode;
|
|
37
|
+
/** Accessible name for the region. */
|
|
38
|
+
label?: string;
|
|
39
|
+
className?: string;
|
|
40
|
+
style?: CSSProperties;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ToastControls {
|
|
44
|
+
dismiss: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Rendered {
|
|
48
|
+
notification: Notification;
|
|
49
|
+
/** Set once it has left the queue and is animating out. */
|
|
50
|
+
leaving: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Swipe follows the edge the stack is pinned to, so a toast never slides across the page. */
|
|
54
|
+
function swipeAxis(position: ToastPosition): { axis: "x" | "y"; sign: number; } {
|
|
55
|
+
if (position.endsWith("-center")) return { axis: "y", sign: position.startsWith("top") ? -1 : 1 };
|
|
56
|
+
return { axis: "x", sign: position.endsWith("-left") ? -1 : 1 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function Toaster({
|
|
60
|
+
queue = defaultQueue,
|
|
61
|
+
position = "bottom-right",
|
|
62
|
+
exitDuration = 200,
|
|
63
|
+
swipeThreshold = 60,
|
|
64
|
+
children,
|
|
65
|
+
label = "Notifications",
|
|
66
|
+
className,
|
|
67
|
+
style
|
|
68
|
+
}: ToasterProps): ReactNode {
|
|
69
|
+
// Seeded from the queue rather than empty: a notify() that happened before this mounted
|
|
70
|
+
// - during a redirect, or from a module that runs early - would otherwise be invisible
|
|
71
|
+
// for a frame. On a server the queue is empty, so this still renders nothing there.
|
|
72
|
+
const [rendered, setRendered] = useState<Rendered[]>(
|
|
73
|
+
() => queue.items.map((notification) => ({ notification, leaving: false }))
|
|
74
|
+
);
|
|
75
|
+
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const sync = (items: readonly Notification[]): void => {
|
|
79
|
+
setRendered((current) => {
|
|
80
|
+
const live = new Map(items.map((item) => [item.id, item]));
|
|
81
|
+
const kept = current.map((entry) => {
|
|
82
|
+
const still = live.get(entry.notification.id);
|
|
83
|
+
if (still) return { notification: still, leaving: false };
|
|
84
|
+
if (entry.leaving) return entry;
|
|
85
|
+
|
|
86
|
+
// It has just left the queue: hold the node for the exit animation, and
|
|
87
|
+
// only then drop it. Without this the stack jumps.
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
timers.current.delete(entry.notification.id);
|
|
90
|
+
setRendered((later) => later.filter((row) => row.notification.id !== entry.notification.id));
|
|
91
|
+
}, exitDuration);
|
|
92
|
+
timers.current.set(entry.notification.id, timer);
|
|
93
|
+
return { ...entry, leaving: true };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const seen = new Set(kept.map((entry) => entry.notification.id));
|
|
97
|
+
const added = items.filter((item) => !seen.has(item.id)).map((item) => ({ notification: item, leaving: false }));
|
|
98
|
+
return [...kept, ...added];
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
sync(queue.items);
|
|
103
|
+
return queue.subscribe(sync);
|
|
104
|
+
}, [queue, exitDuration]);
|
|
105
|
+
|
|
106
|
+
// Clearing on unmount only: a timer per exiting toast is cheap, and clearing them on
|
|
107
|
+
// every render would cancel the exit that is currently running.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const pending = timers.current;
|
|
110
|
+
return () => {
|
|
111
|
+
for (const timer of pending.values()) clearTimeout(timer);
|
|
112
|
+
pending.clear();
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
if (!rendered.length) return null;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<section
|
|
120
|
+
aria-label={label}
|
|
121
|
+
data-enigma-toaster=""
|
|
122
|
+
data-position={position}
|
|
123
|
+
className={className}
|
|
124
|
+
style={style}
|
|
125
|
+
// A pointer resting on the stack stops every clock, so a message cannot expire
|
|
126
|
+
// while it is being read. Focus counts too, for anyone using a keyboard.
|
|
127
|
+
onPointerEnter={() => queue.pause()}
|
|
128
|
+
onPointerLeave={() => queue.resume()}
|
|
129
|
+
onFocusCapture={() => queue.pause()}
|
|
130
|
+
onBlurCapture={() => queue.resume()}
|
|
131
|
+
>
|
|
132
|
+
{rendered.map((entry, index) => (
|
|
133
|
+
<Toast
|
|
134
|
+
key={entry.notification.id}
|
|
135
|
+
entry={entry}
|
|
136
|
+
index={rendered.length - 1 - index}
|
|
137
|
+
position={position}
|
|
138
|
+
swipeThreshold={swipeThreshold}
|
|
139
|
+
onDismiss={() => queue.dismiss(entry.notification.id)}
|
|
140
|
+
render={children}
|
|
141
|
+
/>
|
|
142
|
+
))}
|
|
143
|
+
</section>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface ToastProps {
|
|
148
|
+
entry: Rendered;
|
|
149
|
+
/** 0 is the front of the stack. Drives the offset and scale in the theme. */
|
|
150
|
+
index: number;
|
|
151
|
+
position: ToastPosition;
|
|
152
|
+
swipeThreshold: number;
|
|
153
|
+
onDismiss: () => void;
|
|
154
|
+
render?: (notification: Notification, controls: ToastControls) => ReactNode;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Toast({ entry, index, position, swipeThreshold, onDismiss, render }: ToastProps): ReactNode {
|
|
158
|
+
const { notification, leaving } = entry;
|
|
159
|
+
const [offset, setOffset] = useState(0);
|
|
160
|
+
const [swiping, setSwiping] = useState(false);
|
|
161
|
+
const start = useRef(0);
|
|
162
|
+
const { axis, sign } = swipeAxis(position);
|
|
163
|
+
|
|
164
|
+
const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
|
165
|
+
// Not on the buttons: a press on "Undo" is a press, not the start of a drag.
|
|
166
|
+
if ((event.target as HTMLElement).closest("button")) return;
|
|
167
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
168
|
+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
169
|
+
start.current = axis === "x" ? event.clientX : event.clientY;
|
|
170
|
+
setSwiping(true);
|
|
171
|
+
}, [axis]);
|
|
172
|
+
|
|
173
|
+
const onPointerMove = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
|
174
|
+
if (!swiping) return;
|
|
175
|
+
const delta = (axis === "x" ? event.clientX : event.clientY) - start.current;
|
|
176
|
+
// Only towards the edge it is pinned to. Dragging the other way does nothing, so a
|
|
177
|
+
// scroll or a misread gesture cannot pull the toast into the middle of the page.
|
|
178
|
+
setOffset(sign > 0 ? Math.max(0, delta) : Math.min(0, delta));
|
|
179
|
+
}, [swiping, axis, sign]);
|
|
180
|
+
|
|
181
|
+
const onPointerUp = useCallback(() => {
|
|
182
|
+
if (!swiping) return;
|
|
183
|
+
setSwiping(false);
|
|
184
|
+
if (Math.abs(offset) >= swipeThreshold) {
|
|
185
|
+
onDismiss();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
setOffset(0); // Under the threshold it springs back, so nothing is lost by accident.
|
|
189
|
+
}, [swiping, offset, swipeThreshold, onDismiss]);
|
|
190
|
+
|
|
191
|
+
const controls: ToastControls = { dismiss: onDismiss };
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<article
|
|
195
|
+
data-enigma-toast=""
|
|
196
|
+
data-tone={notification.tone}
|
|
197
|
+
data-state={leaving ? "leaving" : "open"}
|
|
198
|
+
data-index={index}
|
|
199
|
+
data-front={index === 0 ? "" : undefined}
|
|
200
|
+
data-swiping={swiping ? "" : undefined}
|
|
201
|
+
// An error interrupts; anything else waits its turn rather than talking over
|
|
202
|
+
// whatever a screen reader is in the middle of.
|
|
203
|
+
role={notification.tone === "error" ? "alert" : "status"}
|
|
204
|
+
aria-live={notification.tone === "error" ? "assertive" : "polite"}
|
|
205
|
+
tabIndex={0}
|
|
206
|
+
onKeyDown={(event) => { if (event.key === "Escape") onDismiss(); }}
|
|
207
|
+
onPointerDown={onPointerDown}
|
|
208
|
+
onPointerMove={onPointerMove}
|
|
209
|
+
onPointerUp={onPointerUp}
|
|
210
|
+
onPointerCancel={onPointerUp}
|
|
211
|
+
style={{
|
|
212
|
+
// Read by the theme, so the transform stays the stylesheet's decision.
|
|
213
|
+
"--enigma-toast-swipe": `${offset}px`,
|
|
214
|
+
"--enigma-toast-index": index
|
|
215
|
+
} as CSSProperties}
|
|
216
|
+
>
|
|
217
|
+
{render
|
|
218
|
+
? render(notification, controls)
|
|
219
|
+
: (
|
|
220
|
+
<>
|
|
221
|
+
<p data-enigma-toast-title="">{notification.title}</p>
|
|
222
|
+
{notification.body && <p data-enigma-toast-body="">{notification.body}</p>}
|
|
223
|
+
{notification.action && (
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
data-enigma-toast-action=""
|
|
227
|
+
onClick={() => {
|
|
228
|
+
notification.action?.onSelect();
|
|
229
|
+
if (notification.action?.dismiss !== false) onDismiss();
|
|
230
|
+
}}
|
|
231
|
+
>{notification.action.label}</button>
|
|
232
|
+
)}
|
|
233
|
+
<button type="button" data-enigma-toast-close="" aria-label="Dismiss" onClick={onDismiss}>×</button>
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
</article>
|
|
237
|
+
);
|
|
238
|
+
}
|
package/src/react/use-button.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { createButton, type ButtonOptions, type ButtonState } from "@/core/butto
|
|
|
4
4
|
export interface UseButtonResult extends ButtonState {
|
|
5
5
|
/** Spread onto the element named by `element`. */
|
|
6
6
|
props: {
|
|
7
|
-
onClick: (event: { preventDefault(): void; }) => void;
|
|
7
|
+
onClick: (event: Event | { preventDefault(): void; }) => void;
|
|
8
8
|
"aria-disabled": boolean;
|
|
9
9
|
"aria-busy": boolean;
|
|
10
10
|
"data-loading"?: "";
|
|
@@ -76,7 +76,9 @@ export function useButton(options: ButtonOptions = {}): UseButtonResult {
|
|
|
76
76
|
// An unavailable link still receives clicks - aria-disabled is advisory -
|
|
77
77
|
// so the press is refused here rather than relying on the attribute.
|
|
78
78
|
if (!state.available) { event.preventDefault(); return; }
|
|
79
|
-
|
|
79
|
+
// Passed through: a handler that wants to stop propagation or read the
|
|
80
|
+
// target had no way to get at it while press() was called bare.
|
|
81
|
+
void instance.press(event as Event);
|
|
80
82
|
},
|
|
81
83
|
"aria-disabled": !state.available,
|
|
82
84
|
"aria-busy": state.loading,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useRef, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
import { createNetworkMonitor, SERVER_NETWORK_STATE, type NetworkState } from "@/core/network";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Connection state, kept in sync through useSyncExternalStore.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* const { online, recovered, slow } = useNetworkState();
|
|
9
|
+
*
|
|
10
|
+
* useEffect(() => {
|
|
11
|
+
* if (!online) notify({ key: "net", title: "No connection", tone: "error" });
|
|
12
|
+
* else if (recovered) notify({ key: "net", title: "Back online", tone: "success" });
|
|
13
|
+
* }, [online, recovered]);
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* `recovered` is false on a first load, so a page that opens online never announces a
|
|
17
|
+
* recovery that did not happen - the bug every hand-rolled `wasOffline` flag starts with.
|
|
18
|
+
*/
|
|
19
|
+
export function useNetworkState(): NetworkState {
|
|
20
|
+
// One monitor per component, torn down with it. The listeners are shared browser
|
|
21
|
+
// events, so a handful of these costs nothing.
|
|
22
|
+
const monitor = useMemo(() => createNetworkMonitor(), []);
|
|
23
|
+
const snapshot = useRef(monitor.state);
|
|
24
|
+
|
|
25
|
+
return useSyncExternalStore(
|
|
26
|
+
(listener) => monitor.subscribe((next) => {
|
|
27
|
+
snapshot.current = next;
|
|
28
|
+
listener();
|
|
29
|
+
}),
|
|
30
|
+
() => snapshot.current,
|
|
31
|
+
() => SERVER_NETWORK_STATE
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useRef, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
import { createNotifications, type Notifications, type Notification, type NotificationsOptions } from "@/core/notifications";
|
|
3
|
+
|
|
4
|
+
/** One queue per app by default; a second one would race the first for the corner. */
|
|
5
|
+
const defaultQueue = createNotifications();
|
|
6
|
+
|
|
7
|
+
export interface UseNotificationsResult extends Pick<Notifications, "notify" | "dismiss" | "dismissAll" | "pause" | "resume"> {
|
|
8
|
+
items: readonly Notification[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to a notification queue.
|
|
13
|
+
*
|
|
14
|
+
* Rendering is yours: this returns the list, the ordering and the timers, and
|
|
15
|
+
* nothing about how a notification looks.
|
|
16
|
+
*/
|
|
17
|
+
export function useNotifications(queue: Notifications = defaultQueue): UseNotificationsResult {
|
|
18
|
+
const snapshot = useRef<readonly Notification[]>(queue.items);
|
|
19
|
+
|
|
20
|
+
const items = useSyncExternalStore(
|
|
21
|
+
listener => queue.subscribe(next => {
|
|
22
|
+
snapshot.current = next;
|
|
23
|
+
listener();
|
|
24
|
+
}),
|
|
25
|
+
() => snapshot.current,
|
|
26
|
+
() => snapshot.current
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return useMemo(() => ({
|
|
30
|
+
items,
|
|
31
|
+
notify: queue.notify,
|
|
32
|
+
dismiss: queue.dismiss,
|
|
33
|
+
dismissAll: queue.dismissAll,
|
|
34
|
+
pause: queue.pause,
|
|
35
|
+
resume: queue.resume
|
|
36
|
+
}), [items, queue]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createNotificationQueue(options?: NotificationsOptions): Notifications {
|
|
40
|
+
return createNotifications(options);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { defaultQueue };
|