@funstack/ui 0.1.0-alpha.2 → 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accordion/accordion-context.mjs.map +1 -1
- package/dist/accordion/accordion-item.mjs +18 -4
- package/dist/accordion/accordion-item.mjs.map +1 -1
- package/dist/accordion/accordion-panel.mjs +2 -3
- package/dist/accordion/accordion-panel.mjs.map +1 -1
- package/dist/accordion/accordion-root.d.mts.map +1 -1
- package/dist/accordion/accordion-root.mjs +32 -14
- package/dist/accordion/accordion-root.mjs.map +1 -1
- package/dist/accordion/accordion-trigger.mjs +4 -5
- package/dist/accordion/accordion-trigger.mjs.map +1 -1
- package/dist/checkbox/checkbox.d.mts +5 -3
- package/dist/checkbox/checkbox.d.mts.map +1 -1
- package/dist/checkbox/checkbox.mjs +25 -9
- package/dist/checkbox/checkbox.mjs.map +1 -1
- package/dist/checkbox-group/checkbox-group-context.mjs.map +1 -1
- package/dist/checkbox-group/checkbox-group-item.d.mts +2 -1
- package/dist/checkbox-group/checkbox-group-item.d.mts.map +1 -1
- package/dist/checkbox-group/checkbox-group-item.mjs +3 -1
- package/dist/checkbox-group/checkbox-group-item.mjs.map +1 -1
- package/dist/checkbox-group/checkbox-group-root.d.mts +5 -2
- package/dist/checkbox-group/checkbox-group-root.d.mts.map +1 -1
- package/dist/checkbox-group/checkbox-group-root.mjs +24 -5
- package/dist/checkbox-group/checkbox-group-root.mjs.map +1 -1
- package/dist/combobox/combobox-context.mjs.map +1 -1
- package/dist/combobox/combobox-input.d.mts +3 -1
- package/dist/combobox/combobox-input.d.mts.map +1 -1
- package/dist/combobox/combobox-input.mjs +16 -2
- package/dist/combobox/combobox-input.mjs.map +1 -1
- package/dist/combobox/combobox-list.mjs +1 -1
- package/dist/combobox/combobox-list.mjs.map +1 -1
- package/dist/combobox/combobox-root.d.mts.map +1 -1
- package/dist/combobox/combobox-root.mjs +19 -4
- package/dist/combobox/combobox-root.mjs.map +1 -1
- package/dist/dialog/dialog-close.d.mts +17 -10
- package/dist/dialog/dialog-close.d.mts.map +1 -1
- package/dist/dialog/dialog-close.mjs +31 -20
- package/dist/dialog/dialog-close.mjs.map +1 -1
- package/dist/dialog/dialog-context.mjs.map +1 -1
- package/dist/dialog/dialog-form.d.mts +12 -5
- package/dist/dialog/dialog-form.d.mts.map +1 -1
- package/dist/dialog/dialog-form.mjs +16 -28
- package/dist/dialog/dialog-form.mjs.map +1 -1
- package/dist/dialog/dialog-popup.d.mts +10 -4
- package/dist/dialog/dialog-popup.d.mts.map +1 -1
- package/dist/dialog/dialog-popup.mjs +106 -41
- package/dist/dialog/dialog-popup.mjs.map +1 -1
- package/dist/dialog/dialog-root.d.mts +14 -8
- package/dist/dialog/dialog-root.d.mts.map +1 -1
- package/dist/dialog/dialog-root.mjs +66 -11
- package/dist/dialog/dialog-root.mjs.map +1 -1
- package/dist/dialog/dialog-trigger.d.mts +3 -2
- package/dist/dialog/dialog-trigger.d.mts.map +1 -1
- package/dist/dialog/dialog-trigger.mjs.map +1 -1
- package/dist/dialog/index.d.mts +4 -2
- package/dist/dialog/index.d.mts.map +1 -1
- package/dist/dialog/index.mjs +4 -2
- package/dist/dialog/index.mjs.map +1 -1
- package/dist/field/field-control.d.mts +33 -0
- package/dist/field/field-control.d.mts.map +1 -0
- package/dist/field/field-control.mjs +68 -0
- package/dist/field/field-control.mjs.map +1 -0
- package/dist/field/field-description.d.mts +24 -0
- package/dist/field/field-description.d.mts.map +1 -0
- package/dist/field/field-description.mjs +34 -0
- package/dist/field/field-description.mjs.map +1 -0
- package/dist/field/field-error.d.mts +30 -0
- package/dist/field/field-error.d.mts.map +1 -0
- package/dist/field/field-error.mjs +42 -0
- package/dist/field/field-error.mjs.map +1 -0
- package/dist/field/field-label.d.mts +25 -0
- package/dist/field/field-label.d.mts.map +1 -0
- package/dist/field/field-label.mjs +46 -0
- package/dist/field/field-label.mjs.map +1 -0
- package/dist/field/field-root.d.mts +53 -0
- package/dist/field/field-root.d.mts.map +1 -0
- package/dist/field/field-root.mjs +317 -0
- package/dist/field/field-root.mjs.map +1 -0
- package/dist/field/index.d.mts +18 -0
- package/dist/field/index.d.mts.map +1 -0
- package/dist/field/index.mjs +17 -0
- package/dist/field/index.mjs.map +1 -0
- package/dist/form/form-root.d.mts +58 -0
- package/dist/form/form-root.d.mts.map +1 -0
- package/dist/form/form-root.mjs +146 -0
- package/dist/form/form-root.mjs.map +1 -0
- package/dist/form/form-submit.d.mts +31 -0
- package/dist/form/form-submit.d.mts.map +1 -0
- package/dist/form/form-submit.mjs +52 -0
- package/dist/form/form-submit.mjs.map +1 -0
- package/dist/form/index.d.mts +11 -0
- package/dist/form/index.d.mts.map +1 -0
- package/dist/form/index.mjs +11 -0
- package/dist/form/index.mjs.map +1 -0
- package/dist/index.d.mts +27 -1
- package/dist/index.mjs +6 -1
- package/dist/internal/content-focus-probe.mjs +42 -0
- package/dist/internal/content-focus-probe.mjs.map +1 -0
- package/dist/internal/field-context.d.mts +19 -0
- package/dist/internal/field-context.d.mts.map +1 -0
- package/dist/internal/field-context.mjs +14 -0
- package/dist/internal/field-context.mjs.map +1 -0
- package/dist/internal/focus-first-invalid.mjs +67 -0
- package/dist/internal/focus-first-invalid.mjs.map +1 -0
- package/dist/internal/form-status-watcher.mjs +31 -0
- package/dist/internal/form-status-watcher.mjs.map +1 -0
- package/dist/internal/modal-registry.mjs +39 -0
- package/dist/internal/modal-registry.mjs.map +1 -0
- package/dist/internal/types.d.mts +3 -1
- package/dist/internal/types.d.mts.map +1 -1
- package/dist/internal/use-controllable-action.mjs +33 -15
- package/dist/internal/use-controllable-action.mjs.map +1 -1
- package/dist/internal/use-field-control.mjs +107 -0
- package/dist/internal/use-field-control.mjs.map +1 -0
- package/dist/internal/use-form-owner-event.mjs +44 -0
- package/dist/internal/use-form-owner-event.mjs.map +1 -0
- package/dist/internal/use-form-reset.mjs +10 -33
- package/dist/internal/use-form-reset.mjs.map +1 -1
- package/dist/internal/use-latest.mjs +32 -0
- package/dist/internal/use-latest.mjs.map +1 -0
- package/dist/internal/use-modal-registration.mjs +47 -0
- package/dist/internal/use-modal-registration.mjs.map +1 -0
- package/dist/internal/use-native-group-reconciliation.mjs +6 -5
- package/dist/internal/use-native-group-reconciliation.mjs.map +1 -1
- package/dist/internal/use-native-select-value.mjs +7 -5
- package/dist/internal/use-native-select-value.mjs.map +1 -1
- package/dist/internal/use-popover-popup.mjs +8 -22
- package/dist/internal/use-popover-popup.mjs.map +1 -1
- package/dist/internal/use-popover-root.mjs +58 -23
- package/dist/internal/use-popover-root.mjs.map +1 -1
- package/dist/internal/validity.d.mts +24 -0
- package/dist/internal/validity.d.mts.map +1 -0
- package/dist/internal/validity.mjs +108 -0
- package/dist/internal/validity.mjs.map +1 -0
- package/dist/menu/menu-group.d.mts.map +1 -1
- package/dist/menu/menu-group.mjs +3 -2
- package/dist/menu/menu-group.mjs.map +1 -1
- package/dist/menu/menu-radio-group.mjs +9 -4
- package/dist/menu/menu-radio-group.mjs.map +1 -1
- package/dist/menu/menu-root.mjs.map +1 -1
- package/dist/radio-group/radio-group-root.d.mts +4 -2
- package/dist/radio-group/radio-group-root.d.mts.map +1 -1
- package/dist/radio-group/radio-group-root.mjs +34 -8
- package/dist/radio-group/radio-group-root.mjs.map +1 -1
- package/dist/select/select-group.d.mts.map +1 -1
- package/dist/select/select-group.mjs +6 -4
- package/dist/select/select-group.mjs.map +1 -1
- package/dist/select/select-root.d.mts +4 -2
- package/dist/select/select-root.d.mts.map +1 -1
- package/dist/select/select-root.mjs +26 -6
- package/dist/select/select-root.mjs.map +1 -1
- package/dist/slider/index.d.mts +32 -0
- package/dist/slider/index.d.mts.map +1 -0
- package/dist/slider/index.mjs +27 -0
- package/dist/slider/index.mjs.map +1 -0
- package/dist/slider/slider-arithmetic.mjs +120 -0
- package/dist/slider/slider-arithmetic.mjs.map +1 -0
- package/dist/slider/slider-context.mjs +13 -0
- package/dist/slider/slider-context.mjs.map +1 -0
- package/dist/slider/slider-indicator.d.mts +22 -0
- package/dist/slider/slider-indicator.d.mts.map +1 -0
- package/dist/slider/slider-indicator.mjs +36 -0
- package/dist/slider/slider-indicator.mjs.map +1 -0
- package/dist/slider/slider-label.d.mts +23 -0
- package/dist/slider/slider-label.d.mts.map +1 -0
- package/dist/slider/slider-label.mjs +44 -0
- package/dist/slider/slider-label.mjs.map +1 -0
- package/dist/slider/slider-root.d.mts +111 -0
- package/dist/slider/slider-root.d.mts.map +1 -0
- package/dist/slider/slider-root.mjs +582 -0
- package/dist/slider/slider-root.mjs.map +1 -0
- package/dist/slider/slider-thumb.d.mts +58 -0
- package/dist/slider/slider-thumb.d.mts.map +1 -0
- package/dist/slider/slider-thumb.mjs +145 -0
- package/dist/slider/slider-thumb.mjs.map +1 -0
- package/dist/slider/slider-track.d.mts +29 -0
- package/dist/slider/slider-track.d.mts.map +1 -0
- package/dist/slider/slider-track.mjs +54 -0
- package/dist/slider/slider-track.mjs.map +1 -0
- package/dist/slider/slider-value.d.mts +34 -0
- package/dist/slider/slider-value.d.mts.map +1 -0
- package/dist/slider/slider-value.mjs +38 -0
- package/dist/slider/slider-value.mjs.map +1 -0
- package/dist/switch/switch.d.mts +5 -3
- package/dist/switch/switch.d.mts.map +1 -1
- package/dist/switch/switch.mjs +24 -8
- package/dist/switch/switch.mjs.map +1 -1
- package/dist/tabs/tabs-root.mjs +13 -3
- package/dist/tabs/tabs-root.mjs.map +1 -1
- package/dist/toast/index.d.mts +25 -0
- package/dist/toast/index.d.mts.map +1 -0
- package/dist/toast/index.mjs +26 -0
- package/dist/toast/index.mjs.map +1 -0
- package/dist/toast/toast-action.d.mts +39 -0
- package/dist/toast/toast-action.d.mts.map +1 -0
- package/dist/toast/toast-action.mjs +64 -0
- package/dist/toast/toast-action.mjs.map +1 -0
- package/dist/toast/toast-close.d.mts +25 -0
- package/dist/toast/toast-close.d.mts.map +1 -0
- package/dist/toast/toast-close.mjs +46 -0
- package/dist/toast/toast-close.mjs.map +1 -0
- package/dist/toast/toast-context.mjs +19 -0
- package/dist/toast/toast-context.mjs.map +1 -0
- package/dist/toast/toast-description.d.mts +24 -0
- package/dist/toast/toast-description.d.mts.map +1 -0
- package/dist/toast/toast-description.mjs +35 -0
- package/dist/toast/toast-description.mjs.map +1 -0
- package/dist/toast/toast-root.d.mts +43 -0
- package/dist/toast/toast-root.d.mts.map +1 -0
- package/dist/toast/toast-root.mjs +204 -0
- package/dist/toast/toast-root.mjs.map +1 -0
- package/dist/toast/toast-title.d.mts +24 -0
- package/dist/toast/toast-title.d.mts.map +1 -0
- package/dist/toast/toast-title.mjs +33 -0
- package/dist/toast/toast-title.mjs.map +1 -0
- package/dist/toast/toast-viewport.d.mts +52 -0
- package/dist/toast/toast-viewport.d.mts.map +1 -0
- package/dist/toast/toast-viewport.mjs +621 -0
- package/dist/toast/toast-viewport.mjs.map +1 -0
- package/dist/toast/toast.d.mts +119 -0
- package/dist/toast/toast.d.mts.map +1 -0
- package/dist/toast/toast.mjs +153 -0
- package/dist/toast/toast.mjs.map +1 -0
- package/package.json +5 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { dataAttr } from "../internal/data-attr.mjs";
|
|
3
|
+
import { useLatest } from "../internal/use-latest.mjs";
|
|
4
|
+
import { useRender } from "../internal/use-render.mjs";
|
|
5
|
+
import { useDevWarning } from "../internal/use-dev-warning.mjs";
|
|
6
|
+
import { FieldContext, FormContext } from "../internal/field-context.mjs";
|
|
7
|
+
import { useFormOwnerEvent } from "../internal/use-form-owner-event.mjs";
|
|
8
|
+
import { VALID, arraysEqual, focusTargetOf, normaliseErrorValue, snapshotValidity, supportsUserInvalid, validityElementOf, validityEquals } from "../internal/validity.mjs";
|
|
9
|
+
import { collectInvalid } from "../internal/focus-first-invalid.mjs";
|
|
10
|
+
import { use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { jsx } from "react/jsx-runtime";
|
|
12
|
+
import { useFormStatus } from "react-dom";
|
|
13
|
+
//#region src/field/field-root.tsx
|
|
14
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
15
|
+
const NONE = Object.freeze([]);
|
|
16
|
+
const INITIAL_MIRROR = {
|
|
17
|
+
userInvalid: false,
|
|
18
|
+
validity: VALID,
|
|
19
|
+
messages: NONE
|
|
20
|
+
};
|
|
21
|
+
function mirrorEquals(a, b) {
|
|
22
|
+
return a.userInvalid === b.userInvalid && validityEquals(a.validity, b.validity) && arraysEqual(a.messages, b.messages);
|
|
23
|
+
}
|
|
24
|
+
/** `FormData` entries as comparable strings; a `File` counts as unchanged (design 3.2 item 2). */
|
|
25
|
+
function entryStrings(entries) {
|
|
26
|
+
return entries.map((entry) => typeof entry === "string" ? entry : "\0file");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The field: a `div` that owns the ids, the urgent validity mirror, the custom-validity slot
|
|
30
|
+
* (`validate` and the server error), the control registration and the native listeners
|
|
31
|
+
* (design/components/field-form.md 2, 14). `data-pending` is the enclosing form's submission
|
|
32
|
+
* (`useFormStatus`), the one part that folds the form channel into its own state, because a
|
|
33
|
+
* Field has no action of its own.
|
|
34
|
+
*
|
|
35
|
+
* Owned keys (conventions 1.2): `data-invalid`, `data-disabled`, `data-required`, `data-pending`.
|
|
36
|
+
*/
|
|
37
|
+
function FieldRoot({ name, disabled = false, required = false, validate, className, style, children, render, ...rest }) {
|
|
38
|
+
const parent = use(FieldContext);
|
|
39
|
+
const form = use(FormContext);
|
|
40
|
+
useDevWarning(parent !== null, "Field.Root: nested inside another Field.Root. A control registers with the nearest Field only; give each control its own, sibling Field.");
|
|
41
|
+
useDevWarning(validate !== void 0 && name === void 0, "Field.Root: `validate` without `name`. `validate` receives `formData.get(name)`, which is always \"\" without a name.");
|
|
42
|
+
const id = useId();
|
|
43
|
+
const ids = useMemo(() => ({
|
|
44
|
+
control: `${id}-control`,
|
|
45
|
+
label: `${id}-label`,
|
|
46
|
+
description: `${id}-description`,
|
|
47
|
+
error: `${id}-error`
|
|
48
|
+
}), [id]);
|
|
49
|
+
const { pending: formPending } = useFormStatus();
|
|
50
|
+
const controlRef = useRef(null);
|
|
51
|
+
const [control, setControl] = useState(null);
|
|
52
|
+
const warnedTwoControls = useRef(false);
|
|
53
|
+
const registerControl = useCallback((registration) => {
|
|
54
|
+
const current = controlRef.current;
|
|
55
|
+
if (current !== null && current.element !== registration.element) {
|
|
56
|
+
if (isDev && !warnedTwoControls.current) {
|
|
57
|
+
warnedTwoControls.current = true;
|
|
58
|
+
console.error("Field.Root: two form controls registered in one Field. The first one is the Field's control; give each control its own Field.Root.");
|
|
59
|
+
}
|
|
60
|
+
return () => {};
|
|
61
|
+
}
|
|
62
|
+
controlRef.current = registration;
|
|
63
|
+
setControl(registration);
|
|
64
|
+
return () => {
|
|
65
|
+
if (controlRef.current !== registration) return;
|
|
66
|
+
controlRef.current = null;
|
|
67
|
+
setControl(null);
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
const errorCount = useRef(0);
|
|
71
|
+
const registerError = useCallback(() => {
|
|
72
|
+
errorCount.current += 1;
|
|
73
|
+
if (isDev && errorCount.current === 2) console.error("Field.Error: two Field.Error parts in one Field.Root. Render one; its function `children` receives `validity` for per-constraint wording.");
|
|
74
|
+
return () => {
|
|
75
|
+
errorCount.current -= 1;
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
78
|
+
const errors = form?.errors;
|
|
79
|
+
const serverMessages = useMemo(() => normaliseErrorValue(name === void 0 ? void 0 : errors?.[name]), [errors, name]);
|
|
80
|
+
const serverKey = serverMessages.join("\n");
|
|
81
|
+
/** The user edited the control since the last settled submission. */
|
|
82
|
+
const [dismissed, setDismissed] = useState(false);
|
|
83
|
+
/**
|
|
84
|
+
* The errors shown now answer a value the user had already changed during their flight
|
|
85
|
+
* (intercepted path). Fixed from one settle to the next, so a hidden stale error stays hidden
|
|
86
|
+
* while the next submission is in flight (the `errors` prop still holds that result).
|
|
87
|
+
*/
|
|
88
|
+
const [suppressed, setSuppressed] = useState(false);
|
|
89
|
+
/** The user changed the value during the flight in progress: becomes `suppressed` at its settle. */
|
|
90
|
+
const [suppressNext, setSuppressNext] = useState(false);
|
|
91
|
+
const [previousFormPending, setPreviousFormPending] = useState(formPending);
|
|
92
|
+
if (previousFormPending !== formPending) {
|
|
93
|
+
setPreviousFormPending(formPending);
|
|
94
|
+
if (formPending) setSuppressNext(false);
|
|
95
|
+
else {
|
|
96
|
+
setDismissed(false);
|
|
97
|
+
setSuppressed(suppressNext);
|
|
98
|
+
setSuppressNext(false);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const serverShown = serverMessages.length > 0 && !dismissed && !suppressed;
|
|
102
|
+
const [mirror, setMirror] = useState(INITIAL_MIRROR);
|
|
103
|
+
/** The last mirror written: `refresh` compares against it, so an unchanged read renders nothing. */
|
|
104
|
+
const mirrorRef = useRef(INITIAL_MIRROR);
|
|
105
|
+
/** jsdom fallback for `:user-invalid` (design 2.2): `invalid` fired, cleared by a reset. */
|
|
106
|
+
const attempted = useRef(false);
|
|
107
|
+
/** The element the slot was last written on (a group's first member can change). */
|
|
108
|
+
const slotElement = useRef(null);
|
|
109
|
+
const latest = useLatest({
|
|
110
|
+
name,
|
|
111
|
+
validate,
|
|
112
|
+
serverMessages,
|
|
113
|
+
serverShown,
|
|
114
|
+
formPending,
|
|
115
|
+
form
|
|
116
|
+
});
|
|
117
|
+
/** Re-reads the slot and the mirror; returns the messages the Field shows for the control. */
|
|
118
|
+
const refresh = useCallback((options = {}) => {
|
|
119
|
+
const registration = controlRef.current;
|
|
120
|
+
if (registration === null) return NONE;
|
|
121
|
+
const el = validityElementOf(registration.element);
|
|
122
|
+
if (el === null) return NONE;
|
|
123
|
+
const { name, validate, serverMessages, serverShown, formPending, form } = latest.current;
|
|
124
|
+
const owner = el.form;
|
|
125
|
+
const formData = owner === null ? new FormData() : new FormData(owner);
|
|
126
|
+
const entry = name === void 0 ? null : formData.get(name);
|
|
127
|
+
const custom = validate === void 0 ? NONE : normaliseErrorValue(validate(typeof entry === "string" ? entry : "", formData));
|
|
128
|
+
const showServer = serverShown && options.dismiss !== true;
|
|
129
|
+
if (slotElement.current !== null && slotElement.current !== el) slotElement.current.setCustomValidity("");
|
|
130
|
+
slotElement.current = el;
|
|
131
|
+
el.setCustomValidity(showServer ? serverMessages.join("\n") : custom.join("\n"));
|
|
132
|
+
if (options.reset === true) attempted.current = false;
|
|
133
|
+
const userInvalid = supportsUserInvalid() ? el.matches(":user-invalid") : attempted.current && !el.validity.valid;
|
|
134
|
+
const validity = snapshotValidity(el.validity);
|
|
135
|
+
const messages = validity.valid ? NONE : validity.customError && !showServer && custom.length > 0 ? custom : el.validationMessage === "" ? NONE : [el.validationMessage];
|
|
136
|
+
const next = {
|
|
137
|
+
userInvalid,
|
|
138
|
+
validity,
|
|
139
|
+
messages
|
|
140
|
+
};
|
|
141
|
+
if (!mirrorEquals(mirrorRef.current, next)) {
|
|
142
|
+
mirrorRef.current = next;
|
|
143
|
+
setMirror(next);
|
|
144
|
+
}
|
|
145
|
+
const submitted = formPending && name !== void 0 ? form?.submittedData() ?? null : null;
|
|
146
|
+
if (submitted !== null && name !== void 0) setSuppressNext(!arraysEqual(entryStrings(formData.getAll(name)), entryStrings(submitted.getAll(name))));
|
|
147
|
+
return showServer ? serverMessages : messages;
|
|
148
|
+
}, [latest]);
|
|
149
|
+
useLayoutEffect(() => {
|
|
150
|
+
refresh();
|
|
151
|
+
}, [
|
|
152
|
+
refresh,
|
|
153
|
+
control,
|
|
154
|
+
serverShown,
|
|
155
|
+
serverKey,
|
|
156
|
+
validate
|
|
157
|
+
]);
|
|
158
|
+
useLayoutEffect(() => () => {
|
|
159
|
+
slotElement.current?.setCustomValidity("");
|
|
160
|
+
slotElement.current = null;
|
|
161
|
+
}, []);
|
|
162
|
+
const element = control?.element ?? null;
|
|
163
|
+
useLayoutEffect(() => {
|
|
164
|
+
if (element === null) return;
|
|
165
|
+
const onEdit = () => {
|
|
166
|
+
setDismissed(true);
|
|
167
|
+
refresh({ dismiss: true });
|
|
168
|
+
};
|
|
169
|
+
const onBlur = () => refresh();
|
|
170
|
+
const onInvalid = (event) => {
|
|
171
|
+
const target = event.target;
|
|
172
|
+
const attempt = supportsUserInvalid() ? target.matches(":user-invalid") : true;
|
|
173
|
+
if (!supportsUserInvalid()) attempted.current = true;
|
|
174
|
+
if (attempt && errorCount.current > 0) {
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
const messages = refresh();
|
|
177
|
+
collectInvalid(focusTargetOf(element), messages.join(" "));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
refresh();
|
|
181
|
+
};
|
|
182
|
+
element.addEventListener("input", onEdit, true);
|
|
183
|
+
element.addEventListener("change", onEdit, true);
|
|
184
|
+
element.addEventListener("focusout", onBlur, true);
|
|
185
|
+
element.addEventListener("invalid", onInvalid, true);
|
|
186
|
+
return () => {
|
|
187
|
+
element.removeEventListener("input", onEdit, true);
|
|
188
|
+
element.removeEventListener("change", onEdit, true);
|
|
189
|
+
element.removeEventListener("focusout", onBlur, true);
|
|
190
|
+
element.removeEventListener("invalid", onInvalid, true);
|
|
191
|
+
};
|
|
192
|
+
}, [element, refresh]);
|
|
193
|
+
const ownerRef = useMemo(() => ({
|
|
194
|
+
get current() {
|
|
195
|
+
return controlRef.current?.element ?? null;
|
|
196
|
+
},
|
|
197
|
+
set current(_ignored) {}
|
|
198
|
+
}), []);
|
|
199
|
+
const onOwnerEdit = (event) => {
|
|
200
|
+
const target = event.target;
|
|
201
|
+
if (target instanceof Node && controlRef.current?.element.contains(target) === true) return;
|
|
202
|
+
refresh();
|
|
203
|
+
};
|
|
204
|
+
useFormOwnerEvent(ownerRef, "input", onOwnerEdit);
|
|
205
|
+
useFormOwnerEvent(ownerRef, "change", onOwnerEdit);
|
|
206
|
+
useFormOwnerEvent(ownerRef, "submit", () => {
|
|
207
|
+
setSuppressNext(false);
|
|
208
|
+
});
|
|
209
|
+
useFormOwnerEvent(ownerRef, "reset", (event) => {
|
|
210
|
+
queueMicrotask(() => {
|
|
211
|
+
if (event.defaultPrevented) return;
|
|
212
|
+
refresh({ reset: true });
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
const registerField = form?.registerField;
|
|
216
|
+
const formRegistration = useRef(null);
|
|
217
|
+
useLayoutEffect(() => {
|
|
218
|
+
if (registerField === void 0) return;
|
|
219
|
+
const registration = {
|
|
220
|
+
getFocusTarget: () => focusTargetOf(controlRef.current?.element ?? null),
|
|
221
|
+
showsServerError: () => latest.current.serverShown,
|
|
222
|
+
serverMessages: () => latest.current.serverMessages,
|
|
223
|
+
refresh: () => {
|
|
224
|
+
refresh();
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
formRegistration.current = registration;
|
|
228
|
+
const unregister = registerField(registration);
|
|
229
|
+
return () => {
|
|
230
|
+
unregister();
|
|
231
|
+
if (formRegistration.current === registration) formRegistration.current = null;
|
|
232
|
+
};
|
|
233
|
+
}, [
|
|
234
|
+
registerField,
|
|
235
|
+
latest,
|
|
236
|
+
refresh
|
|
237
|
+
]);
|
|
238
|
+
const notifyChange = useCallback((edit) => {
|
|
239
|
+
if (edit) setDismissed(true);
|
|
240
|
+
refresh(edit ? { dismiss: true } : {});
|
|
241
|
+
latest.current.form?.refreshFields(formRegistration.current);
|
|
242
|
+
}, [refresh, latest]);
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
if (!isDev || controlRef.current !== null) return;
|
|
245
|
+
console.error("Field.Root: no form control registered. Render a Field.Control or a FUNSTACK form control (Checkbox, Switch, RadioGroup, CheckboxGroup, Select, Combobox) inside it.");
|
|
246
|
+
}, []);
|
|
247
|
+
const invalid = mirror.userInvalid || serverShown;
|
|
248
|
+
const messages = serverShown ? serverMessages : mirror.userInvalid ? mirror.messages : NONE;
|
|
249
|
+
const validity = mirror.validity;
|
|
250
|
+
const stateDisabled = disabled || (control?.disabled ?? false);
|
|
251
|
+
const stateRequired = required || (control?.required ?? false);
|
|
252
|
+
const memoState = useMemo(() => ({
|
|
253
|
+
invalid,
|
|
254
|
+
messages,
|
|
255
|
+
validity,
|
|
256
|
+
disabled: stateDisabled,
|
|
257
|
+
required: stateRequired,
|
|
258
|
+
pending: formPending
|
|
259
|
+
}), [
|
|
260
|
+
invalid,
|
|
261
|
+
messages,
|
|
262
|
+
validity,
|
|
263
|
+
stateDisabled,
|
|
264
|
+
stateRequired,
|
|
265
|
+
formPending
|
|
266
|
+
]);
|
|
267
|
+
const labelable = control?.labelable ?? true;
|
|
268
|
+
const context = useMemo(() => ({
|
|
269
|
+
ids,
|
|
270
|
+
name,
|
|
271
|
+
disabled,
|
|
272
|
+
required,
|
|
273
|
+
state: memoState,
|
|
274
|
+
labelable,
|
|
275
|
+
registerControl,
|
|
276
|
+
registerError,
|
|
277
|
+
notifyChange
|
|
278
|
+
}), [
|
|
279
|
+
ids,
|
|
280
|
+
name,
|
|
281
|
+
disabled,
|
|
282
|
+
required,
|
|
283
|
+
memoState,
|
|
284
|
+
labelable,
|
|
285
|
+
registerControl,
|
|
286
|
+
registerError,
|
|
287
|
+
notifyChange
|
|
288
|
+
]);
|
|
289
|
+
const element$ = useRender({
|
|
290
|
+
defaultElement: "div",
|
|
291
|
+
render,
|
|
292
|
+
state: memoState,
|
|
293
|
+
className,
|
|
294
|
+
style,
|
|
295
|
+
children,
|
|
296
|
+
props: rest,
|
|
297
|
+
libraryProps: fieldDataAttributes(memoState)
|
|
298
|
+
});
|
|
299
|
+
return /* @__PURE__ */ jsx(FieldContext, {
|
|
300
|
+
value: context,
|
|
301
|
+
children: element$
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
FieldRoot.displayName = "FieldRoot";
|
|
305
|
+
/** The four state attributes every Field part renders (design 6.1). */
|
|
306
|
+
function fieldDataAttributes(state) {
|
|
307
|
+
return {
|
|
308
|
+
"data-invalid": dataAttr(state.invalid),
|
|
309
|
+
"data-disabled": dataAttr(state.disabled),
|
|
310
|
+
"data-required": dataAttr(state.required),
|
|
311
|
+
"data-pending": dataAttr(state.pending)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
//#endregion
|
|
315
|
+
export { FieldRoot, fieldDataAttributes };
|
|
316
|
+
|
|
317
|
+
//# sourceMappingURL=field-root.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"field-root.mjs","names":[],"sources":["../../src/field/field-root.tsx"],"sourcesContent":["\"use client\";\nimport {\n use,\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport type { ComponentPropsWithRef, CSSProperties, ReactNode, RefObject } from \"react\";\nimport { useFormStatus } from \"react-dom\";\nimport { dataAttr } from \"../internal/data-attr.js\";\nimport { FieldContext, FormContext } from \"../internal/field-context.js\";\nimport type {\n FieldContextValue,\n FieldControlRegistration,\n FieldState,\n FormFieldRegistration,\n} from \"../internal/field-context.js\";\nimport { collectInvalid } from \"../internal/focus-first-invalid.js\";\nimport type { RenderProp, StateValue } from \"../internal/types.js\";\nimport { useDevWarning } from \"../internal/use-dev-warning.js\";\nimport { useFormOwnerEvent } from \"../internal/use-form-owner-event.js\";\nimport { useLatest } from \"../internal/use-latest.js\";\nimport { useRender } from \"../internal/use-render.js\";\nimport {\n arraysEqual,\n focusTargetOf,\n normaliseErrorValue,\n snapshotValidity,\n supportsUserInvalid,\n VALID,\n validityElementOf,\n validityEquals,\n} from \"../internal/validity.js\";\nimport type { FieldErrorValue, FieldValidity, ValidatableElement } from \"../internal/validity.js\";\n\nexport type { FieldState } from \"../internal/field-context.js\";\nexport type { FieldValidity } from \"../internal/validity.js\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\n/**\n * A custom constraint: receives the field's submitted value (`formData.get(name)` as a string,\n * `\"\"` when absent) and the whole form's `FormData`. Return a message (or several) for an error,\n * `null` / `undefined` / `\"\"` for none.\n */\nexport type FieldValidate = (value: string, formData: FormData) => FieldErrorValue;\n\nexport interface FieldRootProps extends Omit<\n ComponentPropsWithRef<\"div\">,\n \"className\" | \"style\" | \"children\"\n> {\n /** Form field name: the control's default `name`, the key in `Form.Root errors`, and the entry `validate` reads. */\n name?: string;\n /** Disables the control (native `disabled`) and marks every part `data-disabled`. */\n disabled?: boolean;\n /** Makes the control `required` (native) and marks every part `data-required`. */\n required?: boolean;\n /**\n * A custom constraint, written into the control with `setCustomValidity`. Sync. Receives the\n * field's submitted value (`formData.get(name)` as a string, `\"\"` when absent) and the whole\n * form's `FormData`, so a cross-field rule can read the other field. Runs at mount, on every\n * `input` / `change` in the form (and a FUNSTACK control's value change), on blur, after a\n * reset, on a submission attempt, and in every commit in which this prop changed identity, so\n * a rule that reads outside state (a list that loads, a sibling value in React state) updates\n * on render. An inline function therefore re-runs on every render of the Field; wrap it in\n * `useCallback` to re-run only when its inputs change. Return a string\n * or strings for an error, `null` / `undefined` / `\"\"` for none. Shown when the platform's\n * `:user-invalid` heuristic says so, like a native constraint. Not serialisable: a Field with\n * `validate` is rendered from a client component.\n */\n validate?: FieldValidate;\n className?: StateValue<string | undefined, FieldState>;\n style?: StateValue<CSSProperties | undefined, FieldState>;\n children?: StateValue<ReactNode, FieldState>;\n render?: RenderProp<ComponentPropsWithRef<\"div\">, FieldState>;\n}\n\n/** The urgent mirror of browser-owned validity (design 2.4). */\ninterface Mirror {\n readonly userInvalid: boolean;\n readonly validity: FieldValidity;\n readonly messages: readonly string[];\n}\n\nconst NONE: readonly string[] = Object.freeze([]);\nconst INITIAL_MIRROR: Mirror = { userInvalid: false, validity: VALID, messages: NONE };\n\nfunction mirrorEquals(a: Mirror, b: Mirror): boolean {\n return (\n a.userInvalid === b.userInvalid &&\n validityEquals(a.validity, b.validity) &&\n arraysEqual(a.messages, b.messages)\n );\n}\n\n/** `FormData` entries as comparable strings; a `File` counts as unchanged (design 3.2 item 2). */\nfunction entryStrings(entries: readonly FormDataEntryValue[]): string[] {\n return entries.map((entry) => (typeof entry === \"string\" ? entry : \"\\u0000file\"));\n}\n\ninterface RefreshOptions {\n /** The user edited the control: the server message is dropped from the slot at once. */\n readonly dismiss?: boolean;\n /** After a form reset: the user-validity flag is clear again. */\n readonly reset?: boolean;\n}\n\n/**\n * The field: a `div` that owns the ids, the urgent validity mirror, the custom-validity slot\n * (`validate` and the server error), the control registration and the native listeners\n * (design/components/field-form.md 2, 14). `data-pending` is the enclosing form's submission\n * (`useFormStatus`), the one part that folds the form channel into its own state, because a\n * Field has no action of its own.\n *\n * Owned keys (conventions 1.2): `data-invalid`, `data-disabled`, `data-required`, `data-pending`.\n */\nexport function FieldRoot({\n name,\n disabled = false,\n required = false,\n validate,\n className,\n style,\n children,\n render,\n ...rest\n}: FieldRootProps) {\n const parent = use(FieldContext);\n const form = use(FormContext);\n useDevWarning(\n parent !== null,\n \"Field.Root: nested inside another Field.Root. A control registers with the nearest Field \" +\n \"only; give each control its own, sibling Field.\",\n );\n useDevWarning(\n validate !== undefined && name === undefined,\n \"Field.Root: `validate` without `name`. `validate` receives `formData.get(name)`, which is \" +\n 'always \"\" without a name.',\n );\n\n const id = useId();\n const ids = useMemo(\n () => ({\n control: `${id}-control`,\n label: `${id}-label`,\n description: `${id}-description`,\n error: `${id}-error`,\n }),\n [id],\n );\n\n const { pending: formPending } = useFormStatus();\n\n // --- Registration -------------------------------------------------------------------------\n\n const controlRef = useRef<FieldControlRegistration | null>(null);\n const [control, setControl] = useState<FieldControlRegistration | null>(null);\n const warnedTwoControls = useRef(false);\n const registerControl = useCallback((registration: FieldControlRegistration) => {\n const current = controlRef.current;\n if (current !== null && current.element !== registration.element) {\n if (isDev && !warnedTwoControls.current) {\n warnedTwoControls.current = true;\n console.error(\n \"Field.Root: two form controls registered in one Field. The first one is the \" +\n \"Field's control; give each control its own Field.Root.\",\n );\n }\n return () => {};\n }\n controlRef.current = registration;\n setControl(registration);\n return () => {\n if (controlRef.current !== registration) return;\n controlRef.current = null;\n setControl(null);\n };\n }, []);\n\n const errorCount = useRef(0);\n const registerError = useCallback(() => {\n errorCount.current += 1;\n if (isDev && errorCount.current === 2) {\n console.error(\n \"Field.Error: two Field.Error parts in one Field.Root. Render one; its function \" +\n \"`children` receives `validity` for per-constraint wording.\",\n );\n }\n return () => {\n errorCount.current -= 1;\n };\n }, []);\n\n // --- Server errors: shown, dismissed, re-armed, suppressed (design 3.4) -------------------\n\n const errors = form?.errors;\n const serverMessages = useMemo(\n () => normaliseErrorValue(name === undefined ? undefined : errors?.[name]),\n [errors, name],\n );\n const serverKey = serverMessages.join(\"\\n\");\n /** The user edited the control since the last settled submission. */\n const [dismissed, setDismissed] = useState(false);\n /**\n * The errors shown now answer a value the user had already changed during their flight\n * (intercepted path). Fixed from one settle to the next, so a hidden stale error stays hidden\n * while the next submission is in flight (the `errors` prop still holds that result).\n */\n const [suppressed, setSuppressed] = useState(false);\n /** The user changed the value during the flight in progress: becomes `suppressed` at its settle. */\n const [suppressNext, setSuppressNext] = useState(false);\n // The settle edge of the enclosing form, seen in the render that commits the result (the\n // action's result and `useFormStatus`'s NotPending land together), so the next submission's\n // errors are shown in that same commit, before the Form's settle effect focuses them.\n const [previousFormPending, setPreviousFormPending] = useState(formPending);\n if (previousFormPending !== formPending) {\n setPreviousFormPending(formPending);\n if (formPending) {\n setSuppressNext(false);\n } else {\n setDismissed(false);\n setSuppressed(suppressNext);\n setSuppressNext(false);\n }\n }\n const serverShown = serverMessages.length > 0 && !dismissed && !suppressed;\n\n // --- The mirror -----------------------------------------------------------------------------\n\n const [mirror, setMirror] = useState<Mirror>(INITIAL_MIRROR);\n /** The last mirror written: `refresh` compares against it, so an unchanged read renders nothing. */\n const mirrorRef = useRef<Mirror>(INITIAL_MIRROR);\n /** jsdom fallback for `:user-invalid` (design 2.2): `invalid` fired, cleared by a reset. */\n const attempted = useRef(false);\n /** The element the slot was last written on (a group's first member can change). */\n const slotElement = useRef<ValidatableElement | null>(null);\n\n const latest = useLatest({ name, validate, serverMessages, serverShown, formPending, form });\n\n /** Re-reads the slot and the mirror; returns the messages the Field shows for the control. */\n const refresh = useCallback(\n (options: RefreshOptions = {}): readonly string[] => {\n const registration = controlRef.current;\n if (registration === null) return NONE;\n const el = validityElementOf(registration.element);\n if (el === null) return NONE;\n const { name, validate, serverMessages, serverShown, formPending, form } = latest.current;\n\n const owner = el.form;\n const formData = owner === null ? new FormData() : new FormData(owner);\n const entry = name === undefined ? null : formData.get(name);\n const value = typeof entry === \"string\" ? entry : \"\";\n const custom = validate === undefined ? NONE : normaliseErrorValue(validate(value, formData));\n\n // The slot (design 2.1): the server message while shown, else `validate`'s.\n const showServer = serverShown && options.dismiss !== true;\n if (slotElement.current !== null && slotElement.current !== el) {\n slotElement.current.setCustomValidity(\"\");\n }\n slotElement.current = el;\n el.setCustomValidity(showServer ? serverMessages.join(\"\\n\") : custom.join(\"\\n\"));\n\n if (options.reset === true) attempted.current = false;\n const userInvalid = supportsUserInvalid()\n ? el.matches(\":user-invalid\")\n : attempted.current && !el.validity.valid;\n const validity = snapshotValidity(el.validity);\n const messages = validity.valid\n ? NONE\n : validity.customError && !showServer && custom.length > 0\n ? custom\n : el.validationMessage === \"\"\n ? NONE\n : [el.validationMessage];\n const next: Mirror = { userInvalid, validity, messages };\n // Only a change schedules a render (a `focusout` with nothing new causes none).\n if (!mirrorEquals(mirrorRef.current, next)) {\n mirrorRef.current = next;\n setMirror(next);\n }\n\n // Stale-value suppression (design 3.2 item 2): while the Form's own submission is in\n // flight, an error is about the value it sent; compare with what the field holds now.\n const submitted = formPending && name !== undefined ? (form?.submittedData() ?? null) : null;\n if (submitted !== null && name !== undefined) {\n setSuppressNext(\n !arraysEqual(entryStrings(formData.getAll(name)), entryStrings(submitted.getAll(name))),\n );\n }\n return showServer ? serverMessages : messages;\n },\n [latest],\n );\n\n // The slot follows the server error and `validate` (and is written once the control is known).\n // Keyed on `validate`'s identity (design 2.1): a rule reading outside state updates in the\n // commit that changed it. An inline function re-runs on every render (documented: `useCallback`\n // avoids it); the form's own values are covered by the listeners below.\n useLayoutEffect(() => {\n refresh();\n }, [refresh, control, serverShown, serverKey, validate]);\n\n // Clear the slot when the control goes away (it may be reused outside the Field).\n useLayoutEffect(\n () => () => {\n slotElement.current?.setCustomValidity(\"\");\n slotElement.current = null;\n },\n [],\n );\n\n // The control's own events (capture, so a group's members are seen on its fieldset).\n const element = control?.element ?? null;\n useLayoutEffect(() => {\n if (element === null) return;\n const onEdit = () => {\n setDismissed(true);\n refresh({ dismiss: true });\n };\n const onBlur = () => refresh();\n const onInvalid = (event: Event) => {\n const target = event.target as ValidatableElement;\n // A submission attempt sets user validity before validating; `checkValidity()` and\n // `reportValidity()` do not (design 2.5). The fallback cannot tell them apart.\n const attempt = supportsUserInvalid() ? target.matches(\":user-invalid\") : true;\n if (!supportsUserInvalid()) attempted.current = true;\n if (attempt && errorCount.current > 0) {\n event.preventDefault();\n const messages = refresh();\n collectInvalid(focusTargetOf(element), messages.join(\" \"));\n return;\n }\n refresh();\n };\n element.addEventListener(\"input\", onEdit, true);\n element.addEventListener(\"change\", onEdit, true);\n element.addEventListener(\"focusout\", onBlur, true);\n element.addEventListener(\"invalid\", onInvalid, true);\n return () => {\n element.removeEventListener(\"input\", onEdit, true);\n element.removeEventListener(\"change\", onEdit, true);\n element.removeEventListener(\"focusout\", onBlur, true);\n element.removeEventListener(\"invalid\", onInvalid, true);\n };\n }, [element, refresh]);\n\n // The form owner's events: another field's edit re-runs `validate` (cross-field rules); a reset\n // clears the user-validity flag, re-read when the controls hold their reset values.\n const ownerRef = useMemo<RefObject<Element | null>>(\n () => ({\n get current() {\n return controlRef.current?.element ?? null;\n },\n set current(_ignored) {},\n }),\n [],\n );\n const onOwnerEdit = (event: Event) => {\n const target = event.target;\n if (target instanceof Node && controlRef.current?.element.contains(target) === true) return;\n refresh();\n };\n useFormOwnerEvent(ownerRef, \"input\", onOwnerEdit);\n useFormOwnerEvent(ownerRef, \"change\", onOwnerEdit);\n // A new submission sends the current values: nothing differs from it yet (a queued second\n // submit arrives while the status stays pending, so the pending edge alone would miss it).\n useFormOwnerEvent(ownerRef, \"submit\", () => {\n setSuppressNext(false);\n });\n useFormOwnerEvent(ownerRef, \"reset\", (event) => {\n queueMicrotask(() => {\n if (event.defaultPrevented) return;\n refresh({ reset: true });\n });\n });\n\n // Registration with the Form, for the first-error focus after a settle (design 3.5) and the\n // refresh of the other Fields on a change no DOM event carried.\n const registerField = form?.registerField;\n const formRegistration = useRef<FormFieldRegistration | null>(null);\n useLayoutEffect(() => {\n if (registerField === undefined) return;\n const registration: FormFieldRegistration = {\n getFocusTarget: () => focusTargetOf(controlRef.current?.element ?? null),\n showsServerError: () => latest.current.serverShown,\n serverMessages: () => latest.current.serverMessages,\n refresh: () => {\n refresh();\n },\n };\n formRegistration.current = registration;\n const unregister = registerField(registration);\n return () => {\n unregister();\n if (formRegistration.current === registration) formRegistration.current = null;\n };\n }, [registerField, latest, refresh]);\n\n const notifyChange = useCallback(\n (edit: boolean) => {\n if (edit) setDismissed(true);\n refresh(edit ? { dismiss: true } : {});\n latest.current.form?.refreshFields(formRegistration.current);\n },\n [refresh, latest],\n );\n\n // Development: a Field around something that is not a form control (Toggle, Menu, ...).\n useEffect(() => {\n if (!isDev || controlRef.current !== null) return;\n console.error(\n \"Field.Root: no form control registered. Render a Field.Control or a FUNSTACK form \" +\n \"control (Checkbox, Switch, RadioGroup, CheckboxGroup, Select, Combobox) inside it.\",\n );\n }, []);\n\n const invalid = mirror.userInvalid || serverShown;\n const messages = serverShown ? serverMessages : mirror.userInvalid ? mirror.messages : NONE;\n const validity = mirror.validity;\n const stateDisabled = disabled || (control?.disabled ?? false);\n const stateRequired = required || (control?.required ?? false);\n // Memoised: the context value (and so every part) changes only with the state.\n const memoState = useMemo<FieldState>(\n () => ({\n invalid,\n messages,\n validity,\n disabled: stateDisabled,\n required: stateRequired,\n pending: formPending,\n }),\n [invalid, messages, validity, stateDisabled, stateRequired, formPending],\n );\n\n const labelable = control?.labelable ?? true;\n const context = useMemo<FieldContextValue>(\n () => ({\n ids,\n name,\n disabled,\n required,\n state: memoState,\n labelable,\n registerControl,\n registerError,\n notifyChange,\n }),\n [\n ids,\n name,\n disabled,\n required,\n memoState,\n labelable,\n registerControl,\n registerError,\n notifyChange,\n ],\n );\n\n const element$ = useRender<ComponentPropsWithRef<\"div\">, FieldState>({\n defaultElement: \"div\",\n render,\n state: memoState,\n className,\n style,\n children,\n props: rest,\n libraryProps: fieldDataAttributes(memoState),\n });\n\n return <FieldContext value={context}>{element$}</FieldContext>;\n}\n\nFieldRoot.displayName = \"FieldRoot\";\n\n/** The four state attributes every Field part renders (design 6.1). */\nexport function fieldDataAttributes(state: FieldState) {\n return {\n \"data-invalid\": dataAttr(state.invalid),\n \"data-disabled\": dataAttr(state.disabled),\n \"data-required\": dataAttr(state.required),\n \"data-pending\": dataAttr(state.pending),\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { FieldValidity } from "../internal/validity.mjs";
|
|
2
|
+
import { FieldState } from "../internal/field-context.mjs";
|
|
3
|
+
import { FieldControl, FieldControlProps } from "./field-control.mjs";
|
|
4
|
+
import { FieldDescription, FieldDescriptionProps } from "./field-description.mjs";
|
|
5
|
+
import { FieldError, FieldErrorProps } from "./field-error.mjs";
|
|
6
|
+
import { FieldLabel, FieldLabelProps } from "./field-label.mjs";
|
|
7
|
+
import { FieldRoot, FieldRootProps, FieldValidate } from "./field-root.mjs";
|
|
8
|
+
//#region src/field/index.d.ts
|
|
9
|
+
export declare const Field: {
|
|
10
|
+
readonly Root: typeof FieldRoot;
|
|
11
|
+
readonly Label: typeof FieldLabel;
|
|
12
|
+
readonly Control: typeof FieldControl;
|
|
13
|
+
readonly Description: typeof FieldDescription;
|
|
14
|
+
readonly Error: typeof FieldError;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export type { FieldControlProps, FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldRootProps, FieldState, FieldValidate, FieldValidity };
|
|
18
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/field/index.ts"],"mappings":";;;;;;;;qBAMa;WACX,aAAI;WACJ,cAAK;WACL,gBAAO;WACP,oBAAW;WACX,cAAK"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { FieldRoot } from "./field-root.mjs";
|
|
2
|
+
import { FieldControl } from "./field-control.mjs";
|
|
3
|
+
import { FieldDescription } from "./field-description.mjs";
|
|
4
|
+
import { FieldError } from "./field-error.mjs";
|
|
5
|
+
import { FieldLabel } from "./field-label.mjs";
|
|
6
|
+
//#region src/field/index.ts
|
|
7
|
+
const Field = {
|
|
8
|
+
Root: FieldRoot,
|
|
9
|
+
Label: FieldLabel,
|
|
10
|
+
Control: FieldControl,
|
|
11
|
+
Description: FieldDescription,
|
|
12
|
+
Error: FieldError
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { Field };
|
|
16
|
+
|
|
17
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/field/index.ts"],"sourcesContent":["import { FieldControl } from \"./field-control.js\";\nimport { FieldDescription } from \"./field-description.js\";\nimport { FieldError } from \"./field-error.js\";\nimport { FieldLabel } from \"./field-label.js\";\nimport { FieldRoot } from \"./field-root.js\";\n\nexport const Field = {\n Root: FieldRoot,\n Label: FieldLabel,\n Control: FieldControl,\n Description: FieldDescription,\n Error: FieldError,\n} as const;\n\nexport type { FieldRootProps, FieldState, FieldValidity, FieldValidate } from \"./field-root.js\";\nexport type { FieldLabelProps } from \"./field-label.js\";\nexport type { FieldControlProps } from \"./field-control.js\";\nexport type { FieldDescriptionProps } from \"./field-description.js\";\nexport type { FieldErrorProps } from \"./field-error.js\";\n"],"mappings":";;;;;;AAMA,MAAa,QAAQ;CACnB,MAAM;CACN,OAAO;CACP,SAAS;CACT,aAAa;CACb,OAAO;AACT"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { RenderProp, StateValue } from "../internal/types.mjs";
|
|
2
|
+
import { FieldErrorValue, FormErrors } from "../internal/validity.mjs";
|
|
3
|
+
import { CSSProperties, ComponentPropsWithRef, ReactNode } from "react";
|
|
4
|
+
//#region src/form/form-root.d.ts
|
|
5
|
+
export interface FormRootState {
|
|
6
|
+
/** A submission is in flight: the Form's own transition, or the form's status (`useFormStatus`), including the Suspense wait of the result. */
|
|
7
|
+
readonly pending: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface FormRootProps extends Omit<ComponentPropsWithRef<"form">, "onSubmit" | "method" | "action" | "className" | "style" | "children"> {
|
|
10
|
+
/**
|
|
11
|
+
* React form action (`(formData) => unknown`: a Server Function, a `useActionState` dispatch,
|
|
12
|
+
* or a client function), rendered on the form unwrapped so React's progressive enhancement is
|
|
13
|
+
* kept, and run by the Form inside its own transition after hydration (no automatic reset; see
|
|
14
|
+
* `autoReset`). A string is a navigating form. A submitter with its own `formAction` is left to
|
|
15
|
+
* React (automatic reset applies to it).
|
|
16
|
+
*/
|
|
17
|
+
action?: string | ((formData: FormData) => unknown);
|
|
18
|
+
/**
|
|
19
|
+
* Field errors keyed by field `name` (usually `state.errors` from `useActionState`). Each
|
|
20
|
+
* `Field.Root` with that name shows its entry, writes it into the control's custom validity,
|
|
21
|
+
* and clears it on the user's next edit; the next settled submission shows the new entries.
|
|
22
|
+
* Errors present at mount are shown without moving focus.
|
|
23
|
+
*/
|
|
24
|
+
errors?: FormErrors;
|
|
25
|
+
/**
|
|
26
|
+
* Reset the form (`form.reset()`) when a submission the Form ran settles with no non-empty
|
|
27
|
+
* entry in `errors`. Default `true`. A submission that reports errors never resets, so the
|
|
28
|
+
* user's input, `:user-invalid` and custom validity stay. `false`: never reset.
|
|
29
|
+
*/
|
|
30
|
+
autoReset?: boolean;
|
|
31
|
+
/** Only with a string `action`. Owned otherwise (React's own with a function action). */
|
|
32
|
+
method?: ComponentPropsWithRef<"form">["method"];
|
|
33
|
+
className?: StateValue<string | undefined, FormRootState>;
|
|
34
|
+
style?: StateValue<CSSProperties | undefined, FormRootState>;
|
|
35
|
+
children?: StateValue<ReactNode, FormRootState>;
|
|
36
|
+
/** Must render a `<form>`. */
|
|
37
|
+
render?: RenderProp<ComponentPropsWithRef<"form">, FormRootState>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A `<form>` that runs its function `action` itself after hydration (design/components/field-form.md
|
|
41
|
+
* 3): `onSubmit` cancels the native submission, so react-dom neither runs the action nor schedules
|
|
42
|
+
* its automatic reset, and the Form awaits `action(formData)` inside its own transition, which
|
|
43
|
+
* still starts the form's status (`useFormStatus`). The action stays rendered unwrapped on the
|
|
44
|
+
* element, so a Server Function posts before hydration.
|
|
45
|
+
*
|
|
46
|
+
* When a submission settles, the Form focuses the first Field that shows a server error (from
|
|
47
|
+
* `errors`), or, when `errors` has no entry and `autoReset` is on, resets the form.
|
|
48
|
+
*
|
|
49
|
+
* Owned keys (conventions 1.2): `action`, `method` (except with a string `action`), `onSubmit`,
|
|
50
|
+
* `data-pending`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function FormRoot({ action, errors, autoReset, method, className, style, children, render, ...rest }: FormRootProps): import("react").JSX.Element;
|
|
53
|
+
export declare namespace FormRoot {
|
|
54
|
+
var displayName: string;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export type { FieldErrorValue, FormErrors };
|
|
58
|
+
//# sourceMappingURL=form-root.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"form-root.d.mts","names":[],"sources":["../../src/form/form-root.tsx"],"mappings":";;;;iBAkBiB;;WAEN;;iBAGM,sBAAsB,KACrC;;;;;;;;EAUA,oBAAoB,UAAU;;;;;;;EAO9B,SAAS;;;;;;EAMT;;EAEA,SAAS;EACT,YAAY,+BAA+B;EAC3C,QAAQ,WAAW,2BAA2B;EAC9C,WAAW,WAAW,WAAW;;EAEjC,SAAS,WAAW,+BAA+B;;;;;;;;;;;;;;;wBAsBrC,WACd,QACA,QACA,WACA,QACA,WACA,OACA,UACA,WACG,QACF,gCAAa,IAAA;yBAVA"}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { dataAttr } from "../internal/data-attr.mjs";
|
|
3
|
+
import { useLatest } from "../internal/use-latest.mjs";
|
|
4
|
+
import { resolveStateValue, useRender } from "../internal/use-render.mjs";
|
|
5
|
+
import { FormContext } from "../internal/field-context.mjs";
|
|
6
|
+
import { FormStatusWatcher } from "../internal/form-status-watcher.mjs";
|
|
7
|
+
import { hasAnyError, sortByDocumentOrder } from "../internal/validity.mjs";
|
|
8
|
+
import { announce } from "../internal/focus-first-invalid.mjs";
|
|
9
|
+
import { useCallback, useMemo, useRef, useState, useTransition } from "react";
|
|
10
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
11
|
+
//#region src/form/form-root.tsx
|
|
12
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
13
|
+
/**
|
|
14
|
+
* A `<form>` that runs its function `action` itself after hydration (design/components/field-form.md
|
|
15
|
+
* 3): `onSubmit` cancels the native submission, so react-dom neither runs the action nor schedules
|
|
16
|
+
* its automatic reset, and the Form awaits `action(formData)` inside its own transition, which
|
|
17
|
+
* still starts the form's status (`useFormStatus`). The action stays rendered unwrapped on the
|
|
18
|
+
* element, so a Server Function posts before hydration.
|
|
19
|
+
*
|
|
20
|
+
* When a submission settles, the Form focuses the first Field that shows a server error (from
|
|
21
|
+
* `errors`), or, when `errors` has no entry and `autoReset` is on, resets the form.
|
|
22
|
+
*
|
|
23
|
+
* Owned keys (conventions 1.2): `action`, `method` (except with a string `action`), `onSubmit`,
|
|
24
|
+
* `data-pending`.
|
|
25
|
+
*/
|
|
26
|
+
function FormRoot({ action, errors, autoReset = true, method, className, style, children, render, ...rest }) {
|
|
27
|
+
const [isPending, startTransition] = useTransition();
|
|
28
|
+
const [statusPending, setStatusPending] = useState(false);
|
|
29
|
+
const pending = isPending || statusPending;
|
|
30
|
+
const formRef = useRef(null);
|
|
31
|
+
const submission = useRef(null);
|
|
32
|
+
const fields = useRef(/* @__PURE__ */ new Set());
|
|
33
|
+
const warnedElement = useRef(false);
|
|
34
|
+
const latest = useLatest({
|
|
35
|
+
errors,
|
|
36
|
+
autoReset
|
|
37
|
+
});
|
|
38
|
+
const isFunction = typeof action === "function";
|
|
39
|
+
const libraryRef = useCallback((form) => {
|
|
40
|
+
formRef.current = form;
|
|
41
|
+
if (isDev && !warnedElement.current && form.tagName !== "FORM") {
|
|
42
|
+
warnedElement.current = true;
|
|
43
|
+
console.error(`Form.Root: expected to render a <form> element but got <${form.tagName.toLowerCase()}>.`);
|
|
44
|
+
}
|
|
45
|
+
const onSubmit = (event) => {
|
|
46
|
+
if (event.target !== form) return;
|
|
47
|
+
submission.current = {
|
|
48
|
+
intercepted: false,
|
|
49
|
+
formData: null
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
form.addEventListener("submit", onSubmit, true);
|
|
53
|
+
return () => {
|
|
54
|
+
form.removeEventListener("submit", onSubmit, true);
|
|
55
|
+
if (formRef.current === form) formRef.current = null;
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
const onSubmit = (event) => {
|
|
59
|
+
if (typeof action !== "function") return;
|
|
60
|
+
if (event.defaultPrevented) {
|
|
61
|
+
submission.current = null;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const submitter = event.nativeEvent.submitter;
|
|
65
|
+
if (submitter?.hasAttribute("formaction") === true) return;
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
const formData = new FormData(event.currentTarget, submitter);
|
|
68
|
+
submission.current = {
|
|
69
|
+
intercepted: true,
|
|
70
|
+
formData
|
|
71
|
+
};
|
|
72
|
+
startTransition(async () => {
|
|
73
|
+
await action(formData);
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const registerField = useCallback((registration) => {
|
|
77
|
+
fields.current.add(registration);
|
|
78
|
+
return () => {
|
|
79
|
+
fields.current.delete(registration);
|
|
80
|
+
};
|
|
81
|
+
}, []);
|
|
82
|
+
const onSettled = useCallback(() => {
|
|
83
|
+
const settled = submission.current;
|
|
84
|
+
submission.current = null;
|
|
85
|
+
const form = formRef.current;
|
|
86
|
+
if (form === null) return;
|
|
87
|
+
const shown = [...fields.current].filter((field) => field.showsServerError()).map((field) => ({
|
|
88
|
+
field,
|
|
89
|
+
target: field.getFocusTarget()
|
|
90
|
+
})).filter((entry) => entry.target !== null && entry.target.isConnected);
|
|
91
|
+
if (shown.length > 0) {
|
|
92
|
+
const first = sortByDocumentOrder(shown, (entry) => entry.target)[0];
|
|
93
|
+
if (first.target.ownerDocument.activeElement === first.target) announce(first.target, first.field.serverMessages().join(" "));
|
|
94
|
+
else first.target.focus();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const { errors, autoReset } = latest.current;
|
|
98
|
+
if (autoReset && settled?.intercepted === true && !hasAnyError(errors)) form.reset();
|
|
99
|
+
}, [latest]);
|
|
100
|
+
const submittedData = useCallback(() => {
|
|
101
|
+
const current = submission.current;
|
|
102
|
+
return current?.intercepted === true ? current.formData : null;
|
|
103
|
+
}, []);
|
|
104
|
+
const refreshFields = useCallback((source) => {
|
|
105
|
+
for (const field of fields.current) if (field !== source) field.refresh();
|
|
106
|
+
}, []);
|
|
107
|
+
const context = useMemo(() => ({
|
|
108
|
+
errors,
|
|
109
|
+
registerField,
|
|
110
|
+
submittedData,
|
|
111
|
+
refreshFields
|
|
112
|
+
}), [
|
|
113
|
+
errors,
|
|
114
|
+
registerField,
|
|
115
|
+
submittedData,
|
|
116
|
+
refreshFields
|
|
117
|
+
]);
|
|
118
|
+
const element = useRender({
|
|
119
|
+
defaultElement: "form",
|
|
120
|
+
render,
|
|
121
|
+
state: { pending },
|
|
122
|
+
className,
|
|
123
|
+
style,
|
|
124
|
+
children: (s) => /* @__PURE__ */ jsxs(Fragment, { children: [resolveStateValue(children, s), isFunction && /* @__PURE__ */ jsx(FormStatusWatcher, {
|
|
125
|
+
onPendingChange: setStatusPending,
|
|
126
|
+
onSettled
|
|
127
|
+
})] }),
|
|
128
|
+
props: rest,
|
|
129
|
+
libraryProps: {
|
|
130
|
+
method: typeof action === "string" ? method : void 0,
|
|
131
|
+
action,
|
|
132
|
+
onSubmit: isFunction ? onSubmit : void 0,
|
|
133
|
+
"data-pending": dataAttr(pending)
|
|
134
|
+
},
|
|
135
|
+
ref: libraryRef
|
|
136
|
+
});
|
|
137
|
+
return /* @__PURE__ */ jsx(FormContext, {
|
|
138
|
+
value: context,
|
|
139
|
+
children: element
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
FormRoot.displayName = "FormRoot";
|
|
143
|
+
//#endregion
|
|
144
|
+
export { FormRoot };
|
|
145
|
+
|
|
146
|
+
//# sourceMappingURL=form-root.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"form-root.mjs","names":[],"sources":["../../src/form/form-root.tsx"],"sourcesContent":["\"use client\";\nimport { useCallback, useMemo, useRef, useState, useTransition } from \"react\";\nimport type { ComponentPropsWithRef, CSSProperties, FormEvent, ReactNode } from \"react\";\nimport { dataAttr } from \"../internal/data-attr.js\";\nimport { FormContext } from \"../internal/field-context.js\";\nimport type { FormContextValue, FormFieldRegistration } from \"../internal/field-context.js\";\nimport { announce } from \"../internal/focus-first-invalid.js\";\nimport { FormStatusWatcher } from \"../internal/form-status-watcher.js\";\nimport type { RenderProp, StateValue } from \"../internal/types.js\";\nimport { useLatest } from \"../internal/use-latest.js\";\nimport { resolveStateValue, useRender } from \"../internal/use-render.js\";\nimport { hasAnyError, sortByDocumentOrder } from \"../internal/validity.js\";\nimport type { FormErrors } from \"../internal/validity.js\";\n\nexport type { FieldErrorValue, FormErrors } from \"../internal/validity.js\";\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\nexport interface FormRootState {\n /** A submission is in flight: the Form's own transition, or the form's status (`useFormStatus`), including the Suspense wait of the result. */\n readonly pending: boolean;\n}\n\nexport interface FormRootProps extends Omit<\n ComponentPropsWithRef<\"form\">,\n \"onSubmit\" | \"method\" | \"action\" | \"className\" | \"style\" | \"children\"\n> {\n /**\n * React form action (`(formData) => unknown`: a Server Function, a `useActionState` dispatch,\n * or a client function), rendered on the form unwrapped so React's progressive enhancement is\n * kept, and run by the Form inside its own transition after hydration (no automatic reset; see\n * `autoReset`). A string is a navigating form. A submitter with its own `formAction` is left to\n * React (automatic reset applies to it).\n */\n action?: string | ((formData: FormData) => unknown);\n /**\n * Field errors keyed by field `name` (usually `state.errors` from `useActionState`). Each\n * `Field.Root` with that name shows its entry, writes it into the control's custom validity,\n * and clears it on the user's next edit; the next settled submission shows the new entries.\n * Errors present at mount are shown without moving focus.\n */\n errors?: FormErrors;\n /**\n * Reset the form (`form.reset()`) when a submission the Form ran settles with no non-empty\n * entry in `errors`. Default `true`. A submission that reports errors never resets, so the\n * user's input, `:user-invalid` and custom validity stay. `false`: never reset.\n */\n autoReset?: boolean;\n /** Only with a string `action`. Owned otherwise (React's own with a function action). */\n method?: ComponentPropsWithRef<\"form\">[\"method\"];\n className?: StateValue<string | undefined, FormRootState>;\n style?: StateValue<CSSProperties | undefined, FormRootState>;\n children?: StateValue<ReactNode, FormRootState>;\n /** Must render a `<form>`. */\n render?: RenderProp<ComponentPropsWithRef<\"form\">, FormRootState>;\n}\n\ninterface Submission {\n /** The Form ran the action itself (`onSubmit` + its own transition). */\n readonly intercepted: boolean;\n readonly formData: FormData | null;\n}\n\n/**\n * A `<form>` that runs its function `action` itself after hydration (design/components/field-form.md\n * 3): `onSubmit` cancels the native submission, so react-dom neither runs the action nor schedules\n * its automatic reset, and the Form awaits `action(formData)` inside its own transition, which\n * still starts the form's status (`useFormStatus`). The action stays rendered unwrapped on the\n * element, so a Server Function posts before hydration.\n *\n * When a submission settles, the Form focuses the first Field that shows a server error (from\n * `errors`), or, when `errors` has no entry and `autoReset` is on, resets the form.\n *\n * Owned keys (conventions 1.2): `action`, `method` (except with a string `action`), `onSubmit`,\n * `data-pending`.\n */\nexport function FormRoot({\n action,\n errors,\n autoReset = true,\n method,\n className,\n style,\n children,\n render,\n ...rest\n}: FormRootProps) {\n const [isPending, startTransition] = useTransition();\n const [statusPending, setStatusPending] = useState(false);\n const pending = isPending || statusPending;\n\n const formRef = useRef<HTMLFormElement | null>(null);\n const submission = useRef<Submission | null>(null);\n const fields = useRef(new Set<FormFieldRegistration>());\n const warnedElement = useRef(false);\n const latest = useLatest({ errors, autoReset });\n const isFunction = typeof action === \"function\";\n\n // A native capture listener sees every submission, React's path included (a submitter with its\n // own `formAction`), before React's `onSubmit` runs and marks it intercepted.\n const libraryRef = useCallback((form: HTMLFormElement) => {\n formRef.current = form;\n if (isDev && !warnedElement.current && form.tagName !== \"FORM\") {\n warnedElement.current = true;\n console.error(\n `Form.Root: expected to render a <form> element but got <${form.tagName.toLowerCase()}>.`,\n );\n }\n const onSubmit = (event: Event) => {\n if (event.target !== form) return;\n submission.current = { intercepted: false, formData: null };\n };\n form.addEventListener(\"submit\", onSubmit, true);\n return () => {\n form.removeEventListener(\"submit\", onSubmit, true);\n if (formRef.current === form) formRef.current = null;\n };\n }, []);\n\n const onSubmit = (event: FormEvent<HTMLFormElement>) => {\n if (typeof action !== \"function\") return; // a navigating form: the browser's\n // A `render` element's own `onSubmit` (merged, it runs first) vetoed the submission\n // (conventions: a `preventDefault()` in the consumer's handler cancels the library's work).\n if (event.defaultPrevented) {\n submission.current = null;\n return;\n }\n const native = event.nativeEvent as SubmitEvent;\n const submitter = native.submitter;\n // A per-button action: React's own path (and its automatic reset).\n if (submitter?.hasAttribute(\"formaction\") === true) return;\n // react-dom now skips its action run and its reset, and still starts the form's status.\n event.preventDefault();\n const formData = new FormData(event.currentTarget, submitter);\n submission.current = { intercepted: true, formData };\n startTransition(async () => {\n await action(formData);\n });\n };\n\n const registerField = useCallback((registration: FormFieldRegistration) => {\n fields.current.add(registration);\n return () => {\n fields.current.delete(registration);\n };\n }, []);\n\n // The settle (design 3.2): the form's status went `true → false`. The Fields rendered the\n // result's errors in that commit; this effect runs after it.\n const onSettled = useCallback(() => {\n const settled = submission.current;\n submission.current = null;\n const form = formRef.current;\n if (form === null) return;\n const shown = [...fields.current]\n .filter((field) => field.showsServerError())\n .map((field) => ({ field, target: field.getFocusTarget() }))\n .filter(\n (entry): entry is { field: FormFieldRegistration; target: HTMLElement } =>\n entry.target !== null && entry.target.isConnected,\n );\n if (shown.length > 0) {\n const first = sortByDocumentOrder(shown, (entry) => entry.target)[0]!;\n // Focus already there (the user pressed Enter in that field): `focus()` does nothing and\n // nothing reads the new description, so the message is announced instead (design 5.3).\n if (first.target.ownerDocument.activeElement === first.target) {\n announce(first.target, first.field.serverMessages().join(\" \"));\n } else {\n first.target.focus();\n }\n return;\n }\n const { errors, autoReset } = latest.current;\n if (autoReset && settled?.intercepted === true && !hasAnyError(errors)) form.reset();\n }, [latest]);\n\n const submittedData = useCallback(() => {\n const current = submission.current;\n return current?.intercepted === true ? current.formData : null;\n }, []);\n\n const refreshFields = useCallback((source: FormFieldRegistration | null) => {\n for (const field of fields.current) if (field !== source) field.refresh();\n }, []);\n\n const context = useMemo<FormContextValue>(\n () => ({ errors, registerField, submittedData, refreshFields }),\n [errors, registerField, submittedData, refreshFields],\n );\n\n const state: FormRootState = { pending };\n\n const element = useRender<ComponentPropsWithRef<\"form\">, FormRootState>({\n defaultElement: \"form\",\n render,\n state,\n className,\n style,\n children: (s) => (\n <>\n {resolveStateValue(children, s)}\n {isFunction && (\n <FormStatusWatcher onPendingChange={setStatusPending} onSettled={onSettled} />\n )}\n </>\n ),\n props: rest,\n libraryProps: {\n method: typeof action === \"string\" ? method : undefined,\n // React's own type wants `void | Promise<void>`; the library's actions return `unknown`\n // (conventions 2.2) and React ignores the result.\n action: action as ComponentPropsWithRef<\"form\">[\"action\"],\n onSubmit: isFunction ? onSubmit : undefined,\n \"data-pending\": dataAttr(pending),\n },\n ref: libraryRef,\n });\n\n return <FormContext value={context}>{element}</FormContext>;\n}\n\nFormRoot.displayName = \"FormRoot\";\n"],"mappings":"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"}
|