@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.
- package/CATALOG.md +36 -5
- package/CLAUDE.md +51 -0
- package/README.md +102 -0
- package/package.json +5 -5
- package/src/a11y.ts +49 -10
- package/src/components/AppShell.tsx +18 -1
- package/src/components/AsyncRegion.module.scss +15 -0
- package/src/components/AsyncRegion.tsx +78 -0
- package/src/components/Button.module.scss +10 -1
- package/src/components/Button.tsx +37 -5
- package/src/components/DataTable.module.scss +7 -0
- package/src/components/DataTable.tsx +38 -5
- package/src/components/Dropzone.module.scss +11 -0
- package/src/components/FileInput.module.scss +11 -0
- package/src/components/Form.tsx +62 -2
- package/src/components/Image.module.scss +5 -0
- package/src/components/Image.tsx +4 -0
- package/src/components/Toast.module.scss +17 -8
- package/src/components/Toast.tsx +34 -2
- package/src/components/Toaster.tsx +77 -0
- package/src/components/async-branch.ts +113 -0
- package/src/components/image-source.ts +15 -0
- package/src/errors.ts +26 -0
- package/src/fake-dom.ts +5 -0
- package/src/form/field-path.ts +12 -0
- package/src/form/form-binding.ts +69 -4
- package/src/form/form-state.ts +19 -1
- package/src/form/form-touch.ts +51 -0
- package/src/form/use-form.ts +11 -1
- package/src/index.ts +61 -4
- package/src/theme/brand.ts +39 -2
- package/src/toast/toast-state.ts +0 -0
- package/src/toast/toast-store.ts +179 -0
- package/src/toast/use-toasts.ts +26 -0
- package/src/tokens/contrast-pairs.ts +71 -0
package/src/form/form-binding.ts
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
};
|
package/src/form/form-state.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/form/use-form.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
package/src/theme/brand.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
|
|
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
|
+
}
|