@funstack/ui 0.1.0-alpha.1 → 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.
Files changed (288) hide show
  1. package/dist/accordion/accordion-context.mjs.map +1 -1
  2. package/dist/accordion/accordion-item.mjs +18 -4
  3. package/dist/accordion/accordion-item.mjs.map +1 -1
  4. package/dist/accordion/accordion-panel.mjs +2 -3
  5. package/dist/accordion/accordion-panel.mjs.map +1 -1
  6. package/dist/accordion/accordion-root.d.mts.map +1 -1
  7. package/dist/accordion/accordion-root.mjs +32 -14
  8. package/dist/accordion/accordion-root.mjs.map +1 -1
  9. package/dist/accordion/accordion-trigger.mjs +4 -5
  10. package/dist/accordion/accordion-trigger.mjs.map +1 -1
  11. package/dist/checkbox/checkbox.d.mts +5 -3
  12. package/dist/checkbox/checkbox.d.mts.map +1 -1
  13. package/dist/checkbox/checkbox.mjs +25 -9
  14. package/dist/checkbox/checkbox.mjs.map +1 -1
  15. package/dist/checkbox-group/checkbox-group-context.mjs.map +1 -1
  16. package/dist/checkbox-group/checkbox-group-item.d.mts +2 -1
  17. package/dist/checkbox-group/checkbox-group-item.d.mts.map +1 -1
  18. package/dist/checkbox-group/checkbox-group-item.mjs +3 -1
  19. package/dist/checkbox-group/checkbox-group-item.mjs.map +1 -1
  20. package/dist/checkbox-group/checkbox-group-root.d.mts +5 -2
  21. package/dist/checkbox-group/checkbox-group-root.d.mts.map +1 -1
  22. package/dist/checkbox-group/checkbox-group-root.mjs +24 -5
  23. package/dist/checkbox-group/checkbox-group-root.mjs.map +1 -1
  24. package/dist/combobox/combobox-clear.d.mts +34 -0
  25. package/dist/combobox/combobox-clear.d.mts.map +1 -0
  26. package/dist/combobox/combobox-clear.mjs +58 -0
  27. package/dist/combobox/combobox-clear.mjs.map +1 -0
  28. package/dist/combobox/combobox-context.mjs +23 -0
  29. package/dist/combobox/combobox-context.mjs.map +1 -0
  30. package/dist/combobox/combobox-group.d.mts +72 -0
  31. package/dist/combobox/combobox-group.d.mts.map +1 -0
  32. package/dist/combobox/combobox-group.mjs +104 -0
  33. package/dist/combobox/combobox-group.mjs.map +1 -0
  34. package/dist/combobox/combobox-input.d.mts +42 -0
  35. package/dist/combobox/combobox-input.d.mts.map +1 -0
  36. package/dist/combobox/combobox-input.mjs +102 -0
  37. package/dist/combobox/combobox-input.mjs.map +1 -0
  38. package/dist/combobox/combobox-label.d.mts +22 -0
  39. package/dist/combobox/combobox-label.d.mts.map +1 -0
  40. package/dist/combobox/combobox-label.mjs +32 -0
  41. package/dist/combobox/combobox-label.mjs.map +1 -0
  42. package/dist/combobox/combobox-list.d.mts +37 -0
  43. package/dist/combobox/combobox-list.d.mts.map +1 -0
  44. package/dist/combobox/combobox-list.mjs +70 -0
  45. package/dist/combobox/combobox-list.mjs.map +1 -0
  46. package/dist/combobox/combobox-option.d.mts +45 -0
  47. package/dist/combobox/combobox-option.d.mts.map +1 -0
  48. package/dist/combobox/combobox-option.mjs +88 -0
  49. package/dist/combobox/combobox-option.mjs.map +1 -0
  50. package/dist/combobox/combobox-popup.d.mts +49 -0
  51. package/dist/combobox/combobox-popup.d.mts.map +1 -0
  52. package/dist/combobox/combobox-popup.mjs +86 -0
  53. package/dist/combobox/combobox-popup.mjs.map +1 -0
  54. package/dist/combobox/combobox-root.d.mts +135 -0
  55. package/dist/combobox/combobox-root.d.mts.map +1 -0
  56. package/dist/combobox/combobox-root.mjs +523 -0
  57. package/dist/combobox/combobox-root.mjs.map +1 -0
  58. package/dist/combobox/combobox-trigger.d.mts +36 -0
  59. package/dist/combobox/combobox-trigger.d.mts.map +1 -0
  60. package/dist/combobox/combobox-trigger.mjs +92 -0
  61. package/dist/combobox/combobox-trigger.mjs.map +1 -0
  62. package/dist/combobox/index.d.mts +36 -0
  63. package/dist/combobox/index.d.mts.map +1 -0
  64. package/dist/combobox/index.mjs +36 -0
  65. package/dist/combobox/index.mjs.map +1 -0
  66. package/dist/dialog/dialog-close.d.mts +17 -10
  67. package/dist/dialog/dialog-close.d.mts.map +1 -1
  68. package/dist/dialog/dialog-close.mjs +31 -20
  69. package/dist/dialog/dialog-close.mjs.map +1 -1
  70. package/dist/dialog/dialog-context.mjs.map +1 -1
  71. package/dist/dialog/dialog-form.d.mts +12 -5
  72. package/dist/dialog/dialog-form.d.mts.map +1 -1
  73. package/dist/dialog/dialog-form.mjs +16 -28
  74. package/dist/dialog/dialog-form.mjs.map +1 -1
  75. package/dist/dialog/dialog-popup.d.mts +10 -4
  76. package/dist/dialog/dialog-popup.d.mts.map +1 -1
  77. package/dist/dialog/dialog-popup.mjs +106 -41
  78. package/dist/dialog/dialog-popup.mjs.map +1 -1
  79. package/dist/dialog/dialog-root.d.mts +14 -8
  80. package/dist/dialog/dialog-root.d.mts.map +1 -1
  81. package/dist/dialog/dialog-root.mjs +66 -11
  82. package/dist/dialog/dialog-root.mjs.map +1 -1
  83. package/dist/dialog/dialog-trigger.d.mts +3 -2
  84. package/dist/dialog/dialog-trigger.d.mts.map +1 -1
  85. package/dist/dialog/dialog-trigger.mjs.map +1 -1
  86. package/dist/dialog/index.d.mts +4 -2
  87. package/dist/dialog/index.d.mts.map +1 -1
  88. package/dist/dialog/index.mjs +4 -2
  89. package/dist/dialog/index.mjs.map +1 -1
  90. package/dist/field/field-control.d.mts +33 -0
  91. package/dist/field/field-control.d.mts.map +1 -0
  92. package/dist/field/field-control.mjs +68 -0
  93. package/dist/field/field-control.mjs.map +1 -0
  94. package/dist/field/field-description.d.mts +24 -0
  95. package/dist/field/field-description.d.mts.map +1 -0
  96. package/dist/field/field-description.mjs +34 -0
  97. package/dist/field/field-description.mjs.map +1 -0
  98. package/dist/field/field-error.d.mts +30 -0
  99. package/dist/field/field-error.d.mts.map +1 -0
  100. package/dist/field/field-error.mjs +42 -0
  101. package/dist/field/field-error.mjs.map +1 -0
  102. package/dist/field/field-label.d.mts +25 -0
  103. package/dist/field/field-label.d.mts.map +1 -0
  104. package/dist/field/field-label.mjs +46 -0
  105. package/dist/field/field-label.mjs.map +1 -0
  106. package/dist/field/field-root.d.mts +53 -0
  107. package/dist/field/field-root.d.mts.map +1 -0
  108. package/dist/field/field-root.mjs +317 -0
  109. package/dist/field/field-root.mjs.map +1 -0
  110. package/dist/field/index.d.mts +18 -0
  111. package/dist/field/index.d.mts.map +1 -0
  112. package/dist/field/index.mjs +17 -0
  113. package/dist/field/index.mjs.map +1 -0
  114. package/dist/form/form-root.d.mts +58 -0
  115. package/dist/form/form-root.d.mts.map +1 -0
  116. package/dist/form/form-root.mjs +146 -0
  117. package/dist/form/form-root.mjs.map +1 -0
  118. package/dist/form/form-submit.d.mts +31 -0
  119. package/dist/form/form-submit.d.mts.map +1 -0
  120. package/dist/form/form-submit.mjs +52 -0
  121. package/dist/form/form-submit.mjs.map +1 -0
  122. package/dist/form/index.d.mts +11 -0
  123. package/dist/form/index.d.mts.map +1 -0
  124. package/dist/form/index.mjs +11 -0
  125. package/dist/form/index.mjs.map +1 -0
  126. package/dist/index.d.mts +46 -2
  127. package/dist/index.mjs +8 -1
  128. package/dist/internal/content-focus-probe.mjs +42 -0
  129. package/dist/internal/content-focus-probe.mjs.map +1 -0
  130. package/dist/internal/field-context.d.mts +19 -0
  131. package/dist/internal/field-context.d.mts.map +1 -0
  132. package/dist/internal/field-context.mjs +14 -0
  133. package/dist/internal/field-context.mjs.map +1 -0
  134. package/dist/internal/focus-first-invalid.mjs +67 -0
  135. package/dist/internal/focus-first-invalid.mjs.map +1 -0
  136. package/dist/internal/form-status-watcher.mjs +31 -0
  137. package/dist/internal/form-status-watcher.mjs.map +1 -0
  138. package/dist/internal/modal-registry.mjs +39 -0
  139. package/dist/internal/modal-registry.mjs.map +1 -0
  140. package/dist/internal/platform-attributes.d.mts +12 -1
  141. package/dist/internal/platform-attributes.d.mts.map +1 -1
  142. package/dist/internal/types.d.mts +11 -1
  143. package/dist/internal/types.d.mts.map +1 -1
  144. package/dist/internal/use-controllable-action.mjs +35 -13
  145. package/dist/internal/use-controllable-action.mjs.map +1 -1
  146. package/dist/internal/use-field-control.mjs +107 -0
  147. package/dist/internal/use-field-control.mjs.map +1 -0
  148. package/dist/internal/use-form-owner-event.mjs +44 -0
  149. package/dist/internal/use-form-owner-event.mjs.map +1 -0
  150. package/dist/internal/use-form-reset.mjs +12 -31
  151. package/dist/internal/use-form-reset.mjs.map +1 -1
  152. package/dist/internal/use-hydrated-mount.mjs +21 -0
  153. package/dist/internal/use-hydrated-mount.mjs.map +1 -0
  154. package/dist/internal/use-latest.mjs +32 -0
  155. package/dist/internal/use-latest.mjs.map +1 -0
  156. package/dist/internal/use-modal-registration.mjs +47 -0
  157. package/dist/internal/use-modal-registration.mjs.map +1 -0
  158. package/dist/internal/use-native-group-reconciliation.mjs +6 -5
  159. package/dist/internal/use-native-group-reconciliation.mjs.map +1 -1
  160. package/dist/internal/use-native-select-value.mjs +184 -0
  161. package/dist/internal/use-native-select-value.mjs.map +1 -0
  162. package/dist/internal/use-popover-popup.mjs +8 -22
  163. package/dist/internal/use-popover-popup.mjs.map +1 -1
  164. package/dist/internal/use-popover-root.mjs +58 -23
  165. package/dist/internal/use-popover-root.mjs.map +1 -1
  166. package/dist/internal/validity.d.mts +24 -0
  167. package/dist/internal/validity.d.mts.map +1 -0
  168. package/dist/internal/validity.mjs +108 -0
  169. package/dist/internal/validity.mjs.map +1 -0
  170. package/dist/menu/menu-group.d.mts.map +1 -1
  171. package/dist/menu/menu-group.mjs +3 -2
  172. package/dist/menu/menu-group.mjs.map +1 -1
  173. package/dist/menu/menu-radio-group.mjs +9 -4
  174. package/dist/menu/menu-radio-group.mjs.map +1 -1
  175. package/dist/menu/menu-root.mjs.map +1 -1
  176. package/dist/radio-group/radio-group-root.d.mts +4 -2
  177. package/dist/radio-group/radio-group-root.d.mts.map +1 -1
  178. package/dist/radio-group/radio-group-root.mjs +34 -8
  179. package/dist/radio-group/radio-group-root.mjs.map +1 -1
  180. package/dist/select/index.d.mts +30 -0
  181. package/dist/select/index.d.mts.map +1 -0
  182. package/dist/select/index.mjs +30 -0
  183. package/dist/select/index.mjs.map +1 -0
  184. package/dist/select/select-context.mjs +13 -0
  185. package/dist/select/select-context.mjs.map +1 -0
  186. package/dist/select/select-dom.mjs +44 -0
  187. package/dist/select/select-dom.mjs.map +1 -0
  188. package/dist/select/select-group-label.d.mts +22 -0
  189. package/dist/select/select-group-label.d.mts.map +1 -0
  190. package/dist/select/select-group-label.mjs +39 -0
  191. package/dist/select/select-group-label.mjs.map +1 -0
  192. package/dist/select/select-group.d.mts +28 -0
  193. package/dist/select/select-group.d.mts.map +1 -0
  194. package/dist/select/select-group.mjs +47 -0
  195. package/dist/select/select-group.mjs.map +1 -0
  196. package/dist/select/select-option.d.mts +42 -0
  197. package/dist/select/select-option.d.mts.map +1 -0
  198. package/dist/select/select-option.mjs +65 -0
  199. package/dist/select/select-option.mjs.map +1 -0
  200. package/dist/select/select-root.d.mts +68 -0
  201. package/dist/select/select-root.d.mts.map +1 -0
  202. package/dist/select/select-root.mjs +114 -0
  203. package/dist/select/select-root.mjs.map +1 -0
  204. package/dist/select/select-separator.d.mts +20 -0
  205. package/dist/select/select-separator.d.mts.map +1 -0
  206. package/dist/select/select-separator.mjs +30 -0
  207. package/dist/select/select-separator.mjs.map +1 -0
  208. package/dist/select/select-trigger.d.mts +29 -0
  209. package/dist/select/select-trigger.d.mts.map +1 -0
  210. package/dist/select/select-trigger.mjs +48 -0
  211. package/dist/select/select-trigger.mjs.map +1 -0
  212. package/dist/select/select-value.d.mts +31 -0
  213. package/dist/select/select-value.d.mts.map +1 -0
  214. package/dist/select/select-value.mjs +101 -0
  215. package/dist/select/select-value.mjs.map +1 -0
  216. package/dist/slider/index.d.mts +32 -0
  217. package/dist/slider/index.d.mts.map +1 -0
  218. package/dist/slider/index.mjs +27 -0
  219. package/dist/slider/index.mjs.map +1 -0
  220. package/dist/slider/slider-arithmetic.mjs +120 -0
  221. package/dist/slider/slider-arithmetic.mjs.map +1 -0
  222. package/dist/slider/slider-context.mjs +13 -0
  223. package/dist/slider/slider-context.mjs.map +1 -0
  224. package/dist/slider/slider-indicator.d.mts +22 -0
  225. package/dist/slider/slider-indicator.d.mts.map +1 -0
  226. package/dist/slider/slider-indicator.mjs +36 -0
  227. package/dist/slider/slider-indicator.mjs.map +1 -0
  228. package/dist/slider/slider-label.d.mts +23 -0
  229. package/dist/slider/slider-label.d.mts.map +1 -0
  230. package/dist/slider/slider-label.mjs +44 -0
  231. package/dist/slider/slider-label.mjs.map +1 -0
  232. package/dist/slider/slider-root.d.mts +111 -0
  233. package/dist/slider/slider-root.d.mts.map +1 -0
  234. package/dist/slider/slider-root.mjs +582 -0
  235. package/dist/slider/slider-root.mjs.map +1 -0
  236. package/dist/slider/slider-thumb.d.mts +58 -0
  237. package/dist/slider/slider-thumb.d.mts.map +1 -0
  238. package/dist/slider/slider-thumb.mjs +145 -0
  239. package/dist/slider/slider-thumb.mjs.map +1 -0
  240. package/dist/slider/slider-track.d.mts +29 -0
  241. package/dist/slider/slider-track.d.mts.map +1 -0
  242. package/dist/slider/slider-track.mjs +54 -0
  243. package/dist/slider/slider-track.mjs.map +1 -0
  244. package/dist/slider/slider-value.d.mts +34 -0
  245. package/dist/slider/slider-value.d.mts.map +1 -0
  246. package/dist/slider/slider-value.mjs +38 -0
  247. package/dist/slider/slider-value.mjs.map +1 -0
  248. package/dist/switch/switch.d.mts +5 -3
  249. package/dist/switch/switch.d.mts.map +1 -1
  250. package/dist/switch/switch.mjs +24 -8
  251. package/dist/switch/switch.mjs.map +1 -1
  252. package/dist/tabs/tabs-root.mjs +13 -3
  253. package/dist/tabs/tabs-root.mjs.map +1 -1
  254. package/dist/toast/index.d.mts +25 -0
  255. package/dist/toast/index.d.mts.map +1 -0
  256. package/dist/toast/index.mjs +26 -0
  257. package/dist/toast/index.mjs.map +1 -0
  258. package/dist/toast/toast-action.d.mts +39 -0
  259. package/dist/toast/toast-action.d.mts.map +1 -0
  260. package/dist/toast/toast-action.mjs +64 -0
  261. package/dist/toast/toast-action.mjs.map +1 -0
  262. package/dist/toast/toast-close.d.mts +25 -0
  263. package/dist/toast/toast-close.d.mts.map +1 -0
  264. package/dist/toast/toast-close.mjs +46 -0
  265. package/dist/toast/toast-close.mjs.map +1 -0
  266. package/dist/toast/toast-context.mjs +19 -0
  267. package/dist/toast/toast-context.mjs.map +1 -0
  268. package/dist/toast/toast-description.d.mts +24 -0
  269. package/dist/toast/toast-description.d.mts.map +1 -0
  270. package/dist/toast/toast-description.mjs +35 -0
  271. package/dist/toast/toast-description.mjs.map +1 -0
  272. package/dist/toast/toast-root.d.mts +43 -0
  273. package/dist/toast/toast-root.d.mts.map +1 -0
  274. package/dist/toast/toast-root.mjs +204 -0
  275. package/dist/toast/toast-root.mjs.map +1 -0
  276. package/dist/toast/toast-title.d.mts +24 -0
  277. package/dist/toast/toast-title.d.mts.map +1 -0
  278. package/dist/toast/toast-title.mjs +33 -0
  279. package/dist/toast/toast-title.mjs.map +1 -0
  280. package/dist/toast/toast-viewport.d.mts +52 -0
  281. package/dist/toast/toast-viewport.d.mts.map +1 -0
  282. package/dist/toast/toast-viewport.mjs +621 -0
  283. package/dist/toast/toast-viewport.mjs.map +1 -0
  284. package/dist/toast/toast.d.mts +119 -0
  285. package/dist/toast/toast.d.mts.map +1 -0
  286. package/dist/toast/toast.mjs +153 -0
  287. package/dist/toast/toast.mjs.map +1 -0
  288. package/package.json +7 -1
@@ -1,5 +1,6 @@
1
1
  "use client";
2
- import { addTransitionType, startTransition, useEffect, useOptimistic, useRef, useState, useTransition } from "react";
2
+ import { useLatest } from "./use-latest.mjs";
3
+ import { addTransitionType, startTransition, useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react";
3
4
  //#region src/internal/use-controllable-action.ts
4
5
  const isDev = process.env.NODE_ENV !== "production";
5
6
  function capitalize(s) {
@@ -39,8 +40,18 @@ function useControllableAction(options) {
39
40
  mounted.current = false;
40
41
  };
41
42
  }, []);
42
- function dispatch(next, details) {
43
- const prev = committed;
43
+ const props = useLatest({
44
+ committed,
45
+ isControlled,
46
+ action,
47
+ transitionTypes
48
+ });
49
+ const settle = useCallback(() => {
50
+ inFlight.current.count -= 1;
51
+ if (inFlight.current.count === 0) inFlight.current.next = void 0;
52
+ }, []);
53
+ const dispatch = useCallback((next, details) => {
54
+ const { committed: prev, isControlled, action, transitionTypes } = props.current;
44
55
  const id = ++latest.current;
45
56
  inFlight.current.count += 1;
46
57
  inFlight.current.next = next;
@@ -67,22 +78,33 @@ function useControllableAction(options) {
67
78
  settle();
68
79
  if (!isControlled && id === latest.current) startTransition(() => setInternal(next));
69
80
  });
70
- }
71
- function settle() {
72
- inFlight.current.count -= 1;
73
- if (inFlight.current.count === 0) inFlight.current.next = void 0;
74
- }
75
- function latestInFlight() {
81
+ }, [
82
+ props,
83
+ settle,
84
+ setOptimistic
85
+ ]);
86
+ const latestInFlight = useCallback(() => {
76
87
  const { count, next } = inFlight.current;
77
88
  return count > 0 ? { next } : null;
78
- }
79
- return {
89
+ }, []);
90
+ const adopt = useCallback((next) => {
91
+ if (!props.current.isControlled) setInternal(next);
92
+ }, [props]);
93
+ return useMemo(() => ({
94
+ committed,
95
+ optimistic,
96
+ pending,
97
+ dispatch,
98
+ latestInFlight,
99
+ adopt
100
+ }), [
80
101
  committed,
81
102
  optimistic,
82
103
  pending,
83
104
  dispatch,
84
- latestInFlight
85
- };
105
+ latestInFlight,
106
+ adopt
107
+ ]);
86
108
  }
87
109
  function useDevWarnings(isControlled, hasDefault, warn) {
88
110
  const initialMode = useRef(isControlled);
@@ -1 +1 @@
1
- {"version":3,"file":"use-controllable-action.mjs","names":[],"sources":["../../src/internal/use-controllable-action.ts"],"sourcesContent":["\"use client\";\nimport {\n addTransitionType,\n startTransition,\n useEffect,\n useOptimistic,\n useRef,\n useState,\n useTransition,\n} from \"react\";\nimport type { ActionDetails, ChangeAction } from \"./types.js\";\n\nexport interface UseControllableActionOptions<T, Details extends ActionDetails> {\n /** The controlled prop (`x`). `undefined` means uncontrolled. */\n controlled: T | undefined;\n /** The `defaultX` prop as the consumer passed it; `undefined` when omitted. */\n defaultValue: T | undefined;\n /** Initial uncontrolled value when neither `x` nor `defaultX` is given. */\n initialValue: T;\n /** The `xChangeAction` prop. Awaited inside this hook's transition. */\n action: ChangeAction<T, Details> | undefined;\n /**\n * View Transition types to attach to the commit that ends a dispatch. Receives the previous\n * committed value and the details object (Tabs derives next/prev direction from them).\n */\n transitionTypes?: (next: T, prev: T, details: Details) => readonly string[];\n /**\n * Optimistic reducer (conventions 3.4). Default: replace the value with `next`. Multi-item\n * states (Accordion's open set) pass a reducer keyed by item, reading the item from `details`,\n * so stacked optimistic patches re-base onto a changed committed value.\n */\n reducer?: (prev: T, next: T, details: Details) => T;\n /** Names used in development warnings. */\n warn?: { readonly component: string; readonly prop: string };\n}\n\nexport interface ControllableAction<T, Details extends ActionDetails> {\n /** Committed value (`x ?? internal`). Content regions render this. */\n readonly committed: T;\n /** Optimistic value; trigger parts render this. */\n readonly optimistic: T;\n /** An action dispatched through this hook is in flight. */\n readonly pending: boolean;\n /** Runs the action inside the hook's transition with optimistic feedback. */\n readonly dispatch: (next: T, details: Details) => void;\n /**\n * The `next` of the most recent dispatch while any dispatch's action is still unsettled, else\n * `null`. Lets a component with a browser-owned path (Accordion's find-in-page reveal) build on\n * what the user already intends instead of on the DOM. Read it from handlers and effects only.\n */\n readonly latestInFlight: () => { readonly next: T } | null;\n}\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1);\n}\n\n/** Reports an error that cannot reach a boundary (conventions 2.4: never swallow). */\nfunction report(error: unknown): void {\n if (typeof reportError === \"function\") reportError(error);\n else console.error(error);\n}\n\n/**\n * The one place the state model of conventions section 3 is implemented: committed state,\n * optimistic state (`useOptimistic`, reducer form), the component's own transition, the\n * post-`await` write of uncontrolled state, the latest-dispatch guard, and the surfacing of\n * superseded rejections (conventions 2.4).\n */\nexport function useControllableAction<T, Details extends ActionDetails = ActionDetails>(\n options: UseControllableActionOptions<T, Details>,\n): ControllableAction<T, Details> {\n const { controlled, defaultValue, initialValue, action, transitionTypes, reducer, warn } =\n options;\n\n // React keeps only the newest thenable per `useTransition`, so a rejection from a dispatch that\n // was superseded by a later one would be dropped entirely (no boundary, no `reportError`). The\n // hook catches those itself, keeps the first, and throws it from render so it reaches the same\n // nearest Error Boundary as the latest dispatch's rejection does through React.\n //\n // The throw sits before `useTransition` on purpose: React retries an errored render with every\n // pending lane, and in that retry `useTransition` unwraps the still-pending action thenable and\n // suspends, which would hold the boundary commit until the newer action settles.\n const [supersededError, setSupersededError] = useState<{ readonly error: unknown } | null>(null);\n const hasSupersededError = useRef(false);\n if (supersededError !== null) throw supersededError.error;\n\n const [internal, setInternal] = useState<T>(\n defaultValue === undefined ? initialValue : defaultValue,\n );\n const isControlled = controlled !== undefined;\n const committed = isControlled ? controlled : internal;\n const [optimistic, setOptimistic] = useOptimistic(\n committed,\n (prev: T, patch: { next: T; details: Details }) =>\n reducer ? reducer(prev, patch.next, patch.details) : patch.next,\n );\n const [pending, startOwnTransition] = useTransition();\n const latest = useRef(0);\n const inFlight = useRef<{ count: number; next: T | undefined }>({ count: 0, next: undefined });\n const mounted = useRef(false);\n\n useDevWarnings(isControlled, defaultValue !== undefined, warn);\n\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n function dispatch(next: T, details: Details): void {\n const prev = committed;\n const id = ++latest.current;\n inFlight.current.count += 1;\n inFlight.current.next = next;\n startOwnTransition(async () => {\n for (const type of transitionTypes?.(next, prev, details) ?? []) addTransitionType(type);\n setOptimistic({ next, details });\n try {\n await action?.(next, details);\n } catch (error) {\n settle();\n // The latest dispatch's rejection takes React's own route to the boundary.\n if (id === latest.current) throw error;\n // Nothing to throw to once unmounted (React drops its own thenables the same way).\n if (!mounted.current) return;\n if (hasSupersededError.current) {\n // One boundary reset per burst is enough; later errors are still reported.\n report(error);\n return;\n }\n hasSupersededError.current = true;\n // Outside the transition by now (after an await), so this is an urgent update: the owner\n // re-renders promptly and throws.\n setSupersededError({ error });\n return;\n }\n settle();\n if (!isControlled && id === latest.current) {\n startTransition(() => setInternal(next));\n }\n });\n }\n\n function settle(): void {\n inFlight.current.count -= 1;\n if (inFlight.current.count === 0) inFlight.current.next = undefined;\n }\n\n function latestInFlight(): { readonly next: T } | null {\n const { count, next } = inFlight.current;\n return count > 0 ? { next: next as T } : null;\n }\n\n return { committed, optimistic, pending, dispatch, latestInFlight };\n}\n\nfunction useDevWarnings(\n isControlled: boolean,\n hasDefault: boolean,\n warn: { readonly component: string; readonly prop: string } | undefined,\n): void {\n const initialMode = useRef(isControlled);\n const warnedBoth = useRef(false);\n const warnedSwitch = useRef(false);\n const component = warn?.component ?? \"A component\";\n const prop = warn?.prop ?? \"value\";\n\n useEffect(() => {\n if (!isDev) return;\n const defaultProp = `default${capitalize(prop)}`;\n\n if (isControlled && hasDefault && !warnedBoth.current) {\n warnedBoth.current = true;\n console.error(\n `${component}: both \\`${prop}\\` and \\`${defaultProp}\\` are set. ` +\n `Use \\`${prop}\\` for controlled mode or \\`${defaultProp}\\` for uncontrolled mode, not both.`,\n );\n }\n\n if (initialMode.current !== isControlled && !warnedSwitch.current) {\n warnedSwitch.current = true;\n const from = initialMode.current ? \"controlled\" : \"uncontrolled\";\n const to = isControlled ? \"controlled\" : \"uncontrolled\";\n console.error(\n `${component}: \\`${prop}\\` changed from ${from} to ${to}. Switching modes after mount ` +\n `is not supported; keep \\`${prop}\\` either always defined or always undefined.`,\n );\n }\n }, [component, prop, isControlled, hasDefault]);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"use-controllable-action.mjs","names":[],"sources":["../../src/internal/use-controllable-action.ts"],"sourcesContent":["\"use client\";\nimport {\n addTransitionType,\n startTransition,\n useCallback,\n useEffect,\n useMemo,\n useOptimistic,\n useRef,\n useState,\n useTransition,\n} from \"react\";\nimport type { ActionDetails, ChangeAction } from \"./types.js\";\nimport { useLatest } from \"./use-latest.js\";\n\nexport interface UseControllableActionOptions<T, Details extends ActionDetails> {\n /** The controlled prop (`x`). `undefined` means uncontrolled. */\n controlled: T | undefined;\n /** The `defaultX` prop as the consumer passed it; `undefined` when omitted. */\n defaultValue: T | undefined;\n /** Initial uncontrolled value when neither `x` nor `defaultX` is given. */\n initialValue: T;\n /** The `xChangeAction` prop. Awaited inside this hook's transition. */\n action: ChangeAction<T, Details> | undefined;\n /**\n * View Transition types to attach to the commit that ends a dispatch. Receives the previous\n * committed value and the details object (Tabs derives next/prev direction from them).\n */\n transitionTypes?: (next: T, prev: T, details: Details) => readonly string[];\n /**\n * Optimistic reducer (conventions 3.4). Default: replace the value with `next`. Multi-item\n * states (Accordion's open set) pass a reducer keyed by item, reading the item from `details`,\n * so stacked optimistic patches re-base onto a changed committed value.\n */\n reducer?: (prev: T, next: T, details: Details) => T;\n /** Names used in development warnings. */\n warn?: { readonly component: string; readonly prop: string };\n}\n\nexport interface ControllableAction<T, Details extends ActionDetails> {\n /** Committed value (`x ?? internal`). Content regions render this. */\n readonly committed: T;\n /** Optimistic value; trigger parts render this. */\n readonly optimistic: T;\n /** An action dispatched through this hook is in flight. */\n readonly pending: boolean;\n /** Runs the action inside the hook's transition with optimistic feedback. */\n readonly dispatch: (next: T, details: Details) => void;\n /**\n * The `next` of the most recent dispatch while any dispatch's action is still unsettled, else\n * `null`. Lets a component with a browser-owned path (Accordion's find-in-page reveal) build on\n * what the user already intends instead of on the DOM. Read it from handlers and effects only.\n */\n readonly latestInFlight: () => { readonly next: T } | null;\n /**\n * Uncontrolled mode only (a no-op when controlled): writes the committed value without calling\n * the action, without an optimistic step and without a transition. For a component that adopts\n * a default the browser chose (Select with neither `value` nor `defaultValue` takes the first\n * enabled option, design/components/select.md 5). Call it from a layout effect so the write is\n * flushed before paint.\n */\n readonly adopt: (next: T) => void;\n}\n\nconst isDev = process.env.NODE_ENV !== \"production\";\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1);\n}\n\n/** Reports an error that cannot reach a boundary (conventions 2.4: never swallow). */\nfunction report(error: unknown): void {\n if (typeof reportError === \"function\") reportError(error);\n else console.error(error);\n}\n\n/**\n * The one place the state model of conventions section 3 is implemented: committed state,\n * optimistic state (`useOptimistic`, reducer form), the component's own transition, the\n * post-`await` write of uncontrolled state, the latest-dispatch guard, and the surfacing of\n * superseded rejections (conventions 2.4).\n */\nexport function useControllableAction<T, Details extends ActionDetails = ActionDetails>(\n options: UseControllableActionOptions<T, Details>,\n): ControllableAction<T, Details> {\n const { controlled, defaultValue, initialValue, action, transitionTypes, reducer, warn } =\n options;\n\n // React keeps only the newest thenable per `useTransition`, so a rejection from a dispatch that\n // was superseded by a later one would be dropped entirely (no boundary, no `reportError`). The\n // hook catches those itself, keeps the first, and throws it from render so it reaches the same\n // nearest Error Boundary as the latest dispatch's rejection does through React.\n //\n // The throw sits before `useTransition` on purpose: React retries an errored render with every\n // pending lane, and in that retry `useTransition` unwraps the still-pending action thenable and\n // suspends, which would hold the boundary commit until the newer action settles.\n const [supersededError, setSupersededError] = useState<{ readonly error: unknown } | null>(null);\n const hasSupersededError = useRef(false);\n if (supersededError !== null) throw supersededError.error;\n\n const [internal, setInternal] = useState<T>(\n defaultValue === undefined ? initialValue : defaultValue,\n );\n const isControlled = controlled !== undefined;\n const committed = isControlled ? controlled : internal;\n const [optimistic, setOptimistic] = useOptimistic(\n committed,\n (prev: T, patch: { next: T; details: Details }) =>\n reducer ? reducer(prev, patch.next, patch.details) : patch.next,\n );\n const [pending, startOwnTransition] = useTransition();\n const latest = useRef(0);\n const inFlight = useRef<{ count: number; next: T | undefined }>({ count: 0, next: undefined });\n const mounted = useRef(false);\n\n useDevWarnings(isControlled, defaultValue !== undefined, warn);\n\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // The functions below keep one identity for the hook's lifetime, so the returned object changes\n // only with `committed`, `optimistic` and `pending`, and the components' memoised context values\n // are reused across renders that change none of them (#48). They read the props of the last\n // committed render through `props`; they run from handlers, actions and effects, never render.\n const props = useLatest({ committed, isControlled, action, transitionTypes });\n\n const settle = useCallback((): void => {\n inFlight.current.count -= 1;\n if (inFlight.current.count === 0) inFlight.current.next = undefined;\n }, []);\n\n const dispatch = useCallback(\n (next: T, details: Details): void => {\n // Read once, at the call, as the per-render closure did: the action, the mode and the\n // previous value belong to the UI the user acted on, even after the `await`.\n const { committed: prev, isControlled, action, transitionTypes } = props.current;\n const id = ++latest.current;\n inFlight.current.count += 1;\n inFlight.current.next = next;\n startOwnTransition(async () => {\n for (const type of transitionTypes?.(next, prev, details) ?? []) addTransitionType(type);\n setOptimistic({ next, details });\n try {\n await action?.(next, details);\n } catch (error) {\n settle();\n // The latest dispatch's rejection takes React's own route to the boundary.\n if (id === latest.current) throw error;\n // Nothing to throw to once unmounted (React drops its own thenables the same way).\n if (!mounted.current) return;\n if (hasSupersededError.current) {\n // One boundary reset per burst is enough; later errors are still reported.\n report(error);\n return;\n }\n hasSupersededError.current = true;\n // Outside the transition by now (after an await), so this is an urgent update: the\n // owner re-renders promptly and throws.\n setSupersededError({ error });\n return;\n }\n settle();\n if (!isControlled && id === latest.current) {\n startTransition(() => setInternal(next));\n }\n });\n },\n [props, settle, setOptimistic],\n );\n\n const latestInFlight = useCallback((): { readonly next: T } | null => {\n const { count, next } = inFlight.current;\n return count > 0 ? { next: next as T } : null;\n }, []);\n\n const adopt = useCallback(\n (next: T): void => {\n if (!props.current.isControlled) setInternal(next);\n },\n [props],\n );\n\n return useMemo(\n () => ({ committed, optimistic, pending, dispatch, latestInFlight, adopt }),\n [committed, optimistic, pending, dispatch, latestInFlight, adopt],\n );\n}\n\nfunction useDevWarnings(\n isControlled: boolean,\n hasDefault: boolean,\n warn: { readonly component: string; readonly prop: string } | undefined,\n): void {\n const initialMode = useRef(isControlled);\n const warnedBoth = useRef(false);\n const warnedSwitch = useRef(false);\n const component = warn?.component ?? \"A component\";\n const prop = warn?.prop ?? \"value\";\n\n useEffect(() => {\n if (!isDev) return;\n const defaultProp = `default${capitalize(prop)}`;\n\n if (isControlled && hasDefault && !warnedBoth.current) {\n warnedBoth.current = true;\n console.error(\n `${component}: both \\`${prop}\\` and \\`${defaultProp}\\` are set. ` +\n `Use \\`${prop}\\` for controlled mode or \\`${defaultProp}\\` for uncontrolled mode, not both.`,\n );\n }\n\n if (initialMode.current !== isControlled && !warnedSwitch.current) {\n warnedSwitch.current = true;\n const from = initialMode.current ? \"controlled\" : \"uncontrolled\";\n const to = isControlled ? \"controlled\" : \"uncontrolled\";\n console.error(\n `${component}: \\`${prop}\\` changed from ${from} to ${to}. Switching modes after mount ` +\n `is not supported; keep \\`${prop}\\` either always defined or always undefined.`,\n );\n }\n }, [component, prop, isControlled, hasDefault]);\n}\n"],"mappings":"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"}
@@ -0,0 +1,107 @@
1
+ "use client";
2
+ import { dataAttr } from "./data-attr.mjs";
3
+ import { mergeRefs } from "./merge-props.mjs";
4
+ import { useDevWarning } from "./use-dev-warning.mjs";
5
+ import { FieldContext } from "./field-context.mjs";
6
+ import { use, useCallback, useLayoutEffect, useMemo, useRef } from "react";
7
+ //#region src/internal/use-field-control.ts
8
+ /**
9
+ * The owned props of a labelable native control (`<input>`, `<select>`) inside a Field: the
10
+ * ARIA wiring plus `name` and `required` ("own or the Field's"). Empty outside a Field, so the
11
+ * consumer's own `id` / `name` / `required` keep passing through untouched there.
12
+ */
13
+ function fieldInputProps(field) {
14
+ if (!field.inField) return {};
15
+ return {
16
+ ...field.attributes,
17
+ name: field.name,
18
+ required: field.required
19
+ };
20
+ }
21
+ /** `mergeRefs` with a stable identity while both refs keep theirs. */
22
+ function useMergedRef(a, b) {
23
+ return useMemo(() => b === void 0 ? a : a === void 0 ? b : mergeRefs(a, b), [a, b]);
24
+ }
25
+ /**
26
+ * The contract between a Field and its control (design/components/field-form.md 7.1), called by
27
+ * `Field.Control` and by the owner part of every FUNSTACK form control. Outside a `Field.Root` it
28
+ * returns the control's own props and nothing to merge.
29
+ *
30
+ * Inside a Field: registers the element from the returned ref (the Field attaches its listeners
31
+ * and reads validity there; a group's `<fieldset>` makes `Field.Label` drop `for`), resolves "own
32
+ * or the Field's" for `name` / `disabled` / `required`, and returns the owned ARIA wiring.
33
+ */
34
+ function useFieldControl(options) {
35
+ const { labelable, name, disabled = false, required = false, id, component } = options;
36
+ const field = use(FieldContext);
37
+ const registerControl = field?.registerControl;
38
+ const ref = useCallback((element) => {
39
+ if (registerControl === void 0) return () => {};
40
+ return registerControl({
41
+ element,
42
+ labelable,
43
+ required,
44
+ disabled
45
+ });
46
+ }, [
47
+ registerControl,
48
+ labelable,
49
+ required,
50
+ disabled
51
+ ]);
52
+ useDevWarning(field !== null && id !== void 0, `${component}: the \`id\` prop is ignored inside a Field.Root; the Field owns the control's id so that Field.Label can point at it.`);
53
+ useDevWarning(field !== null && name !== void 0 && field.name !== void 0 && name !== field.name, `${component}: its \`name\` ("${name}") differs from the Field.Root's ("${field?.name}"). The Field reads server errors and \`validate\`'s value by the Field's name.`);
54
+ if (field === null) return {
55
+ inField: false,
56
+ ref: void 0,
57
+ id,
58
+ name,
59
+ disabled,
60
+ required,
61
+ attributes: {},
62
+ state: void 0
63
+ };
64
+ const { ids, state } = field;
65
+ const attributes = {
66
+ "aria-describedby": state.invalid ? `${ids.description} ${ids.error}` : ids.description,
67
+ "aria-invalid": state.invalid ? "true" : "false",
68
+ "data-invalid": dataAttr(state.invalid)
69
+ };
70
+ if (labelable) attributes.id = ids.control;
71
+ else attributes["aria-labelledby"] = ids.label;
72
+ return {
73
+ inField: true,
74
+ ref,
75
+ id: labelable ? ids.control : void 0,
76
+ name: name ?? field.name,
77
+ disabled: disabled || field.disabled,
78
+ required: required || field.required,
79
+ attributes,
80
+ state
81
+ };
82
+ }
83
+ /**
84
+ * Tells the enclosing Field (if any) that the control's value changed, after the commit that
85
+ * wrote it to the DOM (design/components/field-form.md 2.1: `validate` and the mirror follow the
86
+ * value). Needed where the DOM changes without an `input` / `change` event the Field hears: a
87
+ * Combobox selection (React writes the hidden input), a CheckboxGroup's derived `required`, a
88
+ * Parent checking Items, a rejected action rolling the value back. Nothing at mount.
89
+ *
90
+ * `takeEdit` is read when the value changed: `true` when the user made this change (a shown
91
+ * server error is dismissed, like an `input` event would). Native controls whose own events
92
+ * already dismiss pass nothing.
93
+ */
94
+ function useFieldValueChange(value, takeEdit) {
95
+ const notifyChange = use(FieldContext)?.notifyChange;
96
+ const previous = useRef(value);
97
+ useLayoutEffect(() => {
98
+ if (Object.is(previous.current, value)) return;
99
+ previous.current = value;
100
+ const edit = takeEdit?.() ?? false;
101
+ notifyChange?.(edit);
102
+ });
103
+ }
104
+ //#endregion
105
+ export { fieldInputProps, useFieldControl, useFieldValueChange, useMergedRef };
106
+
107
+ //# sourceMappingURL=use-field-control.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-field-control.mjs","names":[],"sources":["../../src/internal/use-field-control.ts"],"sourcesContent":["\"use client\";\nimport { use, useCallback, useLayoutEffect, useMemo, useRef } from \"react\";\nimport type { Ref, RefCallback } from \"react\";\nimport { dataAttr } from \"./data-attr.js\";\nimport { FieldContext } from \"./field-context.js\";\nimport type { FieldState } from \"./field-context.js\";\nimport { mergeRefs } from \"./merge-props.js\";\nimport { useDevWarning } from \"./use-dev-warning.js\";\n\nexport interface UseFieldControlOptions {\n /** `<label for>` can target the element (input, select, textarea). Groups pass `false`. */\n readonly labelable: boolean;\n /** The control's own props, so \"own or the Field's\" can be computed. */\n readonly name?: string | undefined;\n readonly disabled?: boolean | undefined;\n readonly required?: boolean | undefined;\n /** The consumer's own `id` prop: ignored inside a Field (the Field owns it), with a warning. */\n readonly id?: string | undefined;\n /** Dev warning tag. */\n readonly component: string;\n}\n\n/** The ARIA and state attributes a part spreads into its owned layer. */\nexport interface FieldControlAttributes {\n /** Labelable controls only: the id `Field.Label`'s `for` points at. */\n id?: string;\n \"aria-labelledby\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: \"true\" | \"false\";\n \"data-invalid\"?: \"\" | undefined;\n}\n\nexport interface FieldControlBinding {\n /** The control is inside a `Field.Root`. */\n readonly inField: boolean;\n /** Merge into the part's library ref: registers the element with the Field. `undefined` outside a Field. */\n readonly ref: RefCallback<HTMLElement> | undefined;\n /** The control id the Field's Label points at (labelable controls inside a Field), else the consumer's. */\n readonly id: string | undefined;\n /** Own `name` ?? the Field's. */\n readonly name: string | undefined;\n /** Own `disabled` || the Field's. */\n readonly disabled: boolean;\n /** Own `required` || the Field's. */\n readonly required: boolean;\n /**\n * Owned attributes: `id` (labelable controls), `aria-invalid`, `data-invalid`,\n * `aria-labelledby` (groups) and `aria-describedby` (both token-merged by the owned layer, the\n * consumer's tokens first). Empty outside a Field, so nothing is owned there.\n */\n readonly attributes: FieldControlAttributes;\n /** The Field's state, `undefined` outside a Field. */\n readonly state: FieldState | undefined;\n}\n\n/**\n * The owned props of a labelable native control (`<input>`, `<select>`) inside a Field: the\n * ARIA wiring plus `name` and `required` (\"own or the Field's\"). Empty outside a Field, so the\n * consumer's own `id` / `name` / `required` keep passing through untouched there.\n */\nexport function fieldInputProps(field: FieldControlBinding): FieldControlAttributes & {\n name?: string | undefined;\n required?: boolean;\n} {\n if (!field.inField) return {};\n return { ...field.attributes, name: field.name, required: field.required };\n}\n\n/** `mergeRefs` with a stable identity while both refs keep theirs. */\nexport function useMergedRef<T>(\n a: Ref<T> | undefined,\n b: Ref<T> | undefined,\n): RefCallback<T> | Ref<T> | undefined {\n return useMemo(() => (b === undefined ? a : a === undefined ? b : mergeRefs(a, b)), [a, b]);\n}\n\n/**\n * The contract between a Field and its control (design/components/field-form.md 7.1), called by\n * `Field.Control` and by the owner part of every FUNSTACK form control. Outside a `Field.Root` it\n * returns the control's own props and nothing to merge.\n *\n * Inside a Field: registers the element from the returned ref (the Field attaches its listeners\n * and reads validity there; a group's `<fieldset>` makes `Field.Label` drop `for`), resolves \"own\n * or the Field's\" for `name` / `disabled` / `required`, and returns the owned ARIA wiring.\n */\nexport function useFieldControl(options: UseFieldControlOptions): FieldControlBinding {\n const { labelable, name, disabled = false, required = false, id, component } = options;\n const field = use(FieldContext);\n const registerControl = field?.registerControl;\n\n const ref = useCallback(\n (element: HTMLElement) => {\n if (registerControl === undefined) return () => {};\n return registerControl({ element, labelable, required, disabled });\n },\n [registerControl, labelable, required, disabled],\n );\n\n useDevWarning(\n field !== null && id !== undefined,\n `${component}: the \\`id\\` prop is ignored inside a Field.Root; the Field owns the control's ` +\n \"id so that Field.Label can point at it.\",\n );\n useDevWarning(\n field !== null && name !== undefined && field.name !== undefined && name !== field.name,\n `${component}: its \\`name\\` (\"${name}\") differs from the Field.Root's (\"${field?.name}\"). ` +\n \"The Field reads server errors and `validate`'s value by the Field's name.\",\n );\n\n if (field === null) {\n return {\n inField: false,\n ref: undefined,\n id,\n name,\n disabled,\n required,\n attributes: {},\n state: undefined,\n };\n }\n\n const { ids, state } = field;\n const attributes: FieldControlAttributes = {\n \"aria-describedby\": state.invalid ? `${ids.description} ${ids.error}` : ids.description,\n \"aria-invalid\": state.invalid ? \"true\" : \"false\",\n \"data-invalid\": dataAttr(state.invalid),\n };\n if (labelable) attributes.id = ids.control;\n else attributes[\"aria-labelledby\"] = ids.label;\n\n return {\n inField: true,\n ref: ref as RefCallback<HTMLElement>,\n id: labelable ? ids.control : undefined,\n name: name ?? field.name,\n disabled: disabled || field.disabled,\n required: required || field.required,\n attributes,\n state,\n };\n}\n\n/**\n * Tells the enclosing Field (if any) that the control's value changed, after the commit that\n * wrote it to the DOM (design/components/field-form.md 2.1: `validate` and the mirror follow the\n * value). Needed where the DOM changes without an `input` / `change` event the Field hears: a\n * Combobox selection (React writes the hidden input), a CheckboxGroup's derived `required`, a\n * Parent checking Items, a rejected action rolling the value back. Nothing at mount.\n *\n * `takeEdit` is read when the value changed: `true` when the user made this change (a shown\n * server error is dismissed, like an `input` event would). Native controls whose own events\n * already dismiss pass nothing.\n */\nexport function useFieldValueChange(value: unknown, takeEdit?: () => boolean): void {\n const notifyChange = use(FieldContext)?.notifyChange;\n const previous = useRef(value);\n // No deps: compares with the last committed value after every commit of the caller.\n useLayoutEffect(() => {\n if (Object.is(previous.current, value)) return;\n previous.current = value;\n const edit = takeEdit?.() ?? false;\n notifyChange?.(edit);\n });\n}\n"],"mappings":";;;;;;;;;;;;AA4DA,SAAgB,gBAAgB,OAG9B;CACA,IAAI,CAAC,MAAM,SAAS,OAAO,CAAC;CAC5B,OAAO;EAAE,GAAG,MAAM;EAAY,MAAM,MAAM;EAAM,UAAU,MAAM;CAAS;AAC3E;;AAGA,SAAgB,aACd,GACA,GACqC;CACrC,OAAO,cAAe,MAAM,KAAA,IAAY,IAAI,MAAM,KAAA,IAAY,IAAI,UAAU,GAAG,CAAC,GAAI,CAAC,GAAG,CAAC,CAAC;AAC5F;;;;;;;;;;AAWA,SAAgB,gBAAgB,SAAsD;CACpF,MAAM,EAAE,WAAW,MAAM,WAAW,OAAO,WAAW,OAAO,IAAI,cAAc;CAC/E,MAAM,QAAQ,IAAI,YAAY;CAC9B,MAAM,kBAAkB,OAAO;CAE/B,MAAM,MAAM,aACT,YAAyB;EACxB,IAAI,oBAAoB,KAAA,GAAW,aAAa,CAAC;EACjD,OAAO,gBAAgB;GAAE;GAAS;GAAW;GAAU;EAAS,CAAC;CACnE,GACA;EAAC;EAAiB;EAAW;EAAU;CAAQ,CACjD;CAEA,cACE,UAAU,QAAQ,OAAO,KAAA,GACzB,GAAG,UAAU,uHAEf;CACA,cACE,UAAU,QAAQ,SAAS,KAAA,KAAa,MAAM,SAAS,KAAA,KAAa,SAAS,MAAM,MACnF,GAAG,UAAU,mBAAmB,KAAK,qCAAqC,OAAO,KAAK,gFAExF;CAEA,IAAI,UAAU,MACZ,OAAO;EACL,SAAS;EACT,KAAK,KAAA;EACL;EACA;EACA;EACA;EACA,YAAY,CAAC;EACb,OAAO,KAAA;CACT;CAGF,MAAM,EAAE,KAAK,UAAU;CACvB,MAAM,aAAqC;EACzC,oBAAoB,MAAM,UAAU,GAAG,IAAI,YAAY,GAAG,IAAI,UAAU,IAAI;EAC5E,gBAAgB,MAAM,UAAU,SAAS;EACzC,gBAAgB,SAAS,MAAM,OAAO;CACxC;CACA,IAAI,WAAW,WAAW,KAAK,IAAI;MAC9B,WAAW,qBAAqB,IAAI;CAEzC,OAAO;EACL,SAAS;EACJ;EACL,IAAI,YAAY,IAAI,UAAU,KAAA;EAC9B,MAAM,QAAQ,MAAM;EACpB,UAAU,YAAY,MAAM;EAC5B,UAAU,YAAY,MAAM;EAC5B;EACA;CACF;AACF;;;;;;;;;;;;AAaA,SAAgB,oBAAoB,OAAgB,UAAgC;CAClF,MAAM,eAAe,IAAI,YAAY,CAAC,EAAE;CACxC,MAAM,WAAW,OAAO,KAAK;CAE7B,sBAAsB;EACpB,IAAI,OAAO,GAAG,SAAS,SAAS,KAAK,GAAG;EACxC,SAAS,UAAU;EACnB,MAAM,OAAO,WAAW,KAAK;EAC7B,eAAe,IAAI;CACrB,CAAC;AACH"}
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { useEffectEvent, useLayoutEffect, useRef } from "react";
3
+ //#region src/internal/use-form-owner-event.ts
4
+ /**
5
+ * Stable subscription to `type` on the form owner of `ref.current` (conventions 3.8 rule 4,
6
+ * generalised for Field, design/components/field-form.md 14).
7
+ *
8
+ * - The owner (`element.form`, which honours the `form` attribute) is re-read after every commit,
9
+ * but the listener is moved only when the owner changed. It is never removed and re-added on an
10
+ * ordinary re-render: React's automatic reset after a successful `<form action>` runs in the
11
+ * commit's mutation phase, between an effect's cleanup and its next run, so a subscription that
12
+ * followed every commit would miss it.
13
+ * - `handler` always sees the latest committed render's values (`useEffectEvent`).
14
+ * - `ref.current` may be any element: one without a `form` property has no owner.
15
+ */
16
+ function useFormOwnerEvent(ref, type, handler) {
17
+ const handle = useEffectEvent((event, form) => {
18
+ handler(event, form);
19
+ });
20
+ const subscription = useRef(null);
21
+ useLayoutEffect(() => {
22
+ const form = ref.current?.form ?? null;
23
+ const current = subscription.current;
24
+ if (current !== null && current.form === form) return;
25
+ if (current !== null) current.form.removeEventListener(type, current.listener);
26
+ subscription.current = null;
27
+ if (form === null) return;
28
+ const listener = (event) => handle(event, form);
29
+ form.addEventListener(type, listener);
30
+ subscription.current = {
31
+ form,
32
+ listener
33
+ };
34
+ });
35
+ useLayoutEffect(() => () => {
36
+ const current = subscription.current;
37
+ if (current !== null) current.form.removeEventListener(type, current.listener);
38
+ subscription.current = null;
39
+ }, [type]);
40
+ }
41
+ //#endregion
42
+ export { useFormOwnerEvent };
43
+
44
+ //# sourceMappingURL=use-form-owner-event.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-form-owner-event.mjs","names":[],"sources":["../../src/internal/use-form-owner-event.ts"],"sourcesContent":["\"use client\";\nimport { useEffectEvent, useLayoutEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\n\n/** A form-associated element whose form owner the hook follows. */\nexport type FormAssociatedElement =\n | HTMLInputElement\n | HTMLFieldSetElement\n | HTMLSelectElement\n | HTMLTextAreaElement;\n\ninterface Subscription {\n readonly form: HTMLFormElement;\n readonly listener: (event: Event) => void;\n}\n\n/**\n * Stable subscription to `type` on the form owner of `ref.current` (conventions 3.8 rule 4,\n * generalised for Field, design/components/field-form.md 14).\n *\n * - The owner (`element.form`, which honours the `form` attribute) is re-read after every commit,\n * but the listener is moved only when the owner changed. It is never removed and re-added on an\n * ordinary re-render: React's automatic reset after a successful `<form action>` runs in the\n * commit's mutation phase, between an effect's cleanup and its next run, so a subscription that\n * followed every commit would miss it.\n * - `handler` always sees the latest committed render's values (`useEffectEvent`).\n * - `ref.current` may be any element: one without a `form` property has no owner.\n */\nexport function useFormOwnerEvent(\n ref: RefObject<Element | null>,\n type: string,\n handler: (event: Event, form: HTMLFormElement) => void,\n): void {\n const handle = useEffectEvent((event: Event, form: HTMLFormElement) => {\n handler(event, form);\n });\n const subscription = useRef<Subscription | null>(null);\n\n // No deps on purpose: follows the form owner after every commit (a changed `form` attribute, the\n // element moved to another form), but re-subscribes only when the owner actually changed.\n useLayoutEffect(() => {\n const element = ref.current as Partial<FormAssociatedElement> | null;\n const form = element?.form ?? null;\n const current = subscription.current;\n if (current !== null && current.form === form) return;\n if (current !== null) current.form.removeEventListener(type, current.listener);\n subscription.current = null;\n if (form === null) return;\n const listener = (event: Event) => handle(event, form);\n form.addEventListener(type, listener);\n subscription.current = { form, listener };\n });\n\n // The only place the subscription is torn down: unmount (and StrictMode's simulated unmount,\n // after which the effect above subscribes again).\n useLayoutEffect(\n () => () => {\n const current = subscription.current;\n if (current !== null) current.form.removeEventListener(type, current.listener);\n subscription.current = null;\n },\n [type],\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AA4BA,SAAgB,kBACd,KACA,MACA,SACM;CACN,MAAM,SAAS,gBAAgB,OAAc,SAA0B;EACrE,QAAQ,OAAO,IAAI;CACrB,CAAC;CACD,MAAM,eAAe,OAA4B,IAAI;CAIrD,sBAAsB;EAEpB,MAAM,OADU,IAAI,SACE,QAAQ;EAC9B,MAAM,UAAU,aAAa;EAC7B,IAAI,YAAY,QAAQ,QAAQ,SAAS,MAAM;EAC/C,IAAI,YAAY,MAAM,QAAQ,KAAK,oBAAoB,MAAM,QAAQ,QAAQ;EAC7E,aAAa,UAAU;EACvB,IAAI,SAAS,MAAM;EACnB,MAAM,YAAY,UAAiB,OAAO,OAAO,IAAI;EACrD,KAAK,iBAAiB,MAAM,QAAQ;EACpC,aAAa,UAAU;GAAE;GAAM;EAAS;CAC1C,CAAC;CAID,4BACc;EACV,MAAM,UAAU,aAAa;EAC7B,IAAI,YAAY,MAAM,QAAQ,KAAK,oBAAoB,MAAM,QAAQ,QAAQ;EAC7E,aAAa,UAAU;CACzB,GACA,CAAC,IAAI,CACP;AACF"}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
- import { useEffectEvent, useLayoutEffect, useRef } from "react";
2
+ import { useLatest } from "./use-latest.mjs";
3
+ import { useFormOwnerEvent } from "./use-form-owner-event.mjs";
3
4
  //#region src/internal/use-form-reset.ts
4
5
  /**
5
6
  * Stable `reset` subscription on the form owner of `ref.current` (conventions 3.8).
@@ -8,41 +9,21 @@ import { useEffectEvent, useLayoutEffect, useRef } from "react";
8
9
  * but the listener is moved only when the owner changed. It is never removed and re-added on an
9
10
  * ordinary re-render: React's automatic reset after a successful `<form action>` runs in the
10
11
  * commit's mutation phase, between an effect's cleanup and its next run, so a subscription that
11
- * followed every commit would miss it (rule 4).
12
+ * followed every commit would miss it (rule 4). Built on `useFormOwnerEvent`.
12
13
  * - The `reset` event fires before the reset algorithm, so `onReset` is called in a microtask,
13
14
  * when the inputs hold their reset values (rule 5), and only if no listener cancelled the reset
14
15
  * (rule 7).
15
- * - `onReset` always sees the latest committed render's values (`useEffectEvent`).
16
+ * - `onReset` always sees the latest committed render's values.
16
17
  */
17
- function useFormReset(ref, onReset) {
18
- const handleReset = useEffectEvent((event) => {
19
- onReset(event);
18
+ function useFormReset(ref, onReset, onResetEvent) {
19
+ const latestOnReset = useLatest(onReset);
20
+ useFormOwnerEvent(ref, "reset", (event) => {
21
+ onResetEvent?.(event);
22
+ queueMicrotask(() => {
23
+ if (event.defaultPrevented) return;
24
+ latestOnReset.current(event);
25
+ });
20
26
  });
21
- const subscription = useRef(null);
22
- useLayoutEffect(() => {
23
- const form = ref.current?.form ?? null;
24
- const current = subscription.current;
25
- if (current !== null && current.form === form) return;
26
- if (current !== null) current.form.removeEventListener("reset", current.listener);
27
- subscription.current = null;
28
- if (form === null) return;
29
- const listener = (event) => {
30
- queueMicrotask(() => {
31
- if (event.defaultPrevented) return;
32
- handleReset(event);
33
- });
34
- };
35
- form.addEventListener("reset", listener);
36
- subscription.current = {
37
- form,
38
- listener
39
- };
40
- });
41
- useLayoutEffect(() => () => {
42
- const current = subscription.current;
43
- if (current !== null) current.form.removeEventListener("reset", current.listener);
44
- subscription.current = null;
45
- }, []);
46
27
  }
47
28
  //#endregion
48
29
  export { useFormReset };
@@ -1 +1 @@
1
- {"version":3,"file":"use-form-reset.mjs","names":[],"sources":["../../src/internal/use-form-reset.ts"],"sourcesContent":["\"use client\";\nimport { useEffectEvent, useLayoutEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\n\n/** A form-associated element whose form owner the hook follows. */\nexport type FormAssociatedElement = HTMLInputElement | HTMLFieldSetElement;\n\ninterface Subscription {\n readonly form: HTMLFormElement;\n readonly listener: (event: Event) => void;\n}\n\n/**\n * Stable `reset` subscription on the form owner of `ref.current` (conventions 3.8).\n *\n * - The owner (`element.form`, which honours the `form` attribute) is re-read after every commit,\n * but the listener is moved only when the owner changed. It is never removed and re-added on an\n * ordinary re-render: React's automatic reset after a successful `<form action>` runs in the\n * commit's mutation phase, between an effect's cleanup and its next run, so a subscription that\n * followed every commit would miss it (rule 4).\n * - The `reset` event fires before the reset algorithm, so `onReset` is called in a microtask,\n * when the inputs hold their reset values (rule 5), and only if no listener cancelled the reset\n * (rule 7).\n * - `onReset` always sees the latest committed render's values (`useEffectEvent`).\n */\nexport function useFormReset(\n ref: RefObject<FormAssociatedElement | null>,\n onReset: (event: Event) => void,\n): void {\n const handleReset = useEffectEvent((event: Event) => {\n onReset(event);\n });\n const subscription = useRef<Subscription | null>(null);\n\n // No deps on purpose: follows the form owner after every commit (a changed `form` attribute, the\n // element moved to another form), but re-subscribes only when the owner actually changed.\n useLayoutEffect(() => {\n const form = ref.current?.form ?? null;\n const current = subscription.current;\n if (current !== null && current.form === form) return;\n if (current !== null) current.form.removeEventListener(\"reset\", current.listener);\n subscription.current = null;\n if (form === null) return;\n const listener = (event: Event) => {\n queueMicrotask(() => {\n if (event.defaultPrevented) return;\n handleReset(event);\n });\n };\n form.addEventListener(\"reset\", listener);\n subscription.current = { form, listener };\n });\n\n // The only place the subscription is torn down: unmount (and StrictMode's simulated unmount,\n // after which the effect above subscribes again).\n useLayoutEffect(\n () => () => {\n const current = subscription.current;\n if (current !== null) current.form.removeEventListener(\"reset\", current.listener);\n subscription.current = null;\n },\n [],\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAyBA,SAAgB,aACd,KACA,SACM;CACN,MAAM,cAAc,gBAAgB,UAAiB;EACnD,QAAQ,KAAK;CACf,CAAC;CACD,MAAM,eAAe,OAA4B,IAAI;CAIrD,sBAAsB;EACpB,MAAM,OAAO,IAAI,SAAS,QAAQ;EAClC,MAAM,UAAU,aAAa;EAC7B,IAAI,YAAY,QAAQ,QAAQ,SAAS,MAAM;EAC/C,IAAI,YAAY,MAAM,QAAQ,KAAK,oBAAoB,SAAS,QAAQ,QAAQ;EAChF,aAAa,UAAU;EACvB,IAAI,SAAS,MAAM;EACnB,MAAM,YAAY,UAAiB;GACjC,qBAAqB;IACnB,IAAI,MAAM,kBAAkB;IAC5B,YAAY,KAAK;GACnB,CAAC;EACH;EACA,KAAK,iBAAiB,SAAS,QAAQ;EACvC,aAAa,UAAU;GAAE;GAAM;EAAS;CAC1C,CAAC;CAID,4BACc;EACV,MAAM,UAAU,aAAa;EAC7B,IAAI,YAAY,MAAM,QAAQ,KAAK,oBAAoB,SAAS,QAAQ,QAAQ;EAChF,aAAa,UAAU;CACzB,GACA,CAAC,CACH;AACF"}
1
+ {"version":3,"file":"use-form-reset.mjs","names":[],"sources":["../../src/internal/use-form-reset.ts"],"sourcesContent":["\"use client\";\nimport type { RefObject } from \"react\";\nimport { useFormOwnerEvent } from \"./use-form-owner-event.js\";\nimport type { FormAssociatedElement } from \"./use-form-owner-event.js\";\nimport { useLatest } from \"./use-latest.js\";\n\nexport type { FormAssociatedElement } from \"./use-form-owner-event.js\";\n\n/**\n * Stable `reset` subscription on the form owner of `ref.current` (conventions 3.8).\n *\n * - The owner (`element.form`, which honours the `form` attribute) is re-read after every commit,\n * but the listener is moved only when the owner changed. It is never removed and re-added on an\n * ordinary re-render: React's automatic reset after a successful `<form action>` runs in the\n * commit's mutation phase, between an effect's cleanup and its next run, so a subscription that\n * followed every commit would miss it (rule 4). Built on `useFormOwnerEvent`.\n * - The `reset` event fires before the reset algorithm, so `onReset` is called in a microtask,\n * when the inputs hold their reset values (rule 5), and only if no listener cancelled the reset\n * (rule 7).\n * - `onReset` always sees the latest committed render's values.\n */\nexport function useFormReset(\n ref: RefObject<FormAssociatedElement | null>,\n onReset: (event: Event) => void,\n /**\n * Called synchronously from the `reset` event, before the reset algorithm runs: the last\n * moment to write a reset target the browser will restore (Combobox re-applies its mirrored\n * `defaultValue`, which react-dom's attribute syncing overwrites after every keystroke).\n */\n onResetEvent?: (event: Event) => void,\n): void {\n // Read in the microtask, after the commit that may have run the reset (React's own reset runs\n // in a commit's mutation phase): the latest committed `onReset`.\n const latestOnReset = useLatest(onReset);\n useFormOwnerEvent(ref, \"reset\", (event) => {\n onResetEvent?.(event);\n queueMicrotask(() => {\n if (event.defaultPrevented) return;\n latestOnReset.current(event);\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAqBA,SAAgB,aACd,KACA,SAMA,cACM;CAGN,MAAM,gBAAgB,UAAU,OAAO;CACvC,kBAAkB,KAAK,UAAU,UAAU;EACzC,eAAe,KAAK;EACpB,qBAAqB;GACnB,IAAI,MAAM,kBAAkB;GAC5B,cAAc,QAAQ,KAAK;EAC7B,CAAC;CACH,CAAC;AACH"}
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ import { useSyncExternalStore } from "react";
3
+ //#region src/internal/use-hydrated-mount.ts
4
+ const subscribe = () => () => {};
5
+ const clientSnapshot = () => false;
6
+ const serverSnapshot = () => true;
7
+ /**
8
+ * Whether this render hydrates server HTML: `true` in the render that hydrates the component
9
+ * (React uses `useSyncExternalStore`'s server snapshot there, also inside a Suspense boundary that
10
+ * hydrates after its parent) and `false` in a client-created render, including a hydration that
11
+ * fell back to client rendering. After a hydration React re-renders once with the client snapshot
12
+ * (scheduled from a passive effect), so read the value captured by the **mount layout effect**,
13
+ * which belongs to the hydration render (design/components/select.md 6.2).
14
+ */
15
+ function useHydratedMount() {
16
+ return useSyncExternalStore(subscribe, clientSnapshot, serverSnapshot);
17
+ }
18
+ //#endregion
19
+ export { useHydratedMount };
20
+
21
+ //# sourceMappingURL=use-hydrated-mount.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-hydrated-mount.mjs","names":[],"sources":["../../src/internal/use-hydrated-mount.ts"],"sourcesContent":["\"use client\";\nimport { useSyncExternalStore } from \"react\";\n\nconst subscribe = () => () => {};\nconst clientSnapshot = () => false;\nconst serverSnapshot = () => true;\n\n/**\n * Whether this render hydrates server HTML: `true` in the render that hydrates the component\n * (React uses `useSyncExternalStore`'s server snapshot there, also inside a Suspense boundary that\n * hydrates after its parent) and `false` in a client-created render, including a hydration that\n * fell back to client rendering. After a hydration React re-renders once with the client snapshot\n * (scheduled from a passive effect), so read the value captured by the **mount layout effect**,\n * which belongs to the hydration render (design/components/select.md 6.2).\n */\nexport function useHydratedMount(): boolean {\n return useSyncExternalStore(subscribe, clientSnapshot, serverSnapshot);\n}\n"],"mappings":";;;AAGA,MAAM,wBAAwB,CAAC;AAC/B,MAAM,uBAAuB;AAC7B,MAAM,uBAAuB;;;;;;;;;AAU7B,SAAgB,mBAA4B;CAC1C,OAAO,qBAAqB,WAAW,gBAAgB,cAAc;AACvE"}
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { useInsertionEffect, useRef } from "react";
3
+ //#region src/internal/use-latest.ts
4
+ /**
5
+ * A ref holding the value of the last **committed** render, for stable callbacks (handlers,
6
+ * actions, effects) that must read the current props without changing identity on every render.
7
+ *
8
+ * Written in an insertion effect: it runs in the mutation phase, before every layout effect of
9
+ * the same commit (a child's included), so a callback called from a layout effect of the commit
10
+ * that changed the value already sees it. A render that never commits (a discarded concurrent or
11
+ * StrictMode render) never writes it. Never read it during render.
12
+ *
13
+ * Stale window: the write happens in the owner's own insertion effect, and React runs a commit's
14
+ * insertion effects children first, interleaved with that subtree's DOM mutations. So a callback
15
+ * called before the owner's insertion effect of the commit that changed the value still sees the
16
+ * previous commit's value: from a descendant's insertion effect, or from a DOM event the browser
17
+ * fires synchronously during the mutation phase (Chromium fires `blur` / `focusout` when a
18
+ * focused node is removed). This is the same value a per-render closure would have given (the
19
+ * handler attached then was the previous commit's too), so it is not a regression; just do not
20
+ * rely on the new value before the layout phase.
21
+ */
22
+ function useLatest(value) {
23
+ const ref = useRef(value);
24
+ useInsertionEffect(() => {
25
+ ref.current = value;
26
+ });
27
+ return ref;
28
+ }
29
+ //#endregion
30
+ export { useLatest };
31
+
32
+ //# sourceMappingURL=use-latest.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-latest.mjs","names":[],"sources":["../../src/internal/use-latest.ts"],"sourcesContent":["\"use client\";\nimport { useInsertionEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\n\n/**\n * A ref holding the value of the last **committed** render, for stable callbacks (handlers,\n * actions, effects) that must read the current props without changing identity on every render.\n *\n * Written in an insertion effect: it runs in the mutation phase, before every layout effect of\n * the same commit (a child's included), so a callback called from a layout effect of the commit\n * that changed the value already sees it. A render that never commits (a discarded concurrent or\n * StrictMode render) never writes it. Never read it during render.\n *\n * Stale window: the write happens in the owner's own insertion effect, and React runs a commit's\n * insertion effects children first, interleaved with that subtree's DOM mutations. So a callback\n * called before the owner's insertion effect of the commit that changed the value still sees the\n * previous commit's value: from a descendant's insertion effect, or from a DOM event the browser\n * fires synchronously during the mutation phase (Chromium fires `blur` / `focusout` when a\n * focused node is removed). This is the same value a per-render closure would have given (the\n * handler attached then was the previous commit's too), so it is not a regression; just do not\n * rely on the new value before the layout phase.\n */\nexport function useLatest<T>(value: T): RefObject<T> {\n const ref = useRef(value);\n useInsertionEffect(() => {\n ref.current = value;\n });\n return ref;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,UAAa,OAAwB;CACnD,MAAM,MAAM,OAAO,KAAK;CACxB,yBAAyB;EACvB,IAAI,UAAU;CAChB,CAAC;CACD,OAAO;AACT"}
@@ -0,0 +1,47 @@
1
+ "use client";
2
+ import { pushModal, removeModal } from "./modal-registry.mjs";
3
+ import { useLayoutEffect } from "react";
4
+ //#region src/internal/use-modal-registration.ts
5
+ function isModal(dialog) {
6
+ if (!dialog.isConnected || !dialog.open) return false;
7
+ try {
8
+ return dialog.matches(":modal");
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+ /**
14
+ * Keeps the modal registry (`modal-registry.ts`) in step with a `<dialog>`: registered while it
15
+ * is open as a modal (`showModal()`), removed when it closes or unmounts. Reads the DOM truth
16
+ * (`:modal`) instead of the component's state, so every way of opening counts.
17
+ *
18
+ * `beforetoggle` fires synchronously inside `showModal()` / `close()`, before the state changes,
19
+ * so that check runs a microtask later; `toggle` and `close` re-check.
20
+ */
21
+ function useModalRegistration(dialog) {
22
+ useLayoutEffect(() => {
23
+ if (dialog === null) return;
24
+ let active = true;
25
+ const sync = () => {
26
+ if (!active) return;
27
+ if (isModal(dialog)) pushModal(dialog);
28
+ else removeModal(dialog);
29
+ };
30
+ const syncSoon = () => queueMicrotask(sync);
31
+ dialog.addEventListener("beforetoggle", syncSoon);
32
+ dialog.addEventListener("toggle", sync);
33
+ dialog.addEventListener("close", sync);
34
+ sync();
35
+ return () => {
36
+ active = false;
37
+ dialog.removeEventListener("beforetoggle", syncSoon);
38
+ dialog.removeEventListener("toggle", sync);
39
+ dialog.removeEventListener("close", sync);
40
+ removeModal(dialog);
41
+ };
42
+ }, [dialog]);
43
+ }
44
+ //#endregion
45
+ export { useModalRegistration };
46
+
47
+ //# sourceMappingURL=use-modal-registration.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-modal-registration.mjs","names":[],"sources":["../../src/internal/use-modal-registration.ts"],"sourcesContent":["\"use client\";\nimport { useLayoutEffect } from \"react\";\nimport { pushModal, removeModal } from \"./modal-registry.js\";\n\nfunction isModal(dialog: HTMLDialogElement): boolean {\n if (!dialog.isConnected || !dialog.open) return false;\n try {\n return dialog.matches(\":modal\");\n } catch {\n return false;\n }\n}\n\n/**\n * Keeps the modal registry (`modal-registry.ts`) in step with a `<dialog>`: registered while it\n * is open as a modal (`showModal()`), removed when it closes or unmounts. Reads the DOM truth\n * (`:modal`) instead of the component's state, so every way of opening counts.\n *\n * `beforetoggle` fires synchronously inside `showModal()` / `close()`, before the state changes,\n * so that check runs a microtask later; `toggle` and `close` re-check.\n */\nexport function useModalRegistration(dialog: HTMLDialogElement | null): void {\n useLayoutEffect(() => {\n if (dialog === null) return;\n let active = true;\n const sync = (): void => {\n if (!active) return;\n if (isModal(dialog)) pushModal(dialog);\n else removeModal(dialog);\n };\n const syncSoon = (): void => queueMicrotask(sync);\n dialog.addEventListener(\"beforetoggle\", syncSoon);\n dialog.addEventListener(\"toggle\", sync);\n dialog.addEventListener(\"close\", sync);\n sync();\n return () => {\n active = false;\n dialog.removeEventListener(\"beforetoggle\", syncSoon);\n dialog.removeEventListener(\"toggle\", sync);\n dialog.removeEventListener(\"close\", sync);\n removeModal(dialog);\n };\n }, [dialog]);\n}\n"],"mappings":";;;;AAIA,SAAS,QAAQ,QAAoC;CACnD,IAAI,CAAC,OAAO,eAAe,CAAC,OAAO,MAAM,OAAO;CAChD,IAAI;EACF,OAAO,OAAO,QAAQ,QAAQ;CAChC,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;AAUA,SAAgB,qBAAqB,QAAwC;CAC3E,sBAAsB;EACpB,IAAI,WAAW,MAAM;EACrB,IAAI,SAAS;EACb,MAAM,aAAmB;GACvB,IAAI,CAAC,QAAQ;GACb,IAAI,QAAQ,MAAM,GAAG,UAAU,MAAM;QAChC,YAAY,MAAM;EACzB;EACA,MAAM,iBAAuB,eAAe,IAAI;EAChD,OAAO,iBAAiB,gBAAgB,QAAQ;EAChD,OAAO,iBAAiB,UAAU,IAAI;EACtC,OAAO,iBAAiB,SAAS,IAAI;EACrC,KAAK;EACL,aAAa;GACX,SAAS;GACT,OAAO,oBAAoB,gBAAgB,QAAQ;GACnD,OAAO,oBAAoB,UAAU,IAAI;GACzC,OAAO,oBAAoB,SAAS,IAAI;GACxC,YAAY,MAAM;EACpB;CACF,GAAG,CAAC,MAAM,CAAC;AACb"}
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import { useLatest } from "./use-latest.mjs";
2
3
  import { useFormReset } from "./use-form-reset.mjs";
3
4
  import { useCallback, useEffectEvent, useLayoutEffect, useRef } from "react";
4
5
  //#region src/internal/use-native-group-reconciliation.ts
@@ -19,10 +20,7 @@ import { useCallback, useEffectEvent, useLayoutEffect, useRef } from "react";
19
20
  */
20
21
  function useNativeGroupReconciliation(options) {
21
22
  const { element } = options;
22
- const latest = useRef(options);
23
- useLayoutEffect(() => {
24
- latest.current = options;
25
- });
23
+ const latest = useLatest(options);
26
24
  /** The Root's own mount reconciliation has run (reset by its cleanup, StrictMode included). */
27
25
  const rootReconciled = useRef(false);
28
26
  /** A late reconciliation is queued; members mounting in the same commit share it. */
@@ -48,10 +46,13 @@ function useNativeGroupReconciliation(options) {
48
46
  if (current.element.current === null) return;
49
47
  reconcile(current, current.readCurrent());
50
48
  });
51
- }, []);
49
+ }, [latest]);
52
50
  useFormReset(element, (event) => {
53
51
  if (element.current === null) return;
54
52
  reconcile(options, options.readDefault(), event);
53
+ }, (event) => {
54
+ if (element.current === null) return;
55
+ options.onResetEvent?.(event);
55
56
  });
56
57
  return { onItemMounted };
57
58
  }
@@ -1 +1 @@
1
- {"version":3,"file":"use-native-group-reconciliation.mjs","names":[],"sources":["../../src/internal/use-native-group-reconciliation.ts"],"sourcesContent":["\"use client\";\nimport { useCallback, useEffectEvent, useLayoutEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\nimport { useFormReset } from \"./use-form-reset.js\";\n\nexport interface UseNativeGroupReconciliationOptions<T> {\n /** The group's form-associated container (`RadioGroup.Root`'s `<fieldset>`). */\n readonly element: RefObject<HTMLFieldSetElement | null>;\n /** The group's value as the DOM shows it now (`checked` of the members). */\n readonly readCurrent: () => T;\n /** The value the browser resets the group to (`defaultChecked` of the members, rule 5). */\n readonly readDefault: () => T;\n /**\n * What the user intends (rule 1): the latest in-flight dispatch's `next`, else the optimistic\n * value. Use an explicit null check, `const f = latestInFlight(); return f === null ?\n * optimistic : f.next;`, not `?.next ?? optimistic`, which drops an in-flight dispatch of a\n * nullish value (a pending reset to \"nothing checked\").\n */\n readonly base: () => T;\n /**\n * The browser changed the group's value on a residual path and it differs from `base()`.\n * `event` is the `reset` event, or `undefined` for mount reconciliation (the Root's mount, or a\n * member mounting later). The Root dispatches once for the whole group with\n * `reason: \"platform\"`.\n */\n readonly onPlatformChange: (next: T, event: Event | undefined) => void;\n /**\n * Equality between a read value and `base()`. Default `Object.is`, right for a primitive value\n * (RadioGroup's `string | null`); a group with a collection value (CheckboxGroup's\n * `ReadonlySet<string>`) passes structural equality, since every read builds a new object.\n */\n readonly equals?: ((a: T, b: T) => boolean) | undefined;\n}\n\nexport interface UseNativeGroupReconciliationResult {\n /**\n * Stable. Pass it to every member, which hands it to\n * `useNativeChecked({ group: true, onItemMounted })`; the hook calls it from the member's mount\n * layout effect. A member that mounts after the Root's own mount reconciliation (inside a\n * Suspense boundary that hydrates later, selective hydration, or a member added on the client)\n * makes the adapter reconcile the group again: `readCurrent()` vs `base()`, in a microtask, once\n * for all members mounted in the same commit. Members mounting in the Root's own commit are\n * covered by the Root's mount reconciliation (their effects run before the Root's) and are\n * ignored.\n *\n * RadioGroup wiring: `const { onItemMounted } = useNativeGroupReconciliation(...)` in the Root,\n * put `onItemMounted` in the group context, and in each Item\n * `useNativeChecked({ type: \"radio\", group: true, onItemMounted: ctx.onItemMounted, ... })`.\n * The member's element argument is not used here; the Root may use it (dev warnings).\n */\n readonly onItemMounted: (element?: Element | null) => void;\n}\n\n/**\n * Group adapter of the native-input machinery (conventions 3.8): one dispatch per group for the\n * residual browser paths, from the Root rather than once per member.\n *\n * - Mount reconciliation, once, in a layout effect: `readCurrent()` vs `base()`. The members'\n * `useNativeChecked({ group: true })` leaves `checked` alone in their mount commit, so this\n * effect, which runs after the members' (children first), still reads the hydrated DOM.\n * - Late members: a member mounting in a later commit (a Suspense boundary that hydrates after the\n * Root, where a click before hydration changed the DOM) reports through `onItemMounted`, and the\n * group is reconciled again the same way (see `UseNativeGroupReconciliationResult`).\n * - Form reset through `useFormReset` on the container: in a microtask after the reset,\n * `readDefault()` vs `base()`.\n *\n * Values are compared with `equals` (default `Object.is`).\n */\nexport function useNativeGroupReconciliation<T>(\n options: UseNativeGroupReconciliationOptions<T>,\n): UseNativeGroupReconciliationResult {\n const { element } = options;\n\n // The latest committed options, for the late reconciliation, which runs in a microtask outside\n // any effect (so it cannot go through an Effect Event).\n const latest = useRef(options);\n useLayoutEffect(() => {\n latest.current = options;\n });\n\n /** The Root's own mount reconciliation has run (reset by its cleanup, StrictMode included). */\n const rootReconciled = useRef(false);\n /** A late reconciliation is queued; members mounting in the same commit share it. */\n const lateQueued = useRef(false);\n\n const reconcileMount = useEffectEvent(() => {\n if (element.current === null) return;\n reconcile(options, options.readCurrent());\n });\n useLayoutEffect(() => {\n reconcileMount();\n rootReconciled.current = true;\n return () => {\n rootReconciled.current = false;\n };\n }, []);\n\n const onItemMounted = useCallback(() => {\n if (!rootReconciled.current || lateQueued.current) return;\n lateQueued.current = true;\n queueMicrotask(() => {\n lateQueued.current = false;\n if (!rootReconciled.current) return;\n const current = latest.current;\n if (current.element.current === null) return;\n reconcile(current, current.readCurrent());\n });\n }, []);\n\n useFormReset(element, (event) => {\n if (element.current === null) return;\n reconcile(options, options.readDefault(), event);\n });\n\n return { onItemMounted };\n}\n\nfunction reconcile<T>(options: UseNativeGroupReconciliationOptions<T>, next: T, event?: Event) {\n const equals = options.equals ?? Object.is;\n if (!equals(next, options.base())) options.onPlatformChange(next, event);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAoEA,SAAgB,6BACd,SACoC;CACpC,MAAM,EAAE,YAAY;CAIpB,MAAM,SAAS,OAAO,OAAO;CAC7B,sBAAsB;EACpB,OAAO,UAAU;CACnB,CAAC;;CAGD,MAAM,iBAAiB,OAAO,KAAK;;CAEnC,MAAM,aAAa,OAAO,KAAK;CAE/B,MAAM,iBAAiB,qBAAqB;EAC1C,IAAI,QAAQ,YAAY,MAAM;EAC9B,UAAU,SAAS,QAAQ,YAAY,CAAC;CAC1C,CAAC;CACD,sBAAsB;EACpB,eAAe;EACf,eAAe,UAAU;EACzB,aAAa;GACX,eAAe,UAAU;EAC3B;CACF,GAAG,CAAC,CAAC;CAEL,MAAM,gBAAgB,kBAAkB;EACtC,IAAI,CAAC,eAAe,WAAW,WAAW,SAAS;EACnD,WAAW,UAAU;EACrB,qBAAqB;GACnB,WAAW,UAAU;GACrB,IAAI,CAAC,eAAe,SAAS;GAC7B,MAAM,UAAU,OAAO;GACvB,IAAI,QAAQ,QAAQ,YAAY,MAAM;GACtC,UAAU,SAAS,QAAQ,YAAY,CAAC;EAC1C,CAAC;CACH,GAAG,CAAC,CAAC;CAEL,aAAa,UAAU,UAAU;EAC/B,IAAI,QAAQ,YAAY,MAAM;EAC9B,UAAU,SAAS,QAAQ,YAAY,GAAG,KAAK;CACjD,CAAC;CAED,OAAO,EAAE,cAAc;AACzB;AAEA,SAAS,UAAa,SAAiD,MAAS,OAAe;CAE7F,IAAI,EADW,QAAQ,UAAU,OAAO,GAAA,CAC5B,MAAM,QAAQ,KAAK,CAAC,GAAG,QAAQ,iBAAiB,MAAM,KAAK;AACzE"}
1
+ {"version":3,"file":"use-native-group-reconciliation.mjs","names":[],"sources":["../../src/internal/use-native-group-reconciliation.ts"],"sourcesContent":["\"use client\";\nimport { useCallback, useEffectEvent, useLayoutEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\nimport { useFormReset } from \"./use-form-reset.js\";\nimport type { FormAssociatedElement } from \"./use-form-reset.js\";\nimport { useLatest } from \"./use-latest.js\";\n\nexport interface UseNativeGroupReconciliationOptions<T> {\n /**\n * The group's form-associated element: `RadioGroup.Root`'s / `CheckboxGroup.Root`'s\n * `<fieldset>`, `Select.Root`'s `<select>` (whose options are the members), or any member\n * input when the Root renders no form-associated element (`Slider.Root` subscribes through its\n * first Thumb's `<input type=\"range\">`).\n */\n readonly element: RefObject<FormAssociatedElement | null>;\n /** The group's value as the DOM shows it now (`checked` of the members). */\n readonly readCurrent: () => T;\n /** The value the browser resets the group to (`defaultChecked` of the members, rule 5). */\n readonly readDefault: () => T;\n /**\n * What the user intends (rule 1): the latest in-flight dispatch's `next`, else the optimistic\n * value. Use an explicit null check, `const f = latestInFlight(); return f === null ?\n * optimistic : f.next;`, not `?.next ?? optimistic`, which drops an in-flight dispatch of a\n * nullish value (a pending reset to \"nothing checked\").\n */\n readonly base: () => T;\n /**\n * The browser changed the group's value on a residual path and it differs from `base()`.\n * `event` is the `reset` event, or `undefined` for mount reconciliation (the Root's mount, or a\n * member mounting later). The Root dispatches once for the whole group with\n * `reason: \"platform\"`.\n */\n readonly onPlatformChange: (next: T, event: Event | undefined) => void;\n /**\n * Equality between a read value and `base()`. Default `Object.is`, right for a primitive value\n * (RadioGroup's `string | null`); a group with a collection value (CheckboxGroup's\n * `ReadonlySet<string>`) passes structural equality, since every read builds a new object.\n */\n readonly equals?: ((a: T, b: T) => boolean) | undefined;\n /**\n * Passed through to `useFormReset`: called synchronously from the `reset` event, before the\n * reset algorithm runs. A group of controlled **value** inputs (Slider) writes each input's\n * `defaultValue` to the reset target here, because react-dom keeps that attribute equal to the\n * current value after every update (conventions 3.8 rule 6).\n */\n readonly onResetEvent?: ((event: Event) => void) | undefined;\n}\n\nexport interface UseNativeGroupReconciliationResult {\n /**\n * Stable. Pass it to every member, which hands it to\n * `useNativeChecked({ group: true, onItemMounted })`; the hook calls it from the member's mount\n * layout effect. A member that mounts after the Root's own mount reconciliation (inside a\n * Suspense boundary that hydrates later, selective hydration, or a member added on the client)\n * makes the adapter reconcile the group again: `readCurrent()` vs `base()`, in a microtask, once\n * for all members mounted in the same commit. Members mounting in the Root's own commit are\n * covered by the Root's mount reconciliation (their effects run before the Root's) and are\n * ignored.\n *\n * RadioGroup wiring: `const { onItemMounted } = useNativeGroupReconciliation(...)` in the Root,\n * put `onItemMounted` in the group context, and in each Item\n * `useNativeChecked({ type: \"radio\", group: true, onItemMounted: ctx.onItemMounted, ... })`.\n * The member's element argument is not used here; the Root may use it (dev warnings).\n */\n readonly onItemMounted: (element?: Element | null) => void;\n}\n\n/**\n * Group adapter of the native-input machinery (conventions 3.8): one dispatch per group for the\n * residual browser paths, from the Root rather than once per member.\n *\n * - Mount reconciliation, once, in a layout effect: `readCurrent()` vs `base()`. The members'\n * `useNativeChecked({ group: true })` leaves `checked` alone in their mount commit, so this\n * effect, which runs after the members' (children first), still reads the hydrated DOM.\n * - Late members: a member mounting in a later commit (a Suspense boundary that hydrates after the\n * Root, where a click before hydration changed the DOM) reports through `onItemMounted`, and the\n * group is reconciled again the same way (see `UseNativeGroupReconciliationResult`).\n * - Form reset through `useFormReset` on the container: in a microtask after the reset,\n * `readDefault()` vs `base()`.\n *\n * Values are compared with `equals` (default `Object.is`).\n */\nexport function useNativeGroupReconciliation<T>(\n options: UseNativeGroupReconciliationOptions<T>,\n): UseNativeGroupReconciliationResult {\n const { element } = options;\n\n // The latest committed options, for the late reconciliation, which runs in a microtask outside\n // any effect (so it cannot go through an Effect Event).\n const latest = useLatest(options);\n\n /** The Root's own mount reconciliation has run (reset by its cleanup, StrictMode included). */\n const rootReconciled = useRef(false);\n /** A late reconciliation is queued; members mounting in the same commit share it. */\n const lateQueued = useRef(false);\n\n const reconcileMount = useEffectEvent(() => {\n if (element.current === null) return;\n reconcile(options, options.readCurrent());\n });\n useLayoutEffect(() => {\n reconcileMount();\n rootReconciled.current = true;\n return () => {\n rootReconciled.current = false;\n };\n }, []);\n\n const onItemMounted = useCallback(() => {\n if (!rootReconciled.current || lateQueued.current) return;\n lateQueued.current = true;\n queueMicrotask(() => {\n lateQueued.current = false;\n if (!rootReconciled.current) return;\n const current = latest.current;\n if (current.element.current === null) return;\n reconcile(current, current.readCurrent());\n });\n }, [latest]);\n\n useFormReset(\n element,\n (event) => {\n if (element.current === null) return;\n reconcile(options, options.readDefault(), event);\n },\n (event) => {\n if (element.current === null) return;\n options.onResetEvent?.(event);\n },\n );\n\n return { onItemMounted };\n}\n\nfunction reconcile<T>(options: UseNativeGroupReconciliationOptions<T>, next: T, event?: Event) {\n const equals = options.equals ?? Object.is;\n if (!equals(next, options.base())) options.onPlatformChange(next, event);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAkFA,SAAgB,6BACd,SACoC;CACpC,MAAM,EAAE,YAAY;CAIpB,MAAM,SAAS,UAAU,OAAO;;CAGhC,MAAM,iBAAiB,OAAO,KAAK;;CAEnC,MAAM,aAAa,OAAO,KAAK;CAE/B,MAAM,iBAAiB,qBAAqB;EAC1C,IAAI,QAAQ,YAAY,MAAM;EAC9B,UAAU,SAAS,QAAQ,YAAY,CAAC;CAC1C,CAAC;CACD,sBAAsB;EACpB,eAAe;EACf,eAAe,UAAU;EACzB,aAAa;GACX,eAAe,UAAU;EAC3B;CACF,GAAG,CAAC,CAAC;CAEL,MAAM,gBAAgB,kBAAkB;EACtC,IAAI,CAAC,eAAe,WAAW,WAAW,SAAS;EACnD,WAAW,UAAU;EACrB,qBAAqB;GACnB,WAAW,UAAU;GACrB,IAAI,CAAC,eAAe,SAAS;GAC7B,MAAM,UAAU,OAAO;GACvB,IAAI,QAAQ,QAAQ,YAAY,MAAM;GACtC,UAAU,SAAS,QAAQ,YAAY,CAAC;EAC1C,CAAC;CACH,GAAG,CAAC,MAAM,CAAC;CAEX,aACE,UACC,UAAU;EACT,IAAI,QAAQ,YAAY,MAAM;EAC9B,UAAU,SAAS,QAAQ,YAAY,GAAG,KAAK;CACjD,IACC,UAAU;EACT,IAAI,QAAQ,YAAY,MAAM;EAC9B,QAAQ,eAAe,KAAK;CAC9B,CACF;CAEA,OAAO,EAAE,cAAc;AACzB;AAEA,SAAS,UAAa,SAAiD,MAAS,OAAe;CAE7F,IAAI,EADW,QAAQ,UAAU,OAAO,GAAA,CAC5B,MAAM,QAAQ,KAAK,CAAC,GAAG,QAAQ,iBAAiB,MAAM,KAAK;AACzE"}