@ultimat3/ui 19.3.3 → 20.0.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.
@@ -17,10 +17,18 @@ import {
17
17
  distributeIssues,
18
18
  errorOf,
19
19
  type FormState,
20
+ firstInvalidField,
20
21
  IDLE_FORM_STATE,
21
22
  messagesOf,
22
23
  NO_FORM_ERRORS,
23
24
  } from './form-state';
25
+ import {
26
+ type FormTouch,
27
+ markDirty,
28
+ markTouched,
29
+ NO_FORM_TOUCH,
30
+ sameFieldValue,
31
+ } from './form-touch';
24
32
 
25
33
  export interface FormBindingOptions<TValues, TResult> {
26
34
  /**
@@ -40,6 +48,17 @@ export interface FormBindingOptions<TValues, TResult> {
40
48
  readonly schema?: FormSchema | undefined;
41
49
  /** Called on every transition — how a reactive shell mirrors the state into a signal. */
42
50
  readonly onState?: ((state: FormState<TResult>) => void) | undefined;
51
+ /**
52
+ * The values the form OPENED with, keyed by the same field paths — what `edit()` compares
53
+ * against to decide dirtiness. A create form passes nothing, and every non-blank value is then a
54
+ * change. A path missing from this map has a baseline of `undefined`, which `sameFieldValue`
55
+ * treats as equal to an empty control.
56
+ *
57
+ * The baseline is fixed for the life of the binding: a form that stays mounted after a save and
58
+ * wants the saved values as its new baseline builds a new binding, because this one never sees
59
+ * what the user typed and cannot invent one.
60
+ */
61
+ readonly initial?: Readonly<Record<string, unknown>> | undefined;
43
62
  }
44
63
 
45
64
  export interface FormBinding<TValues, TResult> {
@@ -49,6 +68,20 @@ export interface FormBinding<TValues, TResult> {
49
68
  readonly errorFor: (path: string) => string | undefined;
50
69
  /** Every message bound to one path, when a form renders more than one. */
51
70
  readonly messagesFor: (path: string) => readonly string[];
71
+ /**
72
+ * A submit is in flight. The one value that reaches BOTH the submit control (`<Button loading>`)
73
+ * and the form (`<Form busy>`) — one derivation, so a control can be busy and its form not.
74
+ */
75
+ readonly pending: () => boolean;
76
+ /**
77
+ * The first declared field a failed submit put an error on. What `<Form invalidField>` focuses:
78
+ * the thing that has to be fixed, rather than the summary that describes it.
79
+ */
80
+ readonly firstInvalidField: () => string | undefined;
81
+ /** The user left a control. */
82
+ readonly touch: (path: string) => void;
83
+ /** The user changed a control. Dirtiness is decided against `initial`, never by the caller. */
84
+ readonly edit: (path: string, value: unknown) => void;
52
85
  readonly reset: () => void;
53
86
  }
54
87
 
@@ -71,12 +104,35 @@ export function createFormBinding<TValues, TResult>(
71
104
  ): FormBinding<TValues, TResult> {
72
105
  const fields = declaredFields(options.fields);
73
106
  let state: FormState<TResult> = IDLE_FORM_STATE;
107
+ let touch: FormTouch = NO_FORM_TOUCH;
74
108
  let inFlight: Promise<FormState<TResult>> | null = null;
75
109
 
76
- const publish = (next: FormState<TResult>): FormState<TResult> => {
77
- state = next;
78
- options.onState?.(next);
79
- return next;
110
+ /**
111
+ * A transition names the submit's own members; `touched`/`dirty` are added here, from the one
112
+ * place that owns them. Spelling the parameter as the whole `FormState` would let a transition
113
+ * carry a stale pair — the exact drift `publishTouch` exists to prevent.
114
+ */
115
+ const publish = (next: Omit<FormState<TResult>, keyof FormTouch>): FormState<TResult> => {
116
+ state = { ...next, touched: touch.touched, dirty: touch.dirty };
117
+ options.onState?.(state);
118
+ return state;
119
+ };
120
+
121
+ /**
122
+ * The value this field opened with. `Object.hasOwn`, never the read alone: `initial` is the app's
123
+ * own object, so `initial['constructor']` answers the `Object` FUNCTION where an absent key must
124
+ * answer `undefined` — and that field would then read as permanently changed.
125
+ */
126
+ const baseline = (path: string): unknown => {
127
+ const initial = options.initial;
128
+ return initial !== undefined && Object.hasOwn(initial, path) ? initial[path] : undefined;
129
+ };
130
+
131
+ /** Progress through the form is not a transition, so it publishes without disturbing `status`. */
132
+ const publishTouch = (next: FormTouch): void => {
133
+ if (next === touch) return;
134
+ touch = next;
135
+ publish(state);
80
136
  };
81
137
 
82
138
  const failed = (issues: readonly FormIssue[]): FormState<TResult> =>
@@ -102,6 +158,9 @@ export function createFormBinding<TValues, TResult>(
102
158
 
103
159
  try {
104
160
  const result = await options.submit(values);
161
+ // The server accepted what the form held, so there is nothing left to lose. `touched` stays:
162
+ // the user has still visited those fields, and a hint that vanishes on save is a flicker.
163
+ touch = { touched: touch.touched, dirty: NO_FORM_TOUCH.dirty };
105
164
  return publish({ status: 'succeeded', ...NO_FORM_ERRORS, result, issues: [] });
106
165
  } catch (rejection) {
107
166
  return failed(issuesFromRejection(rejection));
@@ -121,7 +180,13 @@ export function createFormBinding<TValues, TResult>(
121
180
  },
122
181
  errorFor: (path) => errorOf(state, path),
123
182
  messagesFor: (path) => messagesOf(state, path),
183
+ pending: () => state.status === 'submitting',
184
+ firstInvalidField: () => firstInvalidField(state, fields),
185
+ touch: (path) => publishTouch(markTouched(touch, path)),
186
+ edit: (path, value) =>
187
+ publishTouch(markDirty(touch, path, !sameFieldValue(value, baseline(path)))),
124
188
  reset: () => {
189
+ touch = NO_FORM_TOUCH;
125
190
  publish(IDLE_FORM_STATE);
126
191
  },
127
192
  };
@@ -3,6 +3,7 @@
3
3
  // control the app forgot to declare, which is worse than having no binding at all.
4
4
 
5
5
  import type { FormIssue } from './form-issue';
6
+ import { type FormTouch, NO_FORM_TOUCH } from './form-touch';
6
7
 
7
8
  export type FormStatus = 'idle' | 'submitting' | 'succeeded' | 'failed';
8
9
 
@@ -12,7 +13,7 @@ export interface FormErrors {
12
13
  readonly formErrors: readonly string[];
13
14
  }
14
15
 
15
- export interface FormState<TResult> extends FormErrors {
16
+ export interface FormState<TResult> extends FormErrors, FormTouch {
16
17
  readonly status: FormStatus;
17
18
  /** The server's answer. Only ever set from a resolved `submit`. */
18
19
  readonly result: TResult | undefined;
@@ -35,6 +36,8 @@ export const IDLE_FORM_STATE: FormState<never> = Object.freeze({
35
36
  formErrors: [],
36
37
  result: undefined,
37
38
  issues: [],
39
+ touched: NO_FORM_TOUCH.touched,
40
+ dirty: NO_FORM_TOUCH.dirty,
38
41
  });
39
42
 
40
43
  /**
@@ -78,6 +81,21 @@ export function distributeIssues(
78
81
  return { fieldErrors, formErrors };
79
82
  }
80
83
 
84
+ /**
85
+ * The first DECLARED field carrying an error, in declaration order — which is also the order the
86
+ * controls are rendered in, so it is the one the reader reaches first going down the page. What a
87
+ * failed submit moves focus to.
88
+ *
89
+ * Declaration order, never the ISSUE order: a server is free to report the last field first, and
90
+ * focus would then land halfway down a form the user has not read.
91
+ */
92
+ export function firstInvalidField(state: FormErrors, fields: Iterable<string>): string | undefined {
93
+ for (const name of fields) {
94
+ if (state.fieldErrors.has(name)) return name;
95
+ }
96
+ return undefined;
97
+ }
98
+
81
99
  /** Every message bound to one field, in the order the issues arrived. */
82
100
  export function messagesOf(state: FormErrors, path: string): readonly string[] {
83
101
  return state.fieldErrors.get(path) ?? [];
@@ -0,0 +1,51 @@
1
+ // Which fields the user has LEFT (touched) and which they have CHANGED (dirty). Pure, and
2
+ // deliberately not a value store: this binding is server-authoritative, so it holds opinions about
3
+ // the user's progress through the form and never a second copy of what the form says.
4
+
5
+ /** Two sets, addressed in the same path grammar a control's `name` and a schema issue use. */
6
+ export interface FormTouch {
7
+ readonly touched: ReadonlySet<string>;
8
+ readonly dirty: ReadonlySet<string>;
9
+ }
10
+
11
+ const NO_PATHS: ReadonlySet<string> = new Set();
12
+
13
+ export const NO_FORM_TOUCH: FormTouch = Object.freeze({ touched: NO_PATHS, dirty: NO_PATHS });
14
+
15
+ /**
16
+ * An empty control and an absent baseline are the same thing to the person filling the form: they
17
+ * typed something into a new record's field and then deleted it again, and calling that "changed"
18
+ * turns every abandoned keystroke into an unsaved-changes prompt. `Object.is` for everything else,
19
+ * so `0` and `false` are values and not blanks.
20
+ */
21
+ export function sameFieldValue(a: unknown, b: unknown): boolean {
22
+ if (Object.is(a, b)) return true;
23
+ return isBlank(a) && isBlank(b);
24
+ }
25
+
26
+ function isBlank(value: unknown): boolean {
27
+ return value === undefined || value === null || value === '';
28
+ }
29
+
30
+ /** The user left the control. Touched is one-way: leaving a field cannot un-visit it. */
31
+ export function markTouched(touch: FormTouch, path: string): FormTouch {
32
+ if (touch.touched.has(path)) return touch;
33
+ return { touched: new Set([...touch.touched, path]), dirty: touch.dirty };
34
+ }
35
+
36
+ /**
37
+ * The user changed the control. `changed` is two-way on purpose — typing a character and deleting
38
+ * it again leaves the form clean, which is the only answer an "unsaved changes" guard can act on.
39
+ */
40
+ export function markDirty(touch: FormTouch, path: string, changed: boolean): FormTouch {
41
+ if (touch.dirty.has(path) === changed) return touch;
42
+ const dirty = new Set(touch.dirty);
43
+ if (changed) dirty.add(path);
44
+ else dirty.delete(path);
45
+ return { touched: touch.touched, dirty };
46
+ }
47
+
48
+ /** Anything at all to lose. What a navigation guard asks. */
49
+ export function isFormDirty(touch: FormTouch): boolean {
50
+ return touch.dirty.size > 0;
51
+ }
@@ -6,7 +6,13 @@
6
6
 
7
7
  import { solid } from '../theme/solid-adapter';
8
8
  import { createFormBinding, type FormBinding, type FormBindingOptions } from './form-binding';
9
- import { errorOf, type FormState, IDLE_FORM_STATE, messagesOf } from './form-state';
9
+ import {
10
+ errorOf,
11
+ type FormState,
12
+ firstInvalidField,
13
+ IDLE_FORM_STATE,
14
+ messagesOf,
15
+ } from './form-state';
10
16
 
11
17
  /**
12
18
  * Call it in a component body. On the server there is no registered runtime and the inert one
@@ -34,5 +40,9 @@ export function useForm<TValues, TResult>(
34
40
  state,
35
41
  errorFor: (path) => errorOf(state(), path),
36
42
  messagesFor: (path) => messagesOf(state(), path),
43
+ pending: () => state().status === 'submitting',
44
+ // Same reason: `firstInvalidField` off the binding closes over a snapshot, so a `<Form>` bound
45
+ // to it would keep focusing whichever field failed the FIRST submit, for the rest of the page.
46
+ firstInvalidField: () => firstInvalidField(state(), options.fields),
37
47
  };
38
48
  }
package/src/index.ts CHANGED
@@ -6,7 +6,13 @@
6
6
  // would be TS2307 there. The reference pulls the contract along with the entry.
7
7
  /// <reference path="./scss.d.ts" />
8
8
 
9
- export type { FocusTrap, Politeness, RovingOptions, RovingOrientation } from './a11y';
9
+ export type {
10
+ FocusTrap,
11
+ LiveRegionAttrs,
12
+ Politeness,
13
+ RovingOptions,
14
+ RovingOrientation,
15
+ } from './a11y';
10
16
  export {
11
17
  announce,
12
18
  ariaBool,
@@ -14,6 +20,8 @@ export {
14
20
  createRovingTabindex,
15
21
  FOCUSABLE_SELECTOR,
16
22
  focusableWithin,
23
+ LIVE_REGION_LEVELS,
24
+ liveRegionAttrs,
17
25
  nextRovingIndex,
18
26
  resetIdCounter,
19
27
  useId,
@@ -25,12 +33,28 @@ export type { AlertProps } from './components/Alert';
25
33
  export { Alert } from './components/Alert';
26
34
  export type { AppShellProps } from './components/AppShell';
27
35
  export { AppShell } from './components/AppShell';
36
+ export type { AsyncRegionProps } from './components/AsyncRegion';
37
+ // --- async regions: the one four-way decision, and the component that renders it --------------
38
+ export { AsyncRegion } from './components/AsyncRegion';
28
39
  export type { AvatarProps } from './components/Avatar';
29
40
  export { Avatar, initialsOf } from './components/Avatar';
30
41
  export type { AccordionSection } from './components/accordion-view';
31
42
  export { accordionOpenIds } from './components/accordion-view';
32
43
  export type { ShellIds, ShellLandmark, ShellSlots } from './components/app-shell-view';
33
44
  export { shellIds, shellLandmarks } from './components/app-shell-view';
45
+ export type {
46
+ AsyncBranch,
47
+ AsyncFlags,
48
+ AsyncState,
49
+ ReserveBox,
50
+ } from './components/async-branch';
51
+ export {
52
+ asyncBranch,
53
+ asyncStateOf,
54
+ isBusyBranch,
55
+ isEmptyData,
56
+ reserveBlockSize,
57
+ } from './components/async-branch';
34
58
  export type { BadgeProps } from './components/Badge';
35
59
  export { Badge } from './components/Badge';
36
60
  export type { BreadcrumbItem, BreadcrumbProps } from './components/Breadcrumb';
@@ -110,7 +134,7 @@ export { Input } from './components/Input';
110
134
  export type { IconElement, IconGlyph, IconTag } from './components/icon-glyph';
111
135
  export { ICON_TAGS, iconElements, isIconTag } from './components/icon-glyph';
112
136
  export type { ImageBox, ImageLoadingHints, ImageVariant } from './components/image-source';
113
- export { boxFor, loadingHints, srcsetFor } from './components/image-source';
137
+ export { boxFor, loadingHints, ratioFor, srcsetFor } from './components/image-source';
114
138
  export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
115
139
  export { loadMoreState } from './components/infinite-scroll-view';
116
140
  export type { LinkProps } from './components/Link';
@@ -162,8 +186,10 @@ export type { TextareaProps } from './components/Textarea';
162
186
  export { Textarea } from './components/Textarea';
163
187
  export type { ThemeChoice, ThemeToggleProps } from './components/ThemeToggle';
164
188
  export { ThemeToggle } from './components/ThemeToggle';
165
- export type { ToastProps, ToastRegionProps } from './components/Toast';
189
+ export type { ToastPlacement, ToastProps, ToastRegionProps } from './components/Toast';
166
190
  export { Toast, ToastRegion } from './components/Toast';
191
+ export type { ToasterProps } from './components/Toaster';
192
+ export { Toaster } from './components/Toaster';
167
193
  export type { ToolbarProps } from './components/Toolbar';
168
194
  export { Toolbar } from './components/Toolbar';
169
195
  export type { TooltipProps } from './components/Tooltip';
@@ -179,6 +205,7 @@ export { DEBOUNCE_DEFAULT_MS, debounce } from './debounce';
179
205
  export type { UiErrorCode } from './errors';
180
206
  export {
181
207
  conflictingFieldNameError,
208
+ insufficientContrastError,
182
209
  invalidBrandTokenError,
183
210
  invalidFieldPathError,
184
211
  invalidGlyphError,
@@ -193,7 +220,7 @@ export {
193
220
  } from './errors';
194
221
  // --- forms: the binding between an action's input schema and Field's error slot ---------------
195
222
  export type { FieldPathSegment, IssuePathSegment } from './form/field-path';
196
- export { formatFieldPath, MAX_FIELD_INDEX, parseFieldPath } from './form/field-path';
223
+ export { fieldSelector, formatFieldPath, MAX_FIELD_INDEX, parseFieldPath } from './form/field-path';
197
224
  export type { FormBinding, FormBindingOptions } from './form/form-binding';
198
225
  export { createFormBinding } from './form/form-binding';
199
226
  export type {
@@ -207,10 +234,19 @@ export type { FormErrors, FormState, FormStatus } from './form/form-state';
207
234
  export {
208
235
  distributeIssues,
209
236
  errorOf,
237
+ firstInvalidField,
210
238
  IDLE_FORM_STATE,
211
239
  messagesOf,
212
240
  NO_FORM_ERRORS,
213
241
  } from './form/form-state';
242
+ export type { FormTouch } from './form/form-touch';
243
+ export {
244
+ isFormDirty,
245
+ markDirty,
246
+ markTouched,
247
+ NO_FORM_TOUCH,
248
+ sameFieldValue,
249
+ } from './form/form-touch';
214
250
  export { valuesOfForm } from './form/form-values';
215
251
  export { useForm } from './form/use-form';
216
252
  export type { UiKey } from './i18n-keys';
@@ -266,6 +302,25 @@ export {
266
302
  toggleTheme,
267
303
  watchOsTheme,
268
304
  } from './theme/theme';
305
+ export type {
306
+ ToastAction,
307
+ ToastDwell,
308
+ ToastHold,
309
+ ToastInput,
310
+ ToastItem,
311
+ ToastQueue,
312
+ } from './toast/toast-state';
313
+ // --- toasts: the queue behind ToastRegion ------------------------------------
314
+ export {
315
+ collapsedToasts,
316
+ EMPTY_TOAST_QUEUE,
317
+ TOAST_DWELL_MS,
318
+ TOAST_MAX_VISIBLE,
319
+ visibleToasts,
320
+ } from './toast/toast-state';
321
+ export type { ToastEnv, ToastStore } from './toast/toast-store';
322
+ export { browserToastEnv, createToastStore, INERT_TOAST_ENV } from './toast/toast-store';
323
+ export { useToasts } from './toast/use-toasts';
269
324
  export type { Channels } from './tokens/contrast';
270
325
  export {
271
326
  AA_LARGE,
@@ -277,6 +332,8 @@ export {
277
332
  relativeLuminance,
278
333
  roleContrast,
279
334
  } from './tokens/contrast';
335
+ export type { ContrastPair } from './tokens/contrast-pairs';
336
+ export { CONTRAST_PAIRS, VISIBLE_EDGE } from './tokens/contrast-pairs';
280
337
  export type { ColorRole, RadiusName, Theme } from './tokens/tokens';
281
338
  // --- tokens ------------------------------------------------------------------
282
339
  export {
@@ -3,11 +3,18 @@
3
3
  // level it emits. There is deliberately no SCSS `@use ... with ()` seam — two ways to change the
4
4
  // accent colour is the ambiguity axiom 1 exists to delete.
5
5
 
6
- import { invalidBrandTokenError, runtimeMissingError, unknownTokenError } from '../errors';
7
- import { parseChannels } from '../tokens/contrast';
6
+ import {
7
+ insufficientContrastError,
8
+ invalidBrandTokenError,
9
+ runtimeMissingError,
10
+ unknownTokenError,
11
+ } from '../errors';
12
+ import { contrastRatio, parseChannels } from '../tokens/contrast';
13
+ import { CONTRAST_PAIRS } from '../tokens/contrast-pairs';
8
14
  import {
9
15
  COLOR_ROLES,
10
16
  type ColorRole,
17
+ colorTokens,
11
18
  type RadiusName,
12
19
  radiusTokens,
13
20
  type Theme,
@@ -48,6 +55,12 @@ export function defineTheme(input: BrandInput): Brand {
48
55
  const blocks: string[] = [];
49
56
  const light = colorDeclarations(input.colors?.light, 'colors.light');
50
57
  const dark = colorDeclarations(input.colors?.dark, 'colors.dark');
58
+ // Measured AFTER the channels parse and BEFORE a single declaration is rendered: a palette that
59
+ // fails AA is not a stylesheet with a warning attached, it is a refusal. The pairings are the
60
+ // ones the framework's own palette is held to (`CONTRAST_PAIRS`), so an app cannot ship a brand
61
+ // the design system would have failed its own tests over.
62
+ assertContrast('light', input.colors?.light);
63
+ assertContrast('dark', input.colors?.dark);
51
64
  const root: string[] = [
52
65
  ...light,
53
66
  ...radiusDeclarations(input.radius),
@@ -161,6 +174,30 @@ function fontDeclarations(overrides: Partial<Record<FontSlot, string>> | undefin
161
174
  return out;
162
175
  }
163
176
 
177
+ /**
178
+ * The palette as it will actually render: the shipped channels, with this brand's overrides on
179
+ * top. Measured on the RESOLVED set and not on the overrides alone, because the pairing that
180
+ * breaks is nearly always one the author only touched half of — a new `accent` against the
181
+ * shipped `accent-fg` is the single most common way a brand goes unreadable.
182
+ */
183
+ function assertContrast(
184
+ theme: Theme,
185
+ overrides: Partial<Record<ColorRole, string>> | undefined,
186
+ ): void {
187
+ if (overrides === undefined) return;
188
+ const resolved = (role: ColorRole): string => overrides[role] ?? colorTokens[theme][role];
189
+ for (const pair of CONTRAST_PAIRS) {
190
+ // Only pairings this brand can have changed. The rest are the shipped palette, which
191
+ // `contrast.test.ts` already holds — re-reporting them would blame the app for our colours.
192
+ if (overrides[pair.fg] === undefined && overrides[pair.bg] === undefined) continue;
193
+ const fg = resolved(pair.fg);
194
+ const bg = resolved(pair.bg);
195
+ const ratio = contrastRatio(fg, bg);
196
+ if (ratio >= pair.minimum) continue;
197
+ throw insufficientContrastError(theme, pair.what, pair.fg, pair.bg, ratio, pair.minimum);
198
+ }
199
+ }
200
+
164
201
  function assertChannels(scope: string, role: string, value: string): void {
165
202
  try {
166
203
  parseChannels(value);
Binary file
@@ -0,0 +1,179 @@
1
+ // The queue with a clock attached: auto-dismiss, and the three independent reasons it stops.
2
+ //
3
+ // Hover and focus-within are the two everybody implements. `document.hidden` is the one everybody
4
+ // forgets, and it is the one that loses the message outright: a backgrounded tab burns the whole
5
+ // dwell, so the user switches back to an empty corner and never learns the save failed. All three
6
+ // are HOLDS on one counter, because a pointer leaving while the tab is still hidden must not
7
+ // restart the countdown.
8
+
9
+ import { useId } from '../a11y';
10
+ import {
11
+ addToast,
12
+ advanceToasts,
13
+ dismissToast,
14
+ EMPTY_TOAST_QUEUE,
15
+ nextExpiryMs,
16
+ type ToastHold,
17
+ type ToastInput,
18
+ type ToastQueue,
19
+ toastItem,
20
+ } from './toast-state';
21
+
22
+ /**
23
+ * Every host capability the store touches, injected — the same shape `ThemeEnv` has and for the
24
+ * same reason: a test drives the clock instead of waiting on it, and a server render gets an env
25
+ * whose timers never fire rather than a store that throws.
26
+ */
27
+ export interface ToastEnv {
28
+ readonly now: () => number;
29
+ readonly setTimer: (fn: () => void, ms: number) => number;
30
+ readonly clearTimer: (handle: number) => void;
31
+ readonly isHidden: () => boolean;
32
+ /** Subscribe to tab visibility; answers the unsubscribe. */
33
+ readonly onVisibilityChange: (fn: () => void) => () => void;
34
+ }
35
+
36
+ /**
37
+ * A server render has no tab to hide and no frame to wait for, so the honest env is one where
38
+ * nothing is ever scheduled — not a stub of a browser, the same way `INERT_SOLID_RUNTIME` is not
39
+ * a stub of a Solid runtime. A `<ToastRegion>` rendered against it emits its empty live region,
40
+ * which is exactly what the document needs to carry BEFORE the first message arrives.
41
+ */
42
+ export const INERT_TOAST_ENV: ToastEnv = Object.freeze({
43
+ now: () => 0,
44
+ setTimer: () => 0,
45
+ clearTimer: () => undefined,
46
+ isHidden: () => false,
47
+ onVisibilityChange: () => () => undefined,
48
+ });
49
+
50
+ export function browserToastEnv(): ToastEnv {
51
+ return {
52
+ // `Date.now`, not `performance.now`: the dwell is wall-clock time a human is reading for, and
53
+ // it is compared only against itself.
54
+ now: () => Date.now(),
55
+ setTimer: (fn, ms) => Number(setTimeout(fn, ms)),
56
+ clearTimer: (handle) => {
57
+ clearTimeout(handle);
58
+ },
59
+ isHidden: () => document.hidden,
60
+ onVisibilityChange: (fn) => {
61
+ document.addEventListener('visibilitychange', fn);
62
+ return () => {
63
+ document.removeEventListener('visibilitychange', fn);
64
+ };
65
+ },
66
+ };
67
+ }
68
+
69
+ export interface ToastStore {
70
+ readonly queue: () => ToastQueue;
71
+ /** Shows a toast, or refreshes the identical one already queued. Answers the live toast's id. */
72
+ readonly show: (input: ToastInput) => string;
73
+ readonly dismiss: (id: string) => void;
74
+ /** Stop the countdown for one reason. Holds are independent — the last one released resumes. */
75
+ readonly hold: (reason: ToastHold) => void;
76
+ readonly release: (reason: ToastHold) => void;
77
+ readonly subscribe: (listener: (queue: ToastQueue) => void) => () => void;
78
+ /** Drop the timer and the visibility listener. Called from an island's `onCleanup`. */
79
+ readonly stop: () => void;
80
+ }
81
+
82
+ /** A DOM is the whole question, exactly as `solid()` asks it. */
83
+ function defaultEnv(): ToastEnv {
84
+ return typeof document === 'undefined' ? INERT_TOAST_ENV : browserToastEnv();
85
+ }
86
+
87
+ export function createToastStore(env: ToastEnv = defaultEnv()): ToastStore {
88
+ const holds = new Set<ToastHold>();
89
+ const listeners = new Set<(queue: ToastQueue) => void>();
90
+ let queue: ToastQueue = EMPTY_TOAST_QUEUE;
91
+ let timer: number | null = null;
92
+ let lastAt = env.now();
93
+
94
+ /** The ids on the list, which is the only change a renderer can see. */
95
+ const shape = (): string => queue.items.map((item) => item.id).join(' ');
96
+
97
+ /** Bank the time that has passed. While held, nothing is spent — only the clock is re-based. */
98
+ const spend = (): void => {
99
+ const now = env.now();
100
+ if (holds.size === 0) queue = advanceToasts(queue, now - lastAt);
101
+ lastAt = now;
102
+ };
103
+
104
+ const schedule = (): void => {
105
+ if (timer !== null) {
106
+ env.clearTimer(timer);
107
+ timer = null;
108
+ }
109
+ if (holds.size > 0) return;
110
+ const next = nextExpiryMs(queue);
111
+ if (next === null) return;
112
+ timer = env.setTimer(onDue, Math.max(next, 0));
113
+ };
114
+
115
+ const publish = (before: string): void => {
116
+ if (shape() === before) return;
117
+ for (const listener of [...listeners]) listener(queue);
118
+ };
119
+
120
+ function onDue(): void {
121
+ timer = null;
122
+ const before = shape();
123
+ spend();
124
+ publish(before);
125
+ schedule();
126
+ }
127
+
128
+ const act = (change: () => void): void => {
129
+ const before = shape();
130
+ spend();
131
+ change();
132
+ publish(before);
133
+ schedule();
134
+ };
135
+
136
+ const stopVisibility = env.onVisibilityChange(() => {
137
+ if (env.isHidden()) act(() => holds.add('hidden'));
138
+ else act(() => holds.delete('hidden'));
139
+ });
140
+ // A store created while the tab is ALREADY in the background starts held: the first
141
+ // `visibilitychange` it hears is the one that brings the tab back, and by then the dwell is gone.
142
+ if (env.isHidden()) holds.add('hidden');
143
+
144
+ return {
145
+ queue: () => queue,
146
+ show: (input) => {
147
+ let id = '';
148
+ act(() => {
149
+ const added = addToast(queue, toastItem(useId('toast'), input));
150
+ queue = added.queue;
151
+ id = added.id;
152
+ });
153
+ return id;
154
+ },
155
+ dismiss: (id) => {
156
+ act(() => {
157
+ queue = dismissToast(queue, id);
158
+ });
159
+ },
160
+ hold: (reason) => {
161
+ act(() => holds.add(reason));
162
+ },
163
+ release: (reason) => {
164
+ act(() => holds.delete(reason));
165
+ },
166
+ subscribe: (listener) => {
167
+ listeners.add(listener);
168
+ return () => {
169
+ listeners.delete(listener);
170
+ };
171
+ },
172
+ stop: () => {
173
+ if (timer !== null) env.clearTimer(timer);
174
+ timer = null;
175
+ stopVisibility();
176
+ listeners.clear();
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,26 @@
1
+ // The reactive shell over `createToastStore`, and the whole of what it adds: the queue lands in a
2
+ // signal, so `<Toaster>` re-renders when a message arrives or a dwell runs out.
3
+ //
4
+ // The subscription lives in an EFFECT, which is the package's one seam for DOM-only work: a server
5
+ // render never runs it, so the store is never subscribed to on a path where no timer can fire and
6
+ // the region renders empty — which is exactly what a document needs to carry before the first
7
+ // message, since a live region created with its content already inside it is not announced.
8
+
9
+ import { solid } from '../theme/solid-adapter';
10
+ import type { ToastQueue } from './toast-state';
11
+ import type { ToastStore } from './toast-store';
12
+
13
+ export function useToasts(store: ToastStore): () => ToastQueue {
14
+ const runtime = solid();
15
+ const [queue, setQueue] = runtime.createSignal<ToastQueue>(store.queue());
16
+
17
+ runtime.createEffect(() => {
18
+ const unsubscribe = store.subscribe(setQueue);
19
+ // A toast shown between the render and this effect would otherwise never be seen: the signal
20
+ // holds the queue as it was when the component body ran.
21
+ setQueue(store.queue());
22
+ runtime.onCleanup(unsubscribe);
23
+ });
24
+
25
+ return queue;
26
+ }