@stonedogcode/style 0.17.0 → 0.19.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/package.json +5 -5
- package/src/components/StyledCollapsible.tsx +14 -27
- package/src/components/StyledToaster.tsx +325 -0
- package/src/components/toaster-store.ts +334 -0
- package/src/components/useDisclosure.ts +139 -0
- package/src/index.ts +24 -0
- package/src/preset/index.ts +5 -2
- package/src/preset/recipes/toast.ts +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stonedogcode/style",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "StoneDogCode L.L.C.",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/stonedog-code/stonedog-style.git"
|
|
10
10
|
},
|
|
11
|
-
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer
|
|
11
|
+
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer — which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
|
|
12
12
|
"publishConfig": {
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"./preset": "./src/preset/index.ts",
|
|
21
21
|
"./package.json": "./package.json"
|
|
22
22
|
},
|
|
23
|
-
"//files": "The package ships SOURCE, so `src` has to go out whole
|
|
23
|
+
"//files": "The package ships SOURCE, so `src` has to go out whole — but only the part a consumer imports. Tests, Playwright component tests and their harnesses were 57 of 130 entries and never resolvable from any export, so every one of them was a file a consumer's Panda run had to be told to skip. `src/__tests__/published-package.test.ts` asserts this listing rather than this array: npm's own resolution of these negations is the only thing that decides what ships.",
|
|
24
24
|
"files": [
|
|
25
25
|
"src",
|
|
26
26
|
"!src/**/__tests__/**",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"pretype-check": "npm run panda:prepare",
|
|
39
39
|
"type-check": "tsc --noEmit",
|
|
40
40
|
"lint": "eslint . --ext ts,tsx",
|
|
41
|
-
"//pretest": "panda:build, not panda:prepare
|
|
41
|
+
"//pretest": "panda:build, not panda:prepare — the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
|
|
42
42
|
"pretest": "npm run panda:build",
|
|
43
43
|
"test": "jest",
|
|
44
44
|
"pretest:ct": "npm run panda:build",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"test:ct:ui": "playwright test --config playwright-ct.config.ts --ui",
|
|
47
47
|
"test:coverage": "jest --coverage",
|
|
48
48
|
"gate": "npm run type-check && npm run lint && npm run test",
|
|
49
|
-
"//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively
|
|
49
|
+
"//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively — npm prompts for the 2FA one-time password and the login flow needs a browser. It refuses a checkout that is detached, dirty, or behind origin/main: a submodule sits detached at the consumer's gitlink by default, and publishing from one commit behind ships a tarball missing the very thing you are publishing for while looking like a success (it did, on 2026-08-04, without TitleLogo.tsx). Runs the gate, prints the tarball listing, then proves the result by installing from the registry into a temp directory.",
|
|
50
50
|
"publish:stonedog-style": "bash scripts/publish-package.sh",
|
|
51
51
|
"version:bump:patch": "npm version patch --no-git-tag-version",
|
|
52
52
|
"version:bump:minor": "npm version minor --no-git-tag-version",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React
|
|
3
|
+
import React from "react";
|
|
4
4
|
import { styled } from "styled-system/jsx";
|
|
5
|
+
import { useDisclosure } from "./useDisclosure";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* A disclosure: a trigger that shows and hides a region.
|
|
@@ -82,41 +83,27 @@ const StyledCollapsible: React.FC<StyledCollapsibleProps> = ({
|
|
|
82
83
|
triggerTestId,
|
|
83
84
|
contentTestId,
|
|
84
85
|
}) => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const toggle = () => {
|
|
97
|
-
const next = !isOpen;
|
|
98
|
-
// The internal state moves even when controlled. If the host ignores the
|
|
99
|
-
// callback the component would otherwise appear dead to the pointer, and a
|
|
100
|
-
// control that does nothing when pressed is indistinguishable from a broken
|
|
101
|
-
// one — for this audience, the reader concludes the app is broken, not that
|
|
102
|
-
// they misread the affordance.
|
|
103
|
-
if (!isControlled) setUncontrolled(next);
|
|
104
|
-
onOpenChange?.(next);
|
|
105
|
-
};
|
|
86
|
+
// The mechanics live in `useDisclosure`, not here. This component is one
|
|
87
|
+
// ARRANGEMENT of a disclosure — a button with the trigger inside it, above
|
|
88
|
+
// the region — and a host whose control is already a button composes the
|
|
89
|
+
// same hook into its own layout instead of nesting one button in another
|
|
90
|
+
// (NEH-1100). Two arrangements, one implementation.
|
|
91
|
+
const { triggerProps, contentProps } = useDisclosure({
|
|
92
|
+
open,
|
|
93
|
+
defaultOpen,
|
|
94
|
+
onOpenChange,
|
|
95
|
+
});
|
|
106
96
|
|
|
107
97
|
return (
|
|
108
98
|
<>
|
|
109
99
|
<CollapsibleTrigger
|
|
110
|
-
|
|
111
|
-
onClick={toggle}
|
|
112
|
-
aria-expanded={isOpen}
|
|
113
|
-
aria-controls={contentId}
|
|
100
|
+
{...triggerProps}
|
|
114
101
|
aria-label={ariaLabel}
|
|
115
102
|
data-testid={triggerTestId}
|
|
116
103
|
>
|
|
117
104
|
{trigger}
|
|
118
105
|
</CollapsibleTrigger>
|
|
119
|
-
<div
|
|
106
|
+
<div {...contentProps} data-testid={contentTestId}>
|
|
120
107
|
{children}
|
|
121
108
|
</div>
|
|
122
109
|
</>
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { toastRecipe } from "styled-system/recipes";
|
|
6
|
+
import { cx } from "styled-system/css";
|
|
7
|
+
|
|
8
|
+
import StyledButton from "./StyledButton";
|
|
9
|
+
import StyledSpinner from "./StyledSpinner";
|
|
10
|
+
import StyledText from "./StyledText";
|
|
11
|
+
import type { Toast, ToasterStore, ToastType } from "./toaster-store";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Draws whatever is in a toaster store.
|
|
15
|
+
*
|
|
16
|
+
* Mount **one** of these, once, near the root of the application, and hand it
|
|
17
|
+
* the same store your `create()` calls go to. It renders nothing until there is
|
|
18
|
+
* something to show.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* export const toaster = createToaster();
|
|
22
|
+
* // …somewhere near the root:
|
|
23
|
+
* <StyledToaster toaster={toaster} />
|
|
24
|
+
* // …anywhere at all:
|
|
25
|
+
* toaster.create({ title: "Saved.", type: "success" });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* ## The three things that make this SSR-safe
|
|
29
|
+
*
|
|
30
|
+
* All three were failure modes before they were requirements, and none of them
|
|
31
|
+
* shows up in a client-only test:
|
|
32
|
+
*
|
|
33
|
+
* 1. **`getServerSnapshot`** — `useSyncExternalStore` throws during hydration
|
|
34
|
+
* without one. The store supplies a frozen empty array, the same reference
|
|
35
|
+
* every time, so React sees no change between the server render and the
|
|
36
|
+
* first client one.
|
|
37
|
+
* 2. **The portal waits for mount.** `createPortal(…, document.body)` is a
|
|
38
|
+
* `document is not defined` crash on the server. `mounted` below is false
|
|
39
|
+
* for the server render *and* for the first client render, which is what
|
|
40
|
+
* keeps the two identical — checking `typeof document` instead would make
|
|
41
|
+
* them differ and produce a hydration mismatch rather than a crash.
|
|
42
|
+
* 3. **The store refuses to queue on the server**, so a toast created during a
|
|
43
|
+
* render cannot leak into the next request. That one lives in the store; see
|
|
44
|
+
* its header.
|
|
45
|
+
*
|
|
46
|
+
* ## Timers start here, not in the store
|
|
47
|
+
*
|
|
48
|
+
* A toast created before this component mounts — during a redirect, a slow
|
|
49
|
+
* hydration, an early event handler — must still be seen. Because each toast's
|
|
50
|
+
* countdown is an effect *in the toast's own element*, it cannot start before
|
|
51
|
+
* that element exists, so an early toast waits rather than expiring unseen.
|
|
52
|
+
*/
|
|
53
|
+
export interface StyledToasterProps {
|
|
54
|
+
/** The store to draw. Create it with `createToaster()`. */
|
|
55
|
+
toaster: ToasterStore;
|
|
56
|
+
/**
|
|
57
|
+
* The glyph for each kind of toast.
|
|
58
|
+
*
|
|
59
|
+
* **This package ships no icon artwork**, deliberately — see CLAUDE.md. The
|
|
60
|
+
* defaults are text characters, which work everywhere and are nobody's
|
|
61
|
+
* favourite. Pass your own icon set here to replace them; pass `null` for a
|
|
62
|
+
* type to render no glyph at all.
|
|
63
|
+
*
|
|
64
|
+
* Whatever you pass is `aria-hidden`: the toast's role already tells a screen
|
|
65
|
+
* reader what kind of message it is, and reading "check mark" before the text
|
|
66
|
+
* is the same information twice.
|
|
67
|
+
*/
|
|
68
|
+
icons?: Partial<Record<ToastType, React.ReactNode>> | undefined;
|
|
69
|
+
/** The glyph inside the close control. Text by default, for the same reason. */
|
|
70
|
+
closeIcon?: React.ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* The close control's accessible name. It is a button whose only content is a
|
|
73
|
+
* glyph, so without a name it announces as "button" and nothing else.
|
|
74
|
+
*/
|
|
75
|
+
closeLabel?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Names the region for a screen reader listing landmarks.
|
|
78
|
+
*
|
|
79
|
+
* Not the toasts themselves — those announce individually as they arrive.
|
|
80
|
+
*/
|
|
81
|
+
regionLabel?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Text stand-ins for the artwork this package will not ship.
|
|
86
|
+
*
|
|
87
|
+
* `default` has none on purpose: it is the type for a message with no status,
|
|
88
|
+
* and inventing a glyph for "no particular kind" would say something the
|
|
89
|
+
* message does not.
|
|
90
|
+
*/
|
|
91
|
+
const DEFAULT_ICONS: Partial<Record<ToastType, React.ReactNode>> = {
|
|
92
|
+
success: "✓",
|
|
93
|
+
error: "✕",
|
|
94
|
+
warning: "!",
|
|
95
|
+
info: "i",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One toast, and the only place a dismissal timer exists.
|
|
100
|
+
*
|
|
101
|
+
* The timer is an effect keyed on `paused`, which gives pause-and-resume for
|
|
102
|
+
* free: pausing tears the effect down, and the cleanup subtracts the elapsed
|
|
103
|
+
* time from what is left, so resuming schedules the remainder rather than
|
|
104
|
+
* restarting the whole duration. Unmounting runs the same cleanup, so a toast
|
|
105
|
+
* removed mid-countdown cannot fire a state update into a component that is no
|
|
106
|
+
* longer there.
|
|
107
|
+
*/
|
|
108
|
+
function ToastItem({
|
|
109
|
+
toast,
|
|
110
|
+
paused,
|
|
111
|
+
onDismiss,
|
|
112
|
+
icons,
|
|
113
|
+
closeIcon,
|
|
114
|
+
closeLabel,
|
|
115
|
+
}: {
|
|
116
|
+
toast: Toast;
|
|
117
|
+
paused: boolean;
|
|
118
|
+
onDismiss: (id: string) => void;
|
|
119
|
+
icons: Partial<Record<ToastType, React.ReactNode>>;
|
|
120
|
+
closeIcon: React.ReactNode;
|
|
121
|
+
closeLabel: string;
|
|
122
|
+
}) {
|
|
123
|
+
/**
|
|
124
|
+
* Resolved PER TOAST, with this toast's type.
|
|
125
|
+
*
|
|
126
|
+
* The first version called `toastRecipe()` once for the whole region and
|
|
127
|
+
* shared the result, so every card came out as `toast__root--type_default`
|
|
128
|
+
* and no status accent was ever painted — the recipe was correct, its
|
|
129
|
+
* stylesheet was correct, and nothing rendered it. Neither the unit tier
|
|
130
|
+
* (which asserts roles and text) nor the token-contract test (which reads the
|
|
131
|
+
* stylesheet) could see it; the component test comparing three computed
|
|
132
|
+
* accent colours found all three identical.
|
|
133
|
+
*/
|
|
134
|
+
const classes = toastRecipe({ type: toast.type });
|
|
135
|
+
|
|
136
|
+
const remaining = useRef(toast.duration);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
// `loading` and anything given `Infinity` stay until something dismisses
|
|
140
|
+
// them. Scheduling a timeout for Infinity is not merely pointless — the
|
|
141
|
+
// value overflows a 32-bit delay and fires immediately, which would make
|
|
142
|
+
// "stays until dismissed" mean "vanishes at once".
|
|
143
|
+
if (paused || toast.dismissed || !Number.isFinite(remaining.current)) return;
|
|
144
|
+
|
|
145
|
+
const startedAt = Date.now();
|
|
146
|
+
const timer = setTimeout(() => onDismiss(toast.id), remaining.current);
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
remaining.current -= Date.now() - startedAt;
|
|
151
|
+
};
|
|
152
|
+
}, [paused, toast.dismissed, toast.id, onDismiss]);
|
|
153
|
+
|
|
154
|
+
const glyph = icons[toast.type];
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
// `status` rather than `alert`: polite, so it waits for a gap in whatever
|
|
159
|
+
// the reader is already saying instead of cutting across it. An
|
|
160
|
+
// interruption is right for a fire alarm and wrong for "Saved."
|
|
161
|
+
//
|
|
162
|
+
// `aria-atomic` makes the whole toast read as one message. Without it a
|
|
163
|
+
// reader announces only the part of the subtree that changed, which for a
|
|
164
|
+
// toast updated in place is a fragment with no context.
|
|
165
|
+
role="status"
|
|
166
|
+
aria-atomic="true"
|
|
167
|
+
data-state={toast.dismissed ? "closed" : "open"}
|
|
168
|
+
data-type={toast.type}
|
|
169
|
+
className={classes.root}
|
|
170
|
+
>
|
|
171
|
+
{/*
|
|
172
|
+
`aria-hidden` on the whole indicator, spinner included.
|
|
173
|
+
|
|
174
|
+
The glyph is hidden because the toast's role has already told the reader
|
|
175
|
+
what kind of message this is, and "check mark, Saved." is the same thing
|
|
176
|
+
twice. The SPINNER is hidden for a sharper reason: `StyledSpinner`
|
|
177
|
+
carries its own `role="status"`, so rendering it bare nests one live
|
|
178
|
+
region inside another — the message is announced twice, and the outer
|
|
179
|
+
`aria-atomic` no longer describes one coherent thing. What tells a
|
|
180
|
+
reader the work is still going is the toast's own text ("Uploading…"),
|
|
181
|
+
which is the part worth reading anyway.
|
|
182
|
+
*/}
|
|
183
|
+
{(toast.type === "loading" || glyph != null) && (
|
|
184
|
+
<div className={classes.indicator} aria-hidden="true">
|
|
185
|
+
{toast.type === "loading" ? <StyledSpinner loadText="" /> : glyph}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
<div className={classes.content}>
|
|
190
|
+
{toast.title != null && (
|
|
191
|
+
<StyledText className={classes.title}>{toast.title}</StyledText>
|
|
192
|
+
)}
|
|
193
|
+
{toast.description != null && (
|
|
194
|
+
<StyledText className={classes.description}>
|
|
195
|
+
{toast.description}
|
|
196
|
+
</StyledText>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{toast.action && (
|
|
201
|
+
<div className={classes.action}>
|
|
202
|
+
<StyledButton
|
|
203
|
+
onClick={() => {
|
|
204
|
+
toast.action?.onClick();
|
|
205
|
+
// A toast whose button has been pressed has done its job. Leaving
|
|
206
|
+
// it up invites a second press on an action that has already run.
|
|
207
|
+
onDismiss(toast.id);
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{toast.action.label}
|
|
211
|
+
</StyledButton>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{toast.closable && (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
aria-label={closeLabel}
|
|
219
|
+
className={classes.close}
|
|
220
|
+
onClick={() => onDismiss(toast.id)}
|
|
221
|
+
>
|
|
222
|
+
<span aria-hidden="true">{closeIcon}</span>
|
|
223
|
+
</button>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const StyledToaster: React.FC<StyledToasterProps> = ({
|
|
230
|
+
toaster,
|
|
231
|
+
icons,
|
|
232
|
+
closeIcon = "✕",
|
|
233
|
+
closeLabel = "Dismiss notification",
|
|
234
|
+
regionLabel = "Notifications",
|
|
235
|
+
}) => {
|
|
236
|
+
const toasts = useSyncExternalStore(
|
|
237
|
+
toaster.subscribe,
|
|
238
|
+
toaster.getSnapshot,
|
|
239
|
+
toaster.getServerSnapshot,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const [mounted, setMounted] = useState(false);
|
|
243
|
+
useEffect(() => setMounted(true), []);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Pointer or keyboard inside the region, and the tab being hidden, all stop
|
|
247
|
+
* the clock. They are one boolean rather than three because the resume
|
|
248
|
+
* condition is "none of them", and three independent flags is how a toast
|
|
249
|
+
* ends up pinned forever by a hover the pointer left through a portal.
|
|
250
|
+
*/
|
|
251
|
+
const [hovered, setHovered] = useState(false);
|
|
252
|
+
const [focused, setFocused] = useState(false);
|
|
253
|
+
const [pageHidden, setPageHidden] = useState(false);
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (typeof document === "undefined") return;
|
|
257
|
+
// A countdown that runs in a background tab is a message the user never had
|
|
258
|
+
// the chance to read. Chakra's store called this `pauseOnPageIdle` and had
|
|
259
|
+
// it on; this keeps that, without the option, because no consumer wanted
|
|
260
|
+
// the other behaviour.
|
|
261
|
+
const sync = () => setPageHidden(document.hidden);
|
|
262
|
+
sync();
|
|
263
|
+
document.addEventListener("visibilitychange", sync);
|
|
264
|
+
return () => document.removeEventListener("visibilitychange", sync);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const paused = hovered || focused || pageHidden;
|
|
268
|
+
|
|
269
|
+
// Stable identity so it is not a fresh dependency on every render of every
|
|
270
|
+
// toast — each toast's timer effect lists it.
|
|
271
|
+
const onDismiss = React.useCallback(
|
|
272
|
+
(id: string) => toaster.remove(id),
|
|
273
|
+
[toaster],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Only the region slot is read here; every card resolves its own, above.
|
|
277
|
+
const classes = toastRecipe();
|
|
278
|
+
const resolvedIcons = icons ?? DEFAULT_ICONS;
|
|
279
|
+
|
|
280
|
+
if (!mounted) return null;
|
|
281
|
+
|
|
282
|
+
return createPortal(
|
|
283
|
+
<div
|
|
284
|
+
className={cx(classes.region)}
|
|
285
|
+
// The region is present from mount and stays, whether or not it holds
|
|
286
|
+
// anything. A live region created at the same moment as its content is
|
|
287
|
+
// announced inconsistently across screen readers; one that was already
|
|
288
|
+
// there is not.
|
|
289
|
+
aria-label={regionLabel}
|
|
290
|
+
onMouseEnter={() => setHovered(true)}
|
|
291
|
+
onMouseLeave={() => setHovered(false)}
|
|
292
|
+
onFocus={() => setFocused(true)}
|
|
293
|
+
onBlur={(event) => {
|
|
294
|
+
// Only when focus has actually left the region — moving between the
|
|
295
|
+
// action and the close button inside one toast fires blur too, and
|
|
296
|
+
// treating that as "focus left" would restart the countdown under the
|
|
297
|
+
// keyboard user's hands.
|
|
298
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
299
|
+
setFocused(false);
|
|
300
|
+
}
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
{/*
|
|
304
|
+
Rendered oldest-last so the newest toast sits nearest the corner, which
|
|
305
|
+
is where the eye already is. The store keeps them newest-first because
|
|
306
|
+
that is the order its priority rules work in; the reversal is a
|
|
307
|
+
presentation decision and belongs here.
|
|
308
|
+
*/}
|
|
309
|
+
{[...toasts].reverse().map((toast) => (
|
|
310
|
+
<ToastItem
|
|
311
|
+
key={toast.id}
|
|
312
|
+
toast={toast}
|
|
313
|
+
paused={paused}
|
|
314
|
+
onDismiss={onDismiss}
|
|
315
|
+
icons={resolvedIcons}
|
|
316
|
+
closeIcon={closeIcon}
|
|
317
|
+
closeLabel={closeLabel}
|
|
318
|
+
/>
|
|
319
|
+
))}
|
|
320
|
+
</div>,
|
|
321
|
+
document.body,
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export default StyledToaster;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toast queue: a subscribable store, with no React and no DOM in it.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a store and not a component
|
|
5
|
+
*
|
|
6
|
+
* A toast is created from places that are not rendering — an event handler, a
|
|
7
|
+
* `.catch()`, a module-level helper called before anything has mounted. So the
|
|
8
|
+
* thing callers reach for cannot be a hook. It has to be a plain object with a
|
|
9
|
+
* `create()` on it, and the component that draws toasts has to *subscribe* to
|
|
10
|
+
* that object rather than own it.
|
|
11
|
+
*
|
|
12
|
+
* That shape is what `useSyncExternalStore` exists for, and the three methods
|
|
13
|
+
* below (`subscribe`, `getSnapshot`, `getServerSnapshot`) are exactly its
|
|
14
|
+
* contract. Two of its rules are easy to break and fail loudly but obscurely:
|
|
15
|
+
*
|
|
16
|
+
* - **`getSnapshot` must return the identical reference when nothing changed.**
|
|
17
|
+
* Returning a fresh array each call makes React re-render forever. `toasts`
|
|
18
|
+
* below is therefore replaced only on mutation, never rebuilt on read.
|
|
19
|
+
* - **`getServerSnapshot` is mandatory** for anything server-rendered, and must
|
|
20
|
+
* also be reference-stable. `EMPTY` is a single frozen array shared by every
|
|
21
|
+
* call for that reason.
|
|
22
|
+
*
|
|
23
|
+
* ## Why `create()` does nothing on the server
|
|
24
|
+
*
|
|
25
|
+
* A module-level array lives for the lifetime of the Node process, not the
|
|
26
|
+
* request. A toast created during SSR would therefore still be sitting in the
|
|
27
|
+
* queue when the *next* user is served by that same instance — one person's
|
|
28
|
+
* "Saved." announced to a stranger. There is no request boundary available here
|
|
29
|
+
* to scope it to, and a toast has no meaning without a browser to show it in,
|
|
30
|
+
* so the honest answer is to refuse to queue one at all.
|
|
31
|
+
*
|
|
32
|
+
* `create()` still returns the id it would have used, so a caller that stores
|
|
33
|
+
* or logs the result behaves identically in both environments.
|
|
34
|
+
*
|
|
35
|
+
* ## Timers are deliberately NOT here
|
|
36
|
+
*
|
|
37
|
+
* Auto-dismiss lives in the renderer, per toast, starting when that toast first
|
|
38
|
+
* mounts. Putting it here would start the clock at `create()` time — so a toast
|
|
39
|
+
* fired before the toaster mounted (a redirect, a slow hydration, a `create()`
|
|
40
|
+
* in a module body) could expire before it was ever drawn. It would look like
|
|
41
|
+
* the toast was silently dropped, which is the failure this whole component is
|
|
42
|
+
* most likely to be blamed for and least likely to be caught doing.
|
|
43
|
+
*
|
|
44
|
+
* What *is* here is the exit delay, because it is a property of leaving the
|
|
45
|
+
* queue rather than of being on screen: `remove()` marks a toast `dismissed`
|
|
46
|
+
* and purges it `removeDelay` ms later, so the renderer has a state to animate
|
|
47
|
+
* out of.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import type { ReactNode } from "react";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The kinds of toast, matching what the extracted application already used.
|
|
54
|
+
*
|
|
55
|
+
* `default` is a toast with no status at all — no accent, no glyph. It is not a
|
|
56
|
+
* synonym for `info`; a message that means something should say which thing it
|
|
57
|
+
* means.
|
|
58
|
+
*/
|
|
59
|
+
export type ToastType =
|
|
60
|
+
| "success"
|
|
61
|
+
| "error"
|
|
62
|
+
| "warning"
|
|
63
|
+
| "info"
|
|
64
|
+
| "loading"
|
|
65
|
+
| "default";
|
|
66
|
+
|
|
67
|
+
/** A single button on a toast. Rendered after the message. */
|
|
68
|
+
export interface ToastAction {
|
|
69
|
+
label: string;
|
|
70
|
+
onClick: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ToastOptions {
|
|
74
|
+
/**
|
|
75
|
+
* Supply one to make `create()` idempotent — creating with an id that is
|
|
76
|
+
* already on screen updates that toast in place rather than stacking a
|
|
77
|
+
* duplicate. Useful for progress ("Uploading…" → "Uploaded").
|
|
78
|
+
*/
|
|
79
|
+
id?: string | undefined;
|
|
80
|
+
title?: ReactNode;
|
|
81
|
+
description?: ReactNode;
|
|
82
|
+
type?: ToastType | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* Milliseconds on screen. Omit to use the per-type default below.
|
|
85
|
+
* `Infinity` pins the toast until it is dismissed.
|
|
86
|
+
*/
|
|
87
|
+
duration?: number | undefined;
|
|
88
|
+
action?: ToastAction | undefined;
|
|
89
|
+
/** Render a close control. */
|
|
90
|
+
closable?: boolean | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface Toast extends ToastOptions {
|
|
94
|
+
id: string;
|
|
95
|
+
type: ToastType;
|
|
96
|
+
duration: number;
|
|
97
|
+
/**
|
|
98
|
+
* Set the moment `remove()` is called and the toast starts animating out.
|
|
99
|
+
* It stays in the snapshot while true so the renderer has something to
|
|
100
|
+
* animate; it is purged `removeDelay` ms later.
|
|
101
|
+
*/
|
|
102
|
+
dismissed: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ToasterStoreOptions {
|
|
106
|
+
/**
|
|
107
|
+
* How many toasts may be on screen at once. Further ones wait in a queue and
|
|
108
|
+
* are admitted as room appears.
|
|
109
|
+
*
|
|
110
|
+
* 24 is not a considered number — it is the value the store this replaces
|
|
111
|
+
* used, kept so that the behaviour at overflow does not change silently along
|
|
112
|
+
* with everything else.
|
|
113
|
+
*/
|
|
114
|
+
max?: number;
|
|
115
|
+
/** How long a dismissed toast stays in the snapshot so it can animate out. */
|
|
116
|
+
removeDelay?: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ToasterStore {
|
|
120
|
+
/**
|
|
121
|
+
* Show a toast. Returns its id.
|
|
122
|
+
*
|
|
123
|
+
* Named `create` rather than `show` or `toast` because that is the name the
|
|
124
|
+
* 345 call sites in the application this was extracted from already use.
|
|
125
|
+
*/
|
|
126
|
+
create: (options: ToastOptions) => string;
|
|
127
|
+
/**
|
|
128
|
+
* Dismiss one toast, or every toast when called with no argument.
|
|
129
|
+
*
|
|
130
|
+
* The toast animates out first — it is marked `dismissed` immediately and
|
|
131
|
+
* leaves the snapshot `removeDelay` ms later.
|
|
132
|
+
*/
|
|
133
|
+
remove: (id?: string) => void;
|
|
134
|
+
subscribe: (listener: () => void) => () => void;
|
|
135
|
+
getSnapshot: () => readonly Toast[];
|
|
136
|
+
getServerSnapshot: () => readonly Toast[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How long each kind stays up, in milliseconds.
|
|
141
|
+
*
|
|
142
|
+
* These are not invented. They are the values `@zag-js/toast` uses, read out of
|
|
143
|
+
* the installed package rather than guessed, so that swapping the
|
|
144
|
+
* implementation underneath an application does not quietly retime every
|
|
145
|
+
* message in it. Note `success` is much shorter than the rest — a confirmation
|
|
146
|
+
* has been read the moment it is seen, whereas a warning is asking for a
|
|
147
|
+
* decision.
|
|
148
|
+
*/
|
|
149
|
+
export const DEFAULT_DURATIONS: Record<ToastType, number> = {
|
|
150
|
+
success: 2000,
|
|
151
|
+
error: 5000,
|
|
152
|
+
warning: 5000,
|
|
153
|
+
info: 5000,
|
|
154
|
+
loading: Infinity,
|
|
155
|
+
default: 5000,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Which toast wins a place on screen when more than `max` are pending.
|
|
160
|
+
*
|
|
161
|
+
* Lower sorts first. Errors outrank confirmations because a failure the user
|
|
162
|
+
* never sees is the expensive one, and within a type an *actionable* toast
|
|
163
|
+
* outranks a passive one — there is nothing to miss on a toast with no button.
|
|
164
|
+
*
|
|
165
|
+
* Only consulted past `max` simultaneous toasts, which in practice means a loop
|
|
166
|
+
* that has gone wrong. It exists so that what survives that is the half worth
|
|
167
|
+
* reading.
|
|
168
|
+
*/
|
|
169
|
+
const PRIORITY: Record<ToastType, [actionable: number, passive: number]> = {
|
|
170
|
+
error: [1, 2],
|
|
171
|
+
warning: [3, 6],
|
|
172
|
+
loading: [4, 5],
|
|
173
|
+
success: [5, 7],
|
|
174
|
+
info: [6, 8],
|
|
175
|
+
default: [6, 8],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const priorityOf = (toast: Toast): number =>
|
|
179
|
+
PRIORITY[toast.type][toast.action ? 0 : 1];
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One frozen array, returned by every server render and by any snapshot taken
|
|
183
|
+
* of an empty store. `useSyncExternalStore` compares snapshots by reference, so
|
|
184
|
+
* a fresh `[]` here would be a new value on every read.
|
|
185
|
+
*/
|
|
186
|
+
const EMPTY: readonly Toast[] = Object.freeze([]);
|
|
187
|
+
|
|
188
|
+
/** `window` is the only reliable "is there a user in front of this" signal. */
|
|
189
|
+
const inBrowser = (): boolean => typeof window !== "undefined";
|
|
190
|
+
|
|
191
|
+
export function createToaster(options: ToasterStoreOptions = {}): ToasterStore {
|
|
192
|
+
const { max = 24, removeDelay = 200 } = options;
|
|
193
|
+
|
|
194
|
+
let toasts: readonly Toast[] = EMPTY;
|
|
195
|
+
let queued: Toast[] = [];
|
|
196
|
+
let listeners: Array<() => void> = [];
|
|
197
|
+
let counter = 0;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Ids are a counter, not a random or time-based value, so that a test can
|
|
201
|
+
* assert on one and so two toasts created in the same millisecond cannot
|
|
202
|
+
* collide. They are scoped to this store and never leave it.
|
|
203
|
+
*/
|
|
204
|
+
const nextId = (): string => `toast-${++counter}`;
|
|
205
|
+
|
|
206
|
+
const emit = (): void => {
|
|
207
|
+
// Copied before iterating: a listener that unsubscribes itself while being
|
|
208
|
+
// notified would otherwise shorten the array mid-loop and skip its
|
|
209
|
+
// neighbour.
|
|
210
|
+
for (const listener of [...listeners]) listener();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** Admit queued toasts until the screen is full again. */
|
|
214
|
+
const drain = (): void => {
|
|
215
|
+
if (queued.length === 0 || toasts.length >= max) return;
|
|
216
|
+
queued.sort((a, b) => priorityOf(a) - priorityOf(b));
|
|
217
|
+
const admitted = queued.splice(0, max - toasts.length);
|
|
218
|
+
// Queued toasts were created after everything on screen, so they go in
|
|
219
|
+
// front; among themselves the highest priority leads.
|
|
220
|
+
toasts = [...admitted, ...toasts];
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const purge = (id: string): void => {
|
|
224
|
+
const next = toasts.filter((toast) => toast.id !== id);
|
|
225
|
+
if (next.length === toasts.length) return;
|
|
226
|
+
toasts = next.length === 0 ? EMPTY : next;
|
|
227
|
+
drain();
|
|
228
|
+
emit();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const create = (options: ToastOptions): string => {
|
|
232
|
+
const id = options.id ?? nextId();
|
|
233
|
+
|
|
234
|
+
// See the header: never queue on the server.
|
|
235
|
+
if (!inBrowser()) return id;
|
|
236
|
+
|
|
237
|
+
const existing = toasts.find((toast) => toast.id === id);
|
|
238
|
+
if (existing) {
|
|
239
|
+
const type = options.type ?? existing.type;
|
|
240
|
+
const updated: Toast = {
|
|
241
|
+
...existing,
|
|
242
|
+
...options,
|
|
243
|
+
id,
|
|
244
|
+
type,
|
|
245
|
+
// A change of TYPE re-derives the duration, and that is the whole point
|
|
246
|
+
// of this branch. The progress case — `loading` ("Uploading…") updated
|
|
247
|
+
// in place to `success` ("Uploaded.") — carries no explicit duration,
|
|
248
|
+
// and `loading` means `Infinity`. Simply keeping the old value leaves
|
|
249
|
+
// the finished toast pinned to the screen forever, which reads as the
|
|
250
|
+
// upload never having completed. Only an explicit `duration` overrides.
|
|
251
|
+
duration:
|
|
252
|
+
options.duration ??
|
|
253
|
+
(type === existing.type ? existing.duration : DEFAULT_DURATIONS[type]),
|
|
254
|
+
// Re-creating an id that is on its way out brings it back.
|
|
255
|
+
dismissed: false,
|
|
256
|
+
};
|
|
257
|
+
toasts = toasts.map((toast) => (toast.id === id ? updated : toast));
|
|
258
|
+
emit();
|
|
259
|
+
return id;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const type = options.type ?? "info";
|
|
263
|
+
const toast: Toast = {
|
|
264
|
+
...options,
|
|
265
|
+
id,
|
|
266
|
+
type,
|
|
267
|
+
duration: options.duration ?? DEFAULT_DURATIONS[type],
|
|
268
|
+
dismissed: false,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
if (toasts.length >= max) {
|
|
272
|
+
queued.push(toast);
|
|
273
|
+
return id;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
toasts = [toast, ...toasts];
|
|
277
|
+
emit();
|
|
278
|
+
return id;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const remove = (id?: string): void => {
|
|
282
|
+
if (id === undefined) {
|
|
283
|
+
queued = [];
|
|
284
|
+
if (toasts.length === 0) return;
|
|
285
|
+
toasts = toasts.map((toast) => ({ ...toast, dismissed: true }));
|
|
286
|
+
emit();
|
|
287
|
+
const ids = toasts.map((toast) => toast.id);
|
|
288
|
+
schedulePurge(() => ids.forEach(purge));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const target = toasts.find((toast) => toast.id === id);
|
|
293
|
+
if (!target) {
|
|
294
|
+
// It may still be waiting for a slot; drop it before it ever appears.
|
|
295
|
+
queued = queued.filter((toast) => toast.id !== id);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (target.dismissed) return;
|
|
299
|
+
|
|
300
|
+
toasts = toasts.map((toast) =>
|
|
301
|
+
toast.id === id ? { ...toast, dismissed: true } : toast,
|
|
302
|
+
);
|
|
303
|
+
emit();
|
|
304
|
+
schedulePurge(() => purge(id));
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The exit delay, skipped entirely off-browser.
|
|
309
|
+
*
|
|
310
|
+
* A `setTimeout` on the server would keep the event loop alive and fire into
|
|
311
|
+
* a store nothing is subscribed to. There is nothing to animate there, so the
|
|
312
|
+
* removal is immediate.
|
|
313
|
+
*/
|
|
314
|
+
const schedulePurge = (run: () => void): void => {
|
|
315
|
+
if (!inBrowser() || removeDelay <= 0) {
|
|
316
|
+
run();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
setTimeout(run, removeDelay);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
create,
|
|
324
|
+
remove,
|
|
325
|
+
subscribe: (listener) => {
|
|
326
|
+
listeners = [...listeners, listener];
|
|
327
|
+
return () => {
|
|
328
|
+
listeners = listeners.filter((candidate) => candidate !== listener);
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
getSnapshot: () => toasts,
|
|
332
|
+
getServerSnapshot: () => EMPTY,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useId, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The mechanics of a disclosure, with no markup attached.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this exists as a hook and not only as a component
|
|
9
|
+
*
|
|
10
|
+
* `StyledCollapsible` renders its own `<button>` with the trigger inside it.
|
|
11
|
+
* That is the right default and it is the wrong shape for a host whose control
|
|
12
|
+
* is *already* a button — an icon button with a tooltip, say, sitting in a
|
|
13
|
+
* header row opposite a title. Handed such a control as `trigger`, the
|
|
14
|
+
* component would wrap one `<button>` in another: invalid HTML that React warns
|
|
15
|
+
* will break hydration, and one affordance split into two, with the accessible
|
|
16
|
+
* name on the inner element and `aria-expanded` on the outer one. A screen
|
|
17
|
+
* reader then announces a button that says nothing, containing a button that
|
|
18
|
+
* says nothing about its state.
|
|
19
|
+
*
|
|
20
|
+
* The alternative — the host hand-rolling `useState`, a `useId`, the two ARIA
|
|
21
|
+
* attributes and the `hidden` decision — is how one product ends up with two
|
|
22
|
+
* disclosures that disagree, which is exactly what NEH-1100 records happening.
|
|
23
|
+
*
|
|
24
|
+
* So the mechanics live here, and both the component below and any host
|
|
25
|
+
* composition are built on the same three lines. There is one implementation of
|
|
26
|
+
* *a disclosure*; there are as many arrangements of it as there are layouts.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const { open, triggerProps, contentProps } = useDisclosure();
|
|
30
|
+
*
|
|
31
|
+
* <header>
|
|
32
|
+
* <h2>Vitals</h2>
|
|
33
|
+
* <MyIconButton {...triggerProps} aria-label={open ? "Hide" : "Show"} />
|
|
34
|
+
* </header>
|
|
35
|
+
* <section {...contentProps}>…</section>
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* ## `hidden`, never unmounted
|
|
39
|
+
*
|
|
40
|
+
* `contentProps.hidden` is the whole opinion this hook carries, and it is not
|
|
41
|
+
* negotiable by a prop. Unmounting collapsed content looks tidier and discards
|
|
42
|
+
* focus, scroll position and anything part-typed — so a mis-press destroys work
|
|
43
|
+
* rather than merely hiding it. `hidden` also keeps the region addressable by
|
|
44
|
+
* `aria-controls` at all times, which is what lets `aria-expanded` mean
|
|
45
|
+
* anything: a control that claims to expand something must point at something
|
|
46
|
+
* that exists while it is collapsed.
|
|
47
|
+
*
|
|
48
|
+
* A host that genuinely wants unmounting can render `{open && …}` itself. It
|
|
49
|
+
* should then know it is giving that up.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export interface UseDisclosureOptions {
|
|
53
|
+
/** Controlled. Omit to let the hook own the state. */
|
|
54
|
+
open?: boolean | undefined;
|
|
55
|
+
/** Initial state when uncontrolled. Default `false`. */
|
|
56
|
+
defaultOpen?: boolean | undefined;
|
|
57
|
+
onOpenChange?: ((next: boolean) => void) | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The id linking trigger to content. Generated when omitted.
|
|
60
|
+
*
|
|
61
|
+
* Supply one only when something outside this pair must reference the region
|
|
62
|
+
* by id; two disclosures given the same id will produce two triggers pointing
|
|
63
|
+
* at one region.
|
|
64
|
+
*/
|
|
65
|
+
id?: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Spread onto the ONE element that is the control. It must be a `<button>`. */
|
|
69
|
+
export interface DisclosureTriggerProps {
|
|
70
|
+
type: "button";
|
|
71
|
+
"aria-expanded": boolean;
|
|
72
|
+
"aria-controls": string;
|
|
73
|
+
onClick: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Spread onto the region the control shows and hides. */
|
|
77
|
+
export interface DisclosureContentProps {
|
|
78
|
+
id: string;
|
|
79
|
+
hidden: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Disclosure {
|
|
83
|
+
open: boolean;
|
|
84
|
+
toggle: () => void;
|
|
85
|
+
setOpen: (next: boolean) => void;
|
|
86
|
+
triggerProps: DisclosureTriggerProps;
|
|
87
|
+
contentProps: DisclosureContentProps;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function useDisclosure(options: UseDisclosureOptions = {}): Disclosure {
|
|
91
|
+
const { open: controlled, defaultOpen = false, onOpenChange, id } = options;
|
|
92
|
+
|
|
93
|
+
const [uncontrolled, setUncontrolled] = useState(defaultOpen);
|
|
94
|
+
|
|
95
|
+
// Controlled the moment `open` is supplied, and uncontrolled otherwise —
|
|
96
|
+
// decided per render rather than latched at mount, because a host that
|
|
97
|
+
// switches between the two mid-life has a bug we should not paper over by
|
|
98
|
+
// silently ignoring the prop.
|
|
99
|
+
const isControlled = controlled !== undefined;
|
|
100
|
+
const open = isControlled ? controlled : uncontrolled;
|
|
101
|
+
|
|
102
|
+
const generatedId = useId();
|
|
103
|
+
const contentId = id ?? generatedId;
|
|
104
|
+
|
|
105
|
+
const setOpen = useCallback(
|
|
106
|
+
(next: boolean) => {
|
|
107
|
+
// The internal state moves even when controlled. If the host ignores the
|
|
108
|
+
// callback the control would otherwise appear dead to the pointer, and a
|
|
109
|
+
// control that does nothing when pressed is indistinguishable from a
|
|
110
|
+
// broken one.
|
|
111
|
+
if (!isControlled) setUncontrolled(next);
|
|
112
|
+
onOpenChange?.(next);
|
|
113
|
+
},
|
|
114
|
+
[isControlled, onOpenChange],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
open,
|
|
121
|
+
toggle,
|
|
122
|
+
setOpen,
|
|
123
|
+
triggerProps: {
|
|
124
|
+
// `type="button"` because the commonest place a disclosure lives is
|
|
125
|
+
// inside a form, where an untyped button submits it. The symptom is a
|
|
126
|
+
// page reload on the first press of a "show more" control.
|
|
127
|
+
type: "button",
|
|
128
|
+
"aria-expanded": open,
|
|
129
|
+
"aria-controls": contentId,
|
|
130
|
+
onClick: toggle,
|
|
131
|
+
},
|
|
132
|
+
contentProps: {
|
|
133
|
+
id: contentId,
|
|
134
|
+
hidden: !open,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default useDisclosure;
|
package/src/index.ts
CHANGED
|
@@ -315,3 +315,27 @@ export { DL_VARIANTS } from "./components/StyledDefinitionList";
|
|
|
315
315
|
|
|
316
316
|
export { default as StyledSparkLine } from "./components/StyledSparkLine";
|
|
317
317
|
export type { StyledSparkLineProps } from "./components/StyledSparkLine";
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Notifications
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
export { default as StyledToaster, StyledToaster as Toaster } from "./components/StyledToaster";
|
|
323
|
+
export type { StyledToasterProps } from "./components/StyledToaster";
|
|
324
|
+
|
|
325
|
+
export { createToaster, DEFAULT_DURATIONS } from "./components/toaster-store";
|
|
326
|
+
export type {
|
|
327
|
+
Toast,
|
|
328
|
+
ToastAction,
|
|
329
|
+
ToastOptions,
|
|
330
|
+
ToastType,
|
|
331
|
+
ToasterStore,
|
|
332
|
+
ToasterStoreOptions,
|
|
333
|
+
} from "./components/toaster-store";
|
|
334
|
+
|
|
335
|
+
export { default as useDisclosure } from "./components/useDisclosure";
|
|
336
|
+
export type {
|
|
337
|
+
Disclosure,
|
|
338
|
+
DisclosureContentProps,
|
|
339
|
+
DisclosureTriggerProps,
|
|
340
|
+
UseDisclosureOptions,
|
|
341
|
+
} from "./components/useDisclosure";
|
package/src/preset/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { stackRecipe } from "./recipes/stack";
|
|
|
27
27
|
import { stripedRecipe } from "./recipes/striped";
|
|
28
28
|
import { tagRecipe } from "./recipes/tag";
|
|
29
29
|
import { textRecipe } from "./recipes/text";
|
|
30
|
+
import { toastRecipe } from "./recipes/toast";
|
|
30
31
|
import { tooltipRecipe } from "./recipes/tooltip";
|
|
31
32
|
|
|
32
33
|
import {
|
|
@@ -56,8 +57,9 @@ export interface StonedogStylePresetOptions {
|
|
|
56
57
|
/**
|
|
57
58
|
* Every recipe, keyed by the name it is exported under in `styled-system/recipes`.
|
|
58
59
|
*
|
|
59
|
-
*
|
|
60
|
-
* `inputRadioRootRecipe`) are slot recipes declared with
|
|
60
|
+
* Six of these (`alertRecipe`, `listRecipe`, `menuRecipe`, `inputBoolRecipe`,
|
|
61
|
+
* `inputRadioRootRecipe`, `toastRecipe`) are slot recipes declared with
|
|
62
|
+
* `defineSlotRecipe`.
|
|
61
63
|
* Panda accepts them here rather than under `slotRecipes` and generates them
|
|
62
64
|
* correctly — verified against HopperGuard's own generated output. Moving them
|
|
63
65
|
* to `slotRecipes` would be more "correct" by the docs and would change the
|
|
@@ -87,6 +89,7 @@ const recipes = {
|
|
|
87
89
|
stripedRecipe,
|
|
88
90
|
tagRecipe,
|
|
89
91
|
textRecipe,
|
|
92
|
+
toastRecipe,
|
|
90
93
|
tooltipRecipe,
|
|
91
94
|
};
|
|
92
95
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { defineSlotRecipe } from "@pandacss/dev";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A transient message: the region it lives in, and the card itself.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from HopperGuard, where the card was a `cva` in the component file
|
|
7
|
+
* and the region was a nine-cell CSS grid of which one cell was ever used. Both
|
|
8
|
+
* are here now, for the reason every recipe is: a `cva` inside a component is
|
|
9
|
+
* invisible to a consumer's Panda run unless that consumer globs this package's
|
|
10
|
+
* source, whereas a recipe is emitted from config with no parsing at all.
|
|
11
|
+
*
|
|
12
|
+
* ## The accent is a border, not a background
|
|
13
|
+
*
|
|
14
|
+
* Status is carried by a 4px bar down the leading edge rather than by tinting
|
|
15
|
+
* the whole card. Two reasons, and the second is the load-bearing one:
|
|
16
|
+
*
|
|
17
|
+
* - A toast sits over arbitrary page content, so it needs an opaque surface of
|
|
18
|
+
* its own to stay readable. A tint would fight that.
|
|
19
|
+
* - **Colour is never the only cue.** The accent says the same thing as the
|
|
20
|
+
* glyph the component renders beside the message, so a reader who cannot
|
|
21
|
+
* distinguish the hues loses nothing — WCAG 1.4.1, Level A. Deleting the
|
|
22
|
+
* glyph "because the colour already says it" is the regression this note
|
|
23
|
+
* exists to stop.
|
|
24
|
+
*
|
|
25
|
+
* `borderInlineStart` rather than `borderLeft`: in a right-to-left document the
|
|
26
|
+
* accent belongs on the right, and the logical property is what moves it there.
|
|
27
|
+
*/
|
|
28
|
+
export const toastRecipe = defineSlotRecipe({
|
|
29
|
+
className: "toast",
|
|
30
|
+
description: "A transient message and the region that stacks them",
|
|
31
|
+
slots: [
|
|
32
|
+
"region",
|
|
33
|
+
"root",
|
|
34
|
+
"indicator",
|
|
35
|
+
"content",
|
|
36
|
+
"title",
|
|
37
|
+
"description",
|
|
38
|
+
"action",
|
|
39
|
+
"close",
|
|
40
|
+
],
|
|
41
|
+
base: {
|
|
42
|
+
region: {
|
|
43
|
+
position: "fixed",
|
|
44
|
+
// Anchored to one corner rather than laid out in a grid of nine cells:
|
|
45
|
+
// the extracted version declared all nine and rendered into exactly one,
|
|
46
|
+
// so eight of them were markup nothing could ever reach.
|
|
47
|
+
insetBlockEnd: "4",
|
|
48
|
+
insetInlineEnd: "4",
|
|
49
|
+
display: "flex",
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
alignItems: "flex-end",
|
|
52
|
+
gap: "3",
|
|
53
|
+
// The region spans far enough to stack wide toasts but must not swallow
|
|
54
|
+
// clicks meant for the page beneath it — `none` here, `auto` on each
|
|
55
|
+
// card. Without this pairing a dismissed-but-still-animating toast
|
|
56
|
+
// leaves an invisible plate over the corner of the app.
|
|
57
|
+
pointerEvents: "none",
|
|
58
|
+
maxWidth: "calc(100vw - {spacing.8})",
|
|
59
|
+
zIndex: "toast",
|
|
60
|
+
},
|
|
61
|
+
root: {
|
|
62
|
+
pointerEvents: "auto",
|
|
63
|
+
display: "flex",
|
|
64
|
+
alignItems: "center",
|
|
65
|
+
gap: "4",
|
|
66
|
+
borderRadius: "md",
|
|
67
|
+
boxShadow: "lg",
|
|
68
|
+
paddingInline: "4",
|
|
69
|
+
paddingBlock: "3",
|
|
70
|
+
minWidth: { base: "320px", lg: "600px" },
|
|
71
|
+
maxWidth: { base: "400px", lg: "700px" },
|
|
72
|
+
fontSize: "md",
|
|
73
|
+
// Stated, not inherited: a themed typeface otherwise reaches the page and
|
|
74
|
+
// stops at the edge of the component (NEH-289).
|
|
75
|
+
fontFamily: "body",
|
|
76
|
+
borderWidth: "1px",
|
|
77
|
+
borderStyle: "solid",
|
|
78
|
+
// `borderBgPrimary`, not the `borderSubtle` this was first written with:
|
|
79
|
+
// that token belongs to HopperGuard's vocabulary, not this package's, and
|
|
80
|
+
// Panda passes an unknown token through as a literal — the card would
|
|
81
|
+
// have rendered with `border-color: borderSubtle`, which the browser
|
|
82
|
+
// discards, so the toast would have had no border at all and nothing
|
|
83
|
+
// would have said so. The package's own token-contract test caught it.
|
|
84
|
+
borderColor: "borderBgPrimary",
|
|
85
|
+
backgroundColor: "boxBgPrimary",
|
|
86
|
+
color: "textPrimary",
|
|
87
|
+
transition: "opacity 200ms ease, transform 200ms ease",
|
|
88
|
+
// `data-state` rather than a class: the renderer flips one attribute and
|
|
89
|
+
// the same rule drives both directions, so there is no window in which a
|
|
90
|
+
// toast has neither state.
|
|
91
|
+
"&[data-state='closed']": {
|
|
92
|
+
opacity: "0",
|
|
93
|
+
transform: "translateY(0.5rem)",
|
|
94
|
+
},
|
|
95
|
+
"&[data-state='open']": {
|
|
96
|
+
opacity: "1",
|
|
97
|
+
transform: "translateY(0)",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
indicator: {
|
|
101
|
+
flexShrink: 0,
|
|
102
|
+
display: "flex",
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
justifyContent: "center",
|
|
105
|
+
lineHeight: "1",
|
|
106
|
+
fontSize: "lg",
|
|
107
|
+
},
|
|
108
|
+
content: {
|
|
109
|
+
flex: "1",
|
|
110
|
+
minWidth: "0",
|
|
111
|
+
display: "flex",
|
|
112
|
+
flexDirection: "column",
|
|
113
|
+
gap: "1",
|
|
114
|
+
},
|
|
115
|
+
title: {
|
|
116
|
+
fontWeight: "bold",
|
|
117
|
+
},
|
|
118
|
+
description: {
|
|
119
|
+
display: "block",
|
|
120
|
+
},
|
|
121
|
+
action: {
|
|
122
|
+
flexShrink: 0,
|
|
123
|
+
},
|
|
124
|
+
close: {
|
|
125
|
+
flexShrink: 0,
|
|
126
|
+
display: "inline-flex",
|
|
127
|
+
alignItems: "center",
|
|
128
|
+
justifyContent: "center",
|
|
129
|
+
// 48px, matching every other interactive floor in this package. A close
|
|
130
|
+
// control is the one thing on a toast a person is *aiming* at, often
|
|
131
|
+
// while it is animating, so it is the last place to shave a target down
|
|
132
|
+
// to the size of its glyph.
|
|
133
|
+
minWidth: "48px",
|
|
134
|
+
minHeight: "48px",
|
|
135
|
+
borderRadius: "md",
|
|
136
|
+
borderWidth: "1px",
|
|
137
|
+
borderStyle: "solid",
|
|
138
|
+
borderColor: "transparent",
|
|
139
|
+
background: "transparent",
|
|
140
|
+
color: "inherit",
|
|
141
|
+
cursor: "pointer",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
variants: {
|
|
145
|
+
/**
|
|
146
|
+
* The status accent. `default` deliberately has none — a toast that means
|
|
147
|
+
* nothing in particular should not borrow a colour that means something.
|
|
148
|
+
*/
|
|
149
|
+
type: {
|
|
150
|
+
success: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderSuccess" } },
|
|
151
|
+
error: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderError" } },
|
|
152
|
+
warning: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderWarning" } },
|
|
153
|
+
info: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderBgAccent" } },
|
|
154
|
+
loading: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderBgAccent" } },
|
|
155
|
+
default: {},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
defaultVariants: {
|
|
159
|
+
type: "default",
|
|
160
|
+
},
|
|
161
|
+
});
|