@stonedogcode/style 0.16.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.
@@ -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;
@@ -147,3 +147,28 @@ export function stepUpFontSize(size: FontSizeKey, steps = 1): FontSizeKey {
147
147
  // clamp fails safe instead of returning undefined to a caller typed otherwise.
148
148
  return next ?? size;
149
149
  }
150
+
151
+ /**
152
+ * The next size DOWN, clamped at the bottom of the scale.
153
+ *
154
+ * The counterpart to `stepUpFontSize`, added for `StyledFieldHelp` (NEH-972),
155
+ * and the clamp is the load-bearing half. Inline help is deliberately one tier
156
+ * below the text it accompanies — but "one tier below" must never mean "below
157
+ * the smallest tier the host offers", because the reader who has turned their
158
+ * text size all the way down is the reader with the least room to spare. At
159
+ * `xs` this returns `xs`, so help matches the body text rather than shrinking
160
+ * past it.
161
+ *
162
+ * Steps through `FONT_SIZE_ORDER`, so it moves through whatever scale the host
163
+ * has pinned its `--font-sizes-*` properties to rather than through a fixed set
164
+ * of pixel values.
165
+ */
166
+ export function stepDownFontSize(size: FontSizeKey, steps = 1): FontSizeKey {
167
+ const index = FONT_SIZE_ORDER.indexOf(size);
168
+ if (index === -1) return size;
169
+ const next = FONT_SIZE_ORDER[Math.max(index - steps, 0)];
170
+ // Clamped into range above, so this cannot miss — but staying total means a
171
+ // future change to the clamp fails safe rather than handing a caller
172
+ // `undefined` from a function typed otherwise. Same shape as stepUpFontSize.
173
+ return next ?? size;
174
+ }
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  getFontSizeLabel,
49
49
  getFontSizeValue,
50
50
  stepUpFontSize,
51
+ stepDownFontSize,
51
52
  FONT_SIZE_ORDER,
52
53
  } from "./config/font-size";
53
54
 
@@ -197,6 +198,18 @@ export type { StyledTooltipProps } from "./components/StyledTooltip";
197
198
  export { default as StyledFormLabel } from "./components/StyledFormLabel";
198
199
  export type { StyledFormLabelProps } from "./components/StyledFormLabel";
199
200
 
201
+ /**
202
+ * Permanent inline help for a field — PRD-0037's replacement for the hover
203
+ * tooltip and its `?` button. `fieldHelpId` is exported so a host can put the
204
+ * `aria-describedby` association in server-rendered HTML.
205
+ */
206
+ export {
207
+ default as StyledFieldHelp,
208
+ StyledFieldHelp as FieldHelp,
209
+ fieldHelpId,
210
+ } from "./components/StyledFieldHelp";
211
+ export type { StyledFieldHelpProps } from "./components/StyledFieldHelp";
212
+
200
213
  // ---------------------------------------------------------------------------
201
214
  // Components that were blocked on a runtime dependency until NEH-430 gave each
202
215
  // a seam with a working default. None of them adds a dependency; the host
@@ -302,3 +315,27 @@ export { DL_VARIANTS } from "./components/StyledDefinitionList";
302
315
 
303
316
  export { default as StyledSparkLine } from "./components/StyledSparkLine";
304
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";
@@ -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
- * Five of these (`alertRecipe`, `listRecipe`, `menuRecipe`, `inputBoolRecipe`,
60
- * `inputRadioRootRecipe`) are slot recipes declared with `defineSlotRecipe`.
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