@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +45 -0
  4. package/src/AccessibleIcon.ts +26 -0
  5. package/src/Accordion.ts +282 -0
  6. package/src/AlertDialog.ts +110 -0
  7. package/src/Arrow.ts +21 -0
  8. package/src/AspectRatio.ts +34 -0
  9. package/src/Avatar.ts +152 -0
  10. package/src/Checkbox.ts +325 -0
  11. package/src/Collapsible.ts +170 -0
  12. package/src/ContextMenu.ts +303 -0
  13. package/src/Dialog.ts +308 -0
  14. package/src/DismissableLayer.ts +413 -0
  15. package/src/DropdownMenu.ts +275 -0
  16. package/src/FocusScope.ts +266 -0
  17. package/src/Form.ts +621 -0
  18. package/src/HoverCard.ts +318 -0
  19. package/src/Label.ts +25 -0
  20. package/src/Menu.ts +1057 -0
  21. package/src/Menubar.ts +572 -0
  22. package/src/NavigationMenu.ts +1236 -0
  23. package/src/OneTimePasswordField.ts +977 -0
  24. package/src/PasswordToggleField.ts +495 -0
  25. package/src/Popover.ts +354 -0
  26. package/src/Popper.ts +401 -0
  27. package/src/Portal.ts +19 -0
  28. package/src/Presence.ts +225 -0
  29. package/src/Primitive.ts +53 -0
  30. package/src/Progress.ts +92 -0
  31. package/src/Radio.ts +262 -0
  32. package/src/RadioGroup.ts +212 -0
  33. package/src/RovingFocusGroup.ts +259 -0
  34. package/src/ScrollArea.ts +966 -0
  35. package/src/Select.ts +1901 -0
  36. package/src/Separator.ts +36 -0
  37. package/src/Slider.ts +745 -0
  38. package/src/Slot.ts +105 -0
  39. package/src/Switch.ts +278 -0
  40. package/src/Tabs.ts +177 -0
  41. package/src/Toast.ts +923 -0
  42. package/src/Toggle.ts +31 -0
  43. package/src/ToggleGroup.ts +157 -0
  44. package/src/Toolbar.ts +130 -0
  45. package/src/Tooltip.ts +669 -0
  46. package/src/VisuallyHidden.ts +29 -0
  47. package/src/collection.ts +97 -0
  48. package/src/compose-event-handlers.ts +15 -0
  49. package/src/compose-refs.ts +53 -0
  50. package/src/context.ts +109 -0
  51. package/src/direction.ts +19 -0
  52. package/src/focus-guards.ts +56 -0
  53. package/src/index.ts +54 -0
  54. package/src/internal.ts +42 -0
  55. package/src/scroll-lock.ts +57 -0
  56. package/src/use-callback-ref.ts +28 -0
  57. package/src/use-effect-event.ts +36 -0
  58. package/src/use-is-hydrated.ts +23 -0
  59. package/src/use-previous.ts +28 -0
  60. package/src/use-size.ts +57 -0
  61. package/src/useControllableState.ts +78 -0
  62. 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
+ };