@octanejs/radix 0.1.2
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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +45 -0
- package/src/AccessibleIcon.ts +26 -0
- package/src/Accordion.ts +282 -0
- package/src/AlertDialog.ts +110 -0
- package/src/Arrow.ts +21 -0
- package/src/AspectRatio.ts +34 -0
- package/src/Avatar.ts +152 -0
- package/src/Checkbox.ts +325 -0
- package/src/Collapsible.ts +170 -0
- package/src/ContextMenu.ts +303 -0
- package/src/Dialog.ts +308 -0
- package/src/DismissableLayer.ts +413 -0
- package/src/DropdownMenu.ts +275 -0
- package/src/FocusScope.ts +266 -0
- package/src/Form.ts +621 -0
- package/src/HoverCard.ts +318 -0
- package/src/Label.ts +25 -0
- package/src/Menu.ts +1057 -0
- package/src/Menubar.ts +572 -0
- package/src/NavigationMenu.ts +1236 -0
- package/src/OneTimePasswordField.ts +977 -0
- package/src/PasswordToggleField.ts +495 -0
- package/src/Popover.ts +354 -0
- package/src/Popper.ts +401 -0
- package/src/Portal.ts +19 -0
- package/src/Presence.ts +225 -0
- package/src/Primitive.ts +53 -0
- package/src/Progress.ts +92 -0
- package/src/Radio.ts +262 -0
- package/src/RadioGroup.ts +212 -0
- package/src/RovingFocusGroup.ts +259 -0
- package/src/ScrollArea.ts +966 -0
- package/src/Select.ts +1901 -0
- package/src/Separator.ts +36 -0
- package/src/Slider.ts +745 -0
- package/src/Slot.ts +105 -0
- package/src/Switch.ts +278 -0
- package/src/Tabs.ts +177 -0
- package/src/Toast.ts +923 -0
- package/src/Toggle.ts +31 -0
- package/src/ToggleGroup.ts +157 -0
- package/src/Toolbar.ts +130 -0
- package/src/Tooltip.ts +669 -0
- package/src/VisuallyHidden.ts +29 -0
- package/src/collection.ts +97 -0
- package/src/compose-event-handlers.ts +15 -0
- package/src/compose-refs.ts +53 -0
- package/src/context.ts +109 -0
- package/src/direction.ts +19 -0
- package/src/focus-guards.ts +56 -0
- package/src/index.ts +54 -0
- package/src/internal.ts +42 -0
- package/src/scroll-lock.ts +57 -0
- package/src/use-callback-ref.ts +28 -0
- package/src/use-effect-event.ts +36 -0
- package/src/use-is-hydrated.ts +23 -0
- package/src/use-previous.ts +28 -0
- package/src/use-size.ts +57 -0
- package/src/useControllableState.ts +78 -0
- package/src/useId.ts +15 -0
package/src/Form.ts
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-form (source:
|
|
2
|
+
// .radix-primitives/packages/react/form/src/form.tsx). Native-Constraint-Validation
|
|
3
|
+
// forms: Field names a control; Control is a native input whose ValidityState is
|
|
4
|
+
// captured into context on `change`/`invalid` (+ custom sync/async matchers over
|
|
5
|
+
// FormData with `setCustomValidity`); Message renders when its matcher applies and
|
|
6
|
+
// registers itself into the control's `aria-describedby`; Root focuses the first
|
|
7
|
+
// invalid control on submit and suppresses the default browser bubbles. This is the
|
|
8
|
+
// most octane-native primitive of the batch — everything is built on the native
|
|
9
|
+
// validity API (octane's `invalid` events are capture-delegated with React's
|
|
10
|
+
// propagation semantics).
|
|
11
|
+
import { createElement, useCallback, useEffect, useRef, useState } from 'octane';
|
|
12
|
+
|
|
13
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
14
|
+
import { useComposedRefs } from './compose-refs';
|
|
15
|
+
import { createContextScope } from './context';
|
|
16
|
+
import { S, subSlot } from './internal';
|
|
17
|
+
import { Root as LabelPrimitive } from './Label';
|
|
18
|
+
import { Primitive } from './Primitive';
|
|
19
|
+
import { useId } from './useId';
|
|
20
|
+
|
|
21
|
+
const [createFormContext, createFormScope] = createContextScope('Form');
|
|
22
|
+
export { createFormScope };
|
|
23
|
+
|
|
24
|
+
const FORM_NAME = 'Form';
|
|
25
|
+
|
|
26
|
+
type ValidityMap = { [fieldName: string]: ValidityState | undefined };
|
|
27
|
+
type CustomMatcher = (value: string, formData: FormData) => boolean | Promise<boolean>;
|
|
28
|
+
type CustomMatcherEntry = { id: string; match: CustomMatcher };
|
|
29
|
+
type CustomMatcherArgs = [string, FormData];
|
|
30
|
+
|
|
31
|
+
interface ValidationContextValue {
|
|
32
|
+
getFieldValidity(fieldName: string): ValidityState | undefined;
|
|
33
|
+
onFieldValidityChange(fieldName: string, validity: ValidityState): void;
|
|
34
|
+
getFieldCustomMatcherEntries(fieldName: string): CustomMatcherEntry[];
|
|
35
|
+
onFieldCustomMatcherEntryAdd(fieldName: string, matcherEntry: CustomMatcherEntry): void;
|
|
36
|
+
onFieldCustomMatcherEntryRemove(fieldName: string, matcherEntryId: string): void;
|
|
37
|
+
getFieldCustomErrors(fieldName: string): Record<string, boolean>;
|
|
38
|
+
onFieldCustomErrorsChange(fieldName: string, errors: Record<string, boolean>): void;
|
|
39
|
+
onFieldValiditionClear(fieldName: string): void;
|
|
40
|
+
}
|
|
41
|
+
const [ValidationProvider, useValidationContext] =
|
|
42
|
+
createFormContext<ValidationContextValue>(FORM_NAME);
|
|
43
|
+
|
|
44
|
+
interface AriaDescriptionContextValue {
|
|
45
|
+
onFieldMessageIdAdd(fieldName: string, id: string): void;
|
|
46
|
+
onFieldMessageIdRemove(fieldName: string, id: string): void;
|
|
47
|
+
getFieldDescription(fieldName: string): string | undefined;
|
|
48
|
+
}
|
|
49
|
+
const [AriaDescriptionProvider, useAriaDescriptionContext] =
|
|
50
|
+
createFormContext<AriaDescriptionContextValue>(FORM_NAME);
|
|
51
|
+
|
|
52
|
+
export function Root(props: any): any {
|
|
53
|
+
const slot = S('Form.Root');
|
|
54
|
+
const {
|
|
55
|
+
__scopeForm,
|
|
56
|
+
onClearServerErrors = () => {},
|
|
57
|
+
ref: forwardedRef,
|
|
58
|
+
...rootProps
|
|
59
|
+
} = props ?? {};
|
|
60
|
+
const formRef = useRef<HTMLFormElement | null>(null, subSlot(slot, 'form'));
|
|
61
|
+
const composedFormRef = useComposedRefs(forwardedRef, formRef, subSlot(slot, 'refs'));
|
|
62
|
+
|
|
63
|
+
// native validity per field
|
|
64
|
+
const [validityMap, setValidityMap] = useState<ValidityMap>({}, subSlot(slot, 'validity'));
|
|
65
|
+
const getFieldValidity = useCallback(
|
|
66
|
+
(fieldName: string) => validityMap[fieldName],
|
|
67
|
+
[validityMap],
|
|
68
|
+
subSlot(slot, 'getValidity'),
|
|
69
|
+
);
|
|
70
|
+
const handleFieldValidityChange = useCallback(
|
|
71
|
+
(fieldName: string, validity: ValidityState) =>
|
|
72
|
+
setValidityMap((prevValidityMap) => ({
|
|
73
|
+
...prevValidityMap,
|
|
74
|
+
[fieldName]: { ...(prevValidityMap[fieldName] ?? {}), ...validity },
|
|
75
|
+
})),
|
|
76
|
+
[],
|
|
77
|
+
subSlot(slot, 'validityChange'),
|
|
78
|
+
);
|
|
79
|
+
const handleFieldValiditionClear = useCallback(
|
|
80
|
+
(fieldName: string) => {
|
|
81
|
+
setValidityMap((prevValidityMap) => ({ ...prevValidityMap, [fieldName]: undefined }));
|
|
82
|
+
setCustomErrorsMap((prevCustomErrorsMap) => ({ ...prevCustomErrorsMap, [fieldName]: {} }));
|
|
83
|
+
},
|
|
84
|
+
[],
|
|
85
|
+
subSlot(slot, 'validityClear'),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// custom matcher entries per field
|
|
89
|
+
const [customMatcherEntriesMap, setCustomMatcherEntriesMap] = useState<{
|
|
90
|
+
[fieldName: string]: CustomMatcherEntry[];
|
|
91
|
+
}>({}, subSlot(slot, 'matchers'));
|
|
92
|
+
const getFieldCustomMatcherEntries = useCallback(
|
|
93
|
+
(fieldName: string) => customMatcherEntriesMap[fieldName] ?? [],
|
|
94
|
+
[customMatcherEntriesMap],
|
|
95
|
+
subSlot(slot, 'getMatchers'),
|
|
96
|
+
);
|
|
97
|
+
const handleFieldCustomMatcherAdd = useCallback(
|
|
98
|
+
(fieldName: string, matcherEntry: CustomMatcherEntry) => {
|
|
99
|
+
setCustomMatcherEntriesMap((prev) => ({
|
|
100
|
+
...prev,
|
|
101
|
+
[fieldName]: [...(prev[fieldName] ?? []), matcherEntry],
|
|
102
|
+
}));
|
|
103
|
+
},
|
|
104
|
+
[],
|
|
105
|
+
subSlot(slot, 'matcherAdd'),
|
|
106
|
+
);
|
|
107
|
+
const handleFieldCustomMatcherRemove = useCallback(
|
|
108
|
+
(fieldName: string, matcherEntryId: string) => {
|
|
109
|
+
setCustomMatcherEntriesMap((prev) => ({
|
|
110
|
+
...prev,
|
|
111
|
+
[fieldName]: (prev[fieldName] ?? []).filter((entry) => entry.id !== matcherEntryId),
|
|
112
|
+
}));
|
|
113
|
+
},
|
|
114
|
+
[],
|
|
115
|
+
subSlot(slot, 'matcherRemove'),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// custom errors per field
|
|
119
|
+
const [customErrorsMap, setCustomErrorsMap] = useState<{
|
|
120
|
+
[fieldName: string]: Record<string, boolean>;
|
|
121
|
+
}>({}, subSlot(slot, 'errors'));
|
|
122
|
+
const getFieldCustomErrors = useCallback(
|
|
123
|
+
(fieldName: string) => customErrorsMap[fieldName] ?? {},
|
|
124
|
+
[customErrorsMap],
|
|
125
|
+
subSlot(slot, 'getErrors'),
|
|
126
|
+
);
|
|
127
|
+
const handleFieldCustomErrorsChange = useCallback(
|
|
128
|
+
(fieldName: string, customErrors: Record<string, boolean>) => {
|
|
129
|
+
setCustomErrorsMap((prev) => ({
|
|
130
|
+
...prev,
|
|
131
|
+
[fieldName]: { ...(prev[fieldName] ?? {}), ...customErrors },
|
|
132
|
+
}));
|
|
133
|
+
},
|
|
134
|
+
[],
|
|
135
|
+
subSlot(slot, 'errorsChange'),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// messageIds per field
|
|
139
|
+
const [messageIdsMap, setMessageIdsMap] = useState<{ [fieldName: string]: Set<string> }>(
|
|
140
|
+
{},
|
|
141
|
+
subSlot(slot, 'messages'),
|
|
142
|
+
);
|
|
143
|
+
const handleFieldMessageIdAdd = useCallback(
|
|
144
|
+
(fieldName: string, id: string) => {
|
|
145
|
+
setMessageIdsMap((prev) => {
|
|
146
|
+
const fieldDescriptionIds = new Set(prev[fieldName]).add(id);
|
|
147
|
+
return { ...prev, [fieldName]: fieldDescriptionIds };
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
[],
|
|
151
|
+
subSlot(slot, 'messageAdd'),
|
|
152
|
+
);
|
|
153
|
+
const handleFieldMessageIdRemove = useCallback(
|
|
154
|
+
(fieldName: string, id: string) => {
|
|
155
|
+
setMessageIdsMap((prev) => {
|
|
156
|
+
const fieldDescriptionIds = new Set(prev[fieldName]);
|
|
157
|
+
fieldDescriptionIds.delete(id);
|
|
158
|
+
return { ...prev, [fieldName]: fieldDescriptionIds };
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
[],
|
|
162
|
+
subSlot(slot, 'messageRemove'),
|
|
163
|
+
);
|
|
164
|
+
const getFieldDescription = useCallback(
|
|
165
|
+
(fieldName: string) => Array.from(messageIdsMap[fieldName] ?? []).join(' ') || undefined,
|
|
166
|
+
[messageIdsMap],
|
|
167
|
+
subSlot(slot, 'getDescription'),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return createElement(ValidationProvider, {
|
|
171
|
+
scope: __scopeForm,
|
|
172
|
+
getFieldValidity,
|
|
173
|
+
onFieldValidityChange: handleFieldValidityChange,
|
|
174
|
+
getFieldCustomMatcherEntries,
|
|
175
|
+
onFieldCustomMatcherEntryAdd: handleFieldCustomMatcherAdd,
|
|
176
|
+
onFieldCustomMatcherEntryRemove: handleFieldCustomMatcherRemove,
|
|
177
|
+
getFieldCustomErrors,
|
|
178
|
+
onFieldCustomErrorsChange: handleFieldCustomErrorsChange,
|
|
179
|
+
onFieldValiditionClear: handleFieldValiditionClear,
|
|
180
|
+
children: createElement(AriaDescriptionProvider, {
|
|
181
|
+
scope: __scopeForm,
|
|
182
|
+
onFieldMessageIdAdd: handleFieldMessageIdAdd,
|
|
183
|
+
onFieldMessageIdRemove: handleFieldMessageIdRemove,
|
|
184
|
+
getFieldDescription,
|
|
185
|
+
children: createElement(Primitive.form, {
|
|
186
|
+
...rootProps,
|
|
187
|
+
ref: composedFormRef,
|
|
188
|
+
// focus first invalid control when the form is submitted
|
|
189
|
+
onInvalid: composeEventHandlers(props?.onInvalid, (event: Event) => {
|
|
190
|
+
const firstInvalidControl = getFirstInvalidControl(
|
|
191
|
+
event.currentTarget as HTMLFormElement,
|
|
192
|
+
);
|
|
193
|
+
if (firstInvalidControl === event.target) firstInvalidControl.focus();
|
|
194
|
+
// prevent default browser UI for form validation
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
}),
|
|
197
|
+
// clear server errors when the form is re-submitted
|
|
198
|
+
onSubmit: composeEventHandlers(props?.onSubmit, onClearServerErrors, {
|
|
199
|
+
checkForDefaultPrevented: false,
|
|
200
|
+
}),
|
|
201
|
+
// clear server errors when the form is reset
|
|
202
|
+
onReset: composeEventHandlers(props?.onReset, onClearServerErrors),
|
|
203
|
+
}),
|
|
204
|
+
}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const FIELD_NAME = 'FormField';
|
|
209
|
+
|
|
210
|
+
interface FormFieldContextValue {
|
|
211
|
+
id: string;
|
|
212
|
+
name: string;
|
|
213
|
+
serverInvalid: boolean;
|
|
214
|
+
}
|
|
215
|
+
const [FormFieldProvider, useFormFieldContext] =
|
|
216
|
+
createFormContext<FormFieldContextValue>(FIELD_NAME);
|
|
217
|
+
|
|
218
|
+
export function Field(props: any): any {
|
|
219
|
+
const slot = S('Form.Field');
|
|
220
|
+
const { __scopeForm, name, serverInvalid = false, ...fieldProps } = props ?? {};
|
|
221
|
+
const validationContext = useValidationContext(FIELD_NAME, __scopeForm);
|
|
222
|
+
const validity = validationContext.getFieldValidity(name);
|
|
223
|
+
const id = useId(subSlot(slot, 'id'));
|
|
224
|
+
|
|
225
|
+
return createElement(FormFieldProvider, {
|
|
226
|
+
scope: __scopeForm,
|
|
227
|
+
id,
|
|
228
|
+
name,
|
|
229
|
+
serverInvalid,
|
|
230
|
+
children: createElement(Primitive.div, {
|
|
231
|
+
'data-valid': getValidAttribute(validity, serverInvalid),
|
|
232
|
+
'data-invalid': getInvalidAttribute(validity, serverInvalid),
|
|
233
|
+
...fieldProps,
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function Label(props: any): any {
|
|
239
|
+
const { __scopeForm, ...labelProps } = props ?? {};
|
|
240
|
+
const validationContext = useValidationContext('FormLabel', __scopeForm);
|
|
241
|
+
const fieldContext = useFormFieldContext('FormLabel', __scopeForm);
|
|
242
|
+
const htmlFor = labelProps.htmlFor || fieldContext.id;
|
|
243
|
+
const validity = validationContext.getFieldValidity(fieldContext.name);
|
|
244
|
+
|
|
245
|
+
return createElement(LabelPrimitive, {
|
|
246
|
+
'data-valid': getValidAttribute(validity, fieldContext.serverInvalid),
|
|
247
|
+
'data-invalid': getInvalidAttribute(validity, fieldContext.serverInvalid),
|
|
248
|
+
...labelProps,
|
|
249
|
+
htmlFor,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const CONTROL_NAME = 'FormControl';
|
|
254
|
+
|
|
255
|
+
export function Control(props: any): any {
|
|
256
|
+
const slot = S('Form.Control');
|
|
257
|
+
const { __scopeForm, ref: forwardedRef, ...controlProps } = props ?? {};
|
|
258
|
+
|
|
259
|
+
const validationContext = useValidationContext(CONTROL_NAME, __scopeForm);
|
|
260
|
+
const fieldContext = useFormFieldContext(CONTROL_NAME, __scopeForm);
|
|
261
|
+
const ariaDescriptionContext = useAriaDescriptionContext(CONTROL_NAME, __scopeForm);
|
|
262
|
+
|
|
263
|
+
const ref = useRef<HTMLInputElement | null>(null, subSlot(slot, 'ref'));
|
|
264
|
+
const composedRef = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
|
|
265
|
+
const name = controlProps.name || fieldContext.name;
|
|
266
|
+
const id = controlProps.id || fieldContext.id;
|
|
267
|
+
const customMatcherEntries = validationContext.getFieldCustomMatcherEntries(name);
|
|
268
|
+
|
|
269
|
+
const { onFieldValidityChange, onFieldCustomErrorsChange, onFieldValiditionClear } =
|
|
270
|
+
validationContext;
|
|
271
|
+
const updateControlValidity = useCallback(
|
|
272
|
+
async (control: HTMLInputElement) => {
|
|
273
|
+
// 1. first, if we have built-in errors we stop here
|
|
274
|
+
if (hasBuiltInError(control.validity)) {
|
|
275
|
+
const controlValidity = validityStateToObject(control.validity);
|
|
276
|
+
onFieldValidityChange(name, controlValidity as unknown as ValidityState);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 2. gather the form data to give to custom matchers for cross-comparisons
|
|
281
|
+
const formData = control.form ? new FormData(control.form) : new FormData();
|
|
282
|
+
const matcherArgs: CustomMatcherArgs = [control.value, formData];
|
|
283
|
+
|
|
284
|
+
// 3. split sync and async custom matcher entries
|
|
285
|
+
const syncCustomMatcherEntries: CustomMatcherEntry[] = [];
|
|
286
|
+
const asyncCustomMatcherEntries: CustomMatcherEntry[] = [];
|
|
287
|
+
customMatcherEntries.forEach((customMatcherEntry) => {
|
|
288
|
+
if (isAsyncCustomMatcherEntry(customMatcherEntry, matcherArgs)) {
|
|
289
|
+
asyncCustomMatcherEntries.push(customMatcherEntry);
|
|
290
|
+
} else if (isSyncCustomMatcherEntry(customMatcherEntry)) {
|
|
291
|
+
syncCustomMatcherEntries.push(customMatcherEntry);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// 4. run sync custom matchers and update control validity / errors
|
|
296
|
+
const syncCustomErrors = syncCustomMatcherEntries.map(({ id, match }) => {
|
|
297
|
+
return [id, match(...matcherArgs)] as const;
|
|
298
|
+
});
|
|
299
|
+
const syncCustomErrorsById = Object.fromEntries(syncCustomErrors);
|
|
300
|
+
const hasSyncCustomErrors = Object.values(syncCustomErrorsById).some(Boolean);
|
|
301
|
+
control.setCustomValidity(hasSyncCustomErrors ? DEFAULT_INVALID_MESSAGE : '');
|
|
302
|
+
onFieldValidityChange(
|
|
303
|
+
name,
|
|
304
|
+
validityStateToObject(control.validity) as unknown as ValidityState,
|
|
305
|
+
);
|
|
306
|
+
onFieldCustomErrorsChange(name, syncCustomErrorsById as Record<string, boolean>);
|
|
307
|
+
|
|
308
|
+
// 5. run async custom matchers and update control validity / errors
|
|
309
|
+
if (!hasSyncCustomErrors && asyncCustomMatcherEntries.length > 0) {
|
|
310
|
+
const promisedCustomErrors = asyncCustomMatcherEntries.map(({ id, match }) =>
|
|
311
|
+
(match(...matcherArgs) as Promise<boolean>).then((matches) => [id, matches] as const),
|
|
312
|
+
);
|
|
313
|
+
const asyncCustomErrors = await Promise.all(promisedCustomErrors);
|
|
314
|
+
const asyncCustomErrorsById = Object.fromEntries(asyncCustomErrors);
|
|
315
|
+
const hasAsyncCustomErrors = Object.values(asyncCustomErrorsById).some(Boolean);
|
|
316
|
+
control.setCustomValidity(hasAsyncCustomErrors ? DEFAULT_INVALID_MESSAGE : '');
|
|
317
|
+
onFieldValidityChange(
|
|
318
|
+
name,
|
|
319
|
+
validityStateToObject(control.validity) as unknown as ValidityState,
|
|
320
|
+
);
|
|
321
|
+
onFieldCustomErrorsChange(name, asyncCustomErrorsById);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
[customMatcherEntries, name, onFieldCustomErrorsChange, onFieldValidityChange],
|
|
325
|
+
subSlot(slot, 'update'),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
useEffect(
|
|
329
|
+
() => {
|
|
330
|
+
const control = ref.current;
|
|
331
|
+
if (control) {
|
|
332
|
+
// We only validate on the native `change` event (not on every keystroke) —
|
|
333
|
+
// a UX decision from the source.
|
|
334
|
+
const handleChange = (): void => void updateControlValidity(control);
|
|
335
|
+
control.addEventListener('change', handleChange);
|
|
336
|
+
return () => control.removeEventListener('change', handleChange);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
[updateControlValidity],
|
|
340
|
+
subSlot(slot, 'e:change'),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const resetControlValidity = useCallback(
|
|
344
|
+
() => {
|
|
345
|
+
const control = ref.current;
|
|
346
|
+
if (control) {
|
|
347
|
+
control.setCustomValidity('');
|
|
348
|
+
onFieldValiditionClear(name);
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
[name, onFieldValiditionClear],
|
|
352
|
+
subSlot(slot, 'reset'),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// reset validity and errors when the form is reset
|
|
356
|
+
useEffect(
|
|
357
|
+
() => {
|
|
358
|
+
const form = ref.current?.form;
|
|
359
|
+
if (form) {
|
|
360
|
+
form.addEventListener('reset', resetControlValidity);
|
|
361
|
+
return () => form.removeEventListener('reset', resetControlValidity);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
[resetControlValidity],
|
|
365
|
+
subSlot(slot, 'e:reset'),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// focus first invalid control when fields are set as invalid by server
|
|
369
|
+
useEffect(
|
|
370
|
+
() => {
|
|
371
|
+
const control = ref.current;
|
|
372
|
+
const form = control?.closest('form');
|
|
373
|
+
if (form && fieldContext.serverInvalid) {
|
|
374
|
+
const firstInvalidControl = getFirstInvalidControl(form);
|
|
375
|
+
if (firstInvalidControl === control) firstInvalidControl.focus();
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
[fieldContext.serverInvalid],
|
|
379
|
+
subSlot(slot, 'e:server'),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const validity = validationContext.getFieldValidity(name);
|
|
383
|
+
|
|
384
|
+
return createElement(Primitive.input, {
|
|
385
|
+
'data-valid': getValidAttribute(validity, fieldContext.serverInvalid),
|
|
386
|
+
'data-invalid': getInvalidAttribute(validity, fieldContext.serverInvalid),
|
|
387
|
+
'aria-invalid': fieldContext.serverInvalid ? true : undefined,
|
|
388
|
+
'aria-describedby': ariaDescriptionContext.getFieldDescription(name),
|
|
389
|
+
// disable default browser behaviour of showing built-in error message on hover
|
|
390
|
+
title: '',
|
|
391
|
+
...controlProps,
|
|
392
|
+
ref: composedRef,
|
|
393
|
+
id,
|
|
394
|
+
name,
|
|
395
|
+
onInvalid: composeEventHandlers(props?.onInvalid, (event: Event) => {
|
|
396
|
+
const control = event.currentTarget as HTMLInputElement;
|
|
397
|
+
void updateControlValidity(control);
|
|
398
|
+
}),
|
|
399
|
+
// octane adaptation: the source resets on React's `onChange`, which is the
|
|
400
|
+
// native `input` event (each edit) — NOT the native `change` (commit) the
|
|
401
|
+
// validate-on-change listener above uses. Binding this to octane's native
|
|
402
|
+
// onChange would fire on the SAME event and stomp the freshly-captured
|
|
403
|
+
// validity.
|
|
404
|
+
onInput: composeEventHandlers(props?.onInput, () => {
|
|
405
|
+
// reset validity when user changes value
|
|
406
|
+
resetControlValidity();
|
|
407
|
+
}),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const _validityMatchers = [
|
|
412
|
+
'badInput',
|
|
413
|
+
'patternMismatch',
|
|
414
|
+
'rangeOverflow',
|
|
415
|
+
'rangeUnderflow',
|
|
416
|
+
'stepMismatch',
|
|
417
|
+
'tooLong',
|
|
418
|
+
'tooShort',
|
|
419
|
+
'typeMismatch',
|
|
420
|
+
'valid',
|
|
421
|
+
'valueMissing',
|
|
422
|
+
] as const;
|
|
423
|
+
type ValidityMatcher = (typeof _validityMatchers)[number];
|
|
424
|
+
|
|
425
|
+
const DEFAULT_INVALID_MESSAGE = 'This value is not valid';
|
|
426
|
+
const DEFAULT_BUILT_IN_MESSAGES: Record<ValidityMatcher, string | undefined> = {
|
|
427
|
+
badInput: DEFAULT_INVALID_MESSAGE,
|
|
428
|
+
patternMismatch: 'This value does not match the required pattern',
|
|
429
|
+
rangeOverflow: 'This value is too large',
|
|
430
|
+
rangeUnderflow: 'This value is too small',
|
|
431
|
+
stepMismatch: 'This value does not match the required step',
|
|
432
|
+
tooLong: 'This value is too long',
|
|
433
|
+
tooShort: 'This value is too short',
|
|
434
|
+
typeMismatch: 'This value does not match the required type',
|
|
435
|
+
valid: undefined,
|
|
436
|
+
valueMissing: 'This value is missing',
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const MESSAGE_NAME = 'FormMessage';
|
|
440
|
+
|
|
441
|
+
export function Message(props: any): any {
|
|
442
|
+
const { match, name: nameProp, ...messageProps } = props ?? {};
|
|
443
|
+
const fieldContext = useFormFieldContext(MESSAGE_NAME, props?.__scopeForm);
|
|
444
|
+
const name = nameProp ?? fieldContext.name;
|
|
445
|
+
|
|
446
|
+
if (match === undefined) {
|
|
447
|
+
return createElement(FormMessageImpl, {
|
|
448
|
+
...messageProps,
|
|
449
|
+
name,
|
|
450
|
+
children: props?.children || DEFAULT_INVALID_MESSAGE,
|
|
451
|
+
});
|
|
452
|
+
} else if (typeof match === 'function') {
|
|
453
|
+
return createElement(FormCustomMessage, { match, ...messageProps, name });
|
|
454
|
+
} else {
|
|
455
|
+
return createElement(FormBuiltInMessage, { match, ...messageProps, name });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function FormBuiltInMessage(props: any): any {
|
|
460
|
+
const { match, forceMatch = false, name, children, ...messageProps } = props;
|
|
461
|
+
const validationContext = useValidationContext(MESSAGE_NAME, messageProps.__scopeForm);
|
|
462
|
+
const validity = validationContext.getFieldValidity(name);
|
|
463
|
+
const matches = forceMatch || validity?.[match as ValidityMatcher];
|
|
464
|
+
|
|
465
|
+
if (matches) {
|
|
466
|
+
return createElement(FormMessageImpl, {
|
|
467
|
+
...messageProps,
|
|
468
|
+
name,
|
|
469
|
+
children: children ?? DEFAULT_BUILT_IN_MESSAGES[match as ValidityMatcher],
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function FormCustomMessage(props: any): any {
|
|
477
|
+
const slot = S('Form.CustomMessage');
|
|
478
|
+
const { match, forceMatch = false, name, id: idProp, children, ...messageProps } = props;
|
|
479
|
+
const validationContext = useValidationContext(MESSAGE_NAME, messageProps.__scopeForm);
|
|
480
|
+
const _id = useId(subSlot(slot, 'id'));
|
|
481
|
+
const id = idProp ?? _id;
|
|
482
|
+
|
|
483
|
+
const customMatcherEntry = { id, match };
|
|
484
|
+
const { onFieldCustomMatcherEntryAdd, onFieldCustomMatcherEntryRemove } = validationContext;
|
|
485
|
+
useEffect(
|
|
486
|
+
() => {
|
|
487
|
+
onFieldCustomMatcherEntryAdd(name, customMatcherEntry);
|
|
488
|
+
return () => onFieldCustomMatcherEntryRemove(name, customMatcherEntry.id);
|
|
489
|
+
},
|
|
490
|
+
[id, match, name, onFieldCustomMatcherEntryAdd, onFieldCustomMatcherEntryRemove],
|
|
491
|
+
subSlot(slot, 'e:matcher'),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const validity = validationContext.getFieldValidity(name);
|
|
495
|
+
const customErrors = validationContext.getFieldCustomErrors(name);
|
|
496
|
+
const hasMatchingCustomError = customErrors[id];
|
|
497
|
+
const matches = forceMatch || (validity && !hasBuiltInError(validity) && hasMatchingCustomError);
|
|
498
|
+
|
|
499
|
+
if (matches) {
|
|
500
|
+
return createElement(FormMessageImpl, {
|
|
501
|
+
id,
|
|
502
|
+
...messageProps,
|
|
503
|
+
name,
|
|
504
|
+
children: children ?? DEFAULT_INVALID_MESSAGE,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function FormMessageImpl(props: any): any {
|
|
512
|
+
const slot = S('Form.MessageImpl');
|
|
513
|
+
const { __scopeForm, id: idProp, name, ...messageProps } = props;
|
|
514
|
+
const ariaDescriptionContext = useAriaDescriptionContext(MESSAGE_NAME, __scopeForm);
|
|
515
|
+
const _id = useId(subSlot(slot, 'id'));
|
|
516
|
+
const id = idProp ?? _id;
|
|
517
|
+
|
|
518
|
+
const { onFieldMessageIdAdd, onFieldMessageIdRemove } = ariaDescriptionContext;
|
|
519
|
+
useEffect(
|
|
520
|
+
() => {
|
|
521
|
+
onFieldMessageIdAdd(name, id);
|
|
522
|
+
return () => onFieldMessageIdRemove(name, id);
|
|
523
|
+
},
|
|
524
|
+
[name, id, onFieldMessageIdAdd, onFieldMessageIdRemove],
|
|
525
|
+
subSlot(slot, 'e:id'),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
return createElement(Primitive.span, { id, ...messageProps });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function ValidityState(props: any): any {
|
|
532
|
+
const { __scopeForm, name: nameProp, children } = props ?? {};
|
|
533
|
+
const validationContext = useValidationContext('FormValidityState', __scopeForm);
|
|
534
|
+
const fieldContext = useFormFieldContext('FormValidityState', __scopeForm);
|
|
535
|
+
const name = nameProp ?? fieldContext.name;
|
|
536
|
+
const validity = validationContext.getFieldValidity(name);
|
|
537
|
+
return children(validity);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function Submit(props: any): any {
|
|
541
|
+
const { __scopeForm, ...submitProps } = props ?? {};
|
|
542
|
+
return createElement(Primitive.button, { type: 'submit', ...submitProps });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function validityStateToObject(validity: globalThis.ValidityState): Record<string, boolean> {
|
|
546
|
+
const object: any = {};
|
|
547
|
+
for (const key in validity) {
|
|
548
|
+
object[key] = (validity as any)[key];
|
|
549
|
+
}
|
|
550
|
+
return object;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function isHTMLElement(element: any): element is HTMLElement {
|
|
554
|
+
return element instanceof HTMLElement;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function isFormControl(element: any): element is { validity: globalThis.ValidityState } {
|
|
558
|
+
return 'validity' in element;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isInvalid(control: HTMLElement): boolean {
|
|
562
|
+
return (
|
|
563
|
+
isFormControl(control) &&
|
|
564
|
+
(control.validity.valid === false || control.getAttribute('aria-invalid') === 'true')
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function getFirstInvalidControl(form: HTMLFormElement): HTMLElement | undefined {
|
|
569
|
+
const elements = form.elements;
|
|
570
|
+
const [firstInvalidControl] = Array.from(elements).filter(isHTMLElement).filter(isInvalid);
|
|
571
|
+
return firstInvalidControl;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function isAsyncCustomMatcherEntry(entry: CustomMatcherEntry, args: CustomMatcherArgs): boolean {
|
|
575
|
+
return entry.match.constructor.name === 'AsyncFunction' || returnsPromise(entry.match, args);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function isSyncCustomMatcherEntry(entry: CustomMatcherEntry): boolean {
|
|
579
|
+
return entry.match.constructor.name === 'Function';
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function returnsPromise(func: (...args: any[]) => any, args: Array<unknown>): boolean {
|
|
583
|
+
return func(...args) instanceof Promise;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function hasBuiltInError(validity: globalThis.ValidityState): boolean {
|
|
587
|
+
let error = false;
|
|
588
|
+
for (const validityKey in validity) {
|
|
589
|
+
const key = validityKey as keyof globalThis.ValidityState;
|
|
590
|
+
if (key !== 'valid' && key !== 'customError' && validity[key]) {
|
|
591
|
+
error = true;
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return error;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function getValidAttribute(
|
|
599
|
+
validity: globalThis.ValidityState | undefined,
|
|
600
|
+
serverInvalid: boolean,
|
|
601
|
+
): true | undefined {
|
|
602
|
+
if (validity?.valid === true && !serverInvalid) return true;
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
function getInvalidAttribute(
|
|
606
|
+
validity: globalThis.ValidityState | undefined,
|
|
607
|
+
serverInvalid: boolean,
|
|
608
|
+
): true | undefined {
|
|
609
|
+
if (validity?.valid === false || serverInvalid) return true;
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export {
|
|
614
|
+
Root as Form,
|
|
615
|
+
Field as FormField,
|
|
616
|
+
Label as FormLabel,
|
|
617
|
+
Control as FormControl,
|
|
618
|
+
Message as FormMessage,
|
|
619
|
+
ValidityState as FormValidityState,
|
|
620
|
+
Submit as FormSubmit,
|
|
621
|
+
};
|