@multiplatform.one/frappe-ui 7.6.3 → 7.7.1

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 (285) hide show
  1. package/dist/cjs/ChartCard.cjs +52 -0
  2. package/dist/cjs/ChartCard.native.js +72 -0
  3. package/dist/cjs/ChartCard.native.js.map +1 -0
  4. package/dist/cjs/FileBrowser/AttachLibrary.cjs +2 -2
  5. package/dist/cjs/FileBrowser/AttachLibrary.native.js +8 -2
  6. package/dist/cjs/FileBrowser/AttachLibrary.native.js.map +1 -1
  7. package/dist/cjs/FrappeChartCard.cjs +3 -3
  8. package/dist/cjs/FrappeChartCard.native.js +3 -3
  9. package/dist/cjs/FrappeChartCard.native.js.map +1 -1
  10. package/dist/cjs/FrappeDashboard.cjs +6 -27
  11. package/dist/cjs/FrappeDashboard.native.js +6 -33
  12. package/dist/cjs/FrappeDashboard.native.js.map +1 -1
  13. package/dist/cjs/FrappeImageView.cjs +2 -8
  14. package/dist/cjs/FrappeImageView.native.js +2 -9
  15. package/dist/cjs/FrappeImageView.native.js.map +1 -1
  16. package/dist/cjs/fields/CodeEditor/index.cjs +4 -0
  17. package/dist/cjs/fields/CodeEditor/index.native.js +4 -0
  18. package/dist/cjs/fields/CodeEditor/index.native.js.map +1 -1
  19. package/dist/cjs/fields/LinkTree.cjs +5 -3
  20. package/dist/cjs/fields/LinkTree.native.js +9 -5
  21. package/dist/cjs/fields/LinkTree.native.js.map +1 -1
  22. package/dist/cjs/formEngine/createPrefill.cjs +37 -0
  23. package/dist/cjs/formEngine/createPrefill.native.js +40 -0
  24. package/dist/cjs/formEngine/createPrefill.native.js.map +1 -0
  25. package/dist/cjs/formEngine/dirtyTracking.cjs +55 -0
  26. package/dist/cjs/formEngine/dirtyTracking.native.js +77 -0
  27. package/dist/cjs/formEngine/dirtyTracking.native.js.map +1 -0
  28. package/dist/cjs/formEngine/doctypeMeta.cjs +18 -0
  29. package/dist/cjs/formEngine/doctypeMeta.native.js +21 -0
  30. package/dist/cjs/formEngine/doctypeMeta.native.js.map +1 -0
  31. package/dist/cjs/formEngine/fetchFrom.cjs +67 -0
  32. package/dist/cjs/formEngine/fetchFrom.native.js +110 -0
  33. package/dist/cjs/formEngine/fetchFrom.native.js.map +1 -0
  34. package/dist/cjs/formEngine/fieldPermissions.cjs +55 -0
  35. package/dist/cjs/formEngine/fieldPermissions.native.js +80 -0
  36. package/dist/cjs/formEngine/fieldPermissions.native.js.map +1 -0
  37. package/dist/cjs/formEngine/formActions.cjs +189 -0
  38. package/dist/cjs/formEngine/formActions.native.js +199 -0
  39. package/dist/cjs/formEngine/formActions.native.js.map +1 -0
  40. package/dist/cjs/formEngine/formLayout.cjs +245 -0
  41. package/dist/cjs/formEngine/formLayout.native.js +388 -0
  42. package/dist/cjs/formEngine/formLayout.native.js.map +1 -0
  43. package/dist/cjs/formEngine/index.cjs +48 -0
  44. package/dist/cjs/formEngine/index.native.js +51 -0
  45. package/dist/cjs/formEngine/index.native.js.map +1 -0
  46. package/dist/cjs/formEngine/useFormEngine.cjs +387 -0
  47. package/dist/cjs/formEngine/useFormEngine.native.js +476 -0
  48. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -0
  49. package/dist/cjs/frappeStoryTarget.cjs +4 -2
  50. package/dist/cjs/frappeStoryTarget.native.js +4 -3
  51. package/dist/cjs/frappeStoryTarget.native.js.map +1 -1
  52. package/dist/cjs/index.cjs +19 -1
  53. package/dist/cjs/index.native.js +19 -1
  54. package/dist/cjs/index.native.js.map +1 -1
  55. package/dist/esm/ChartCard.mjs +27 -0
  56. package/dist/esm/ChartCard.mjs.map +1 -0
  57. package/dist/esm/ChartCard.native.js +33 -0
  58. package/dist/esm/ChartCard.native.js.map +1 -0
  59. package/dist/esm/FileBrowser/AttachLibrary.mjs +2 -2
  60. package/dist/esm/FileBrowser/AttachLibrary.mjs.map +1 -1
  61. package/dist/esm/FileBrowser/AttachLibrary.native.js +8 -2
  62. package/dist/esm/FileBrowser/AttachLibrary.native.js.map +1 -1
  63. package/dist/esm/FrappeChartCard.mjs +1 -1
  64. package/dist/esm/FrappeChartCard.native.js +1 -1
  65. package/dist/esm/FrappeChartCard.native.js.map +1 -1
  66. package/dist/esm/FrappeDashboard.mjs +6 -27
  67. package/dist/esm/FrappeDashboard.mjs.map +1 -1
  68. package/dist/esm/FrappeDashboard.native.js +6 -33
  69. package/dist/esm/FrappeDashboard.native.js.map +1 -1
  70. package/dist/esm/FrappeImageView.mjs +2 -7
  71. package/dist/esm/FrappeImageView.mjs.map +1 -1
  72. package/dist/esm/FrappeImageView.native.js +2 -8
  73. package/dist/esm/FrappeImageView.native.js.map +1 -1
  74. package/dist/esm/fields/CodeEditor/index.mjs +4 -0
  75. package/dist/esm/fields/CodeEditor/index.mjs.map +1 -1
  76. package/dist/esm/fields/CodeEditor/index.native.js +4 -0
  77. package/dist/esm/fields/CodeEditor/index.native.js.map +1 -1
  78. package/dist/esm/fields/LinkTree.mjs +5 -3
  79. package/dist/esm/fields/LinkTree.mjs.map +1 -1
  80. package/dist/esm/fields/LinkTree.native.js +9 -5
  81. package/dist/esm/fields/LinkTree.native.js.map +1 -1
  82. package/dist/esm/formEngine/createPrefill.mjs +11 -0
  83. package/dist/esm/formEngine/createPrefill.mjs.map +1 -0
  84. package/dist/esm/formEngine/createPrefill.native.js +11 -0
  85. package/dist/esm/formEngine/createPrefill.native.js.map +1 -0
  86. package/dist/esm/formEngine/dirtyTracking.mjs +28 -0
  87. package/dist/esm/formEngine/dirtyTracking.mjs.map +1 -0
  88. package/dist/esm/formEngine/dirtyTracking.native.js +47 -0
  89. package/dist/esm/formEngine/dirtyTracking.native.js.map +1 -0
  90. package/dist/esm/formEngine/doctypeMeta.mjs +2 -0
  91. package/dist/esm/formEngine/doctypeMeta.mjs.map +1 -0
  92. package/dist/esm/formEngine/doctypeMeta.native.js +2 -0
  93. package/dist/esm/formEngine/doctypeMeta.native.js.map +1 -0
  94. package/dist/esm/formEngine/fetchFrom.mjs +40 -0
  95. package/dist/esm/formEngine/fetchFrom.mjs.map +1 -0
  96. package/dist/esm/formEngine/fetchFrom.native.js +80 -0
  97. package/dist/esm/formEngine/fetchFrom.native.js.map +1 -0
  98. package/dist/esm/formEngine/fieldPermissions.mjs +28 -0
  99. package/dist/esm/formEngine/fieldPermissions.mjs.map +1 -0
  100. package/dist/esm/formEngine/fieldPermissions.native.js +50 -0
  101. package/dist/esm/formEngine/fieldPermissions.native.js.map +1 -0
  102. package/dist/esm/formEngine/formActions.mjs +146 -0
  103. package/dist/esm/formEngine/formActions.mjs.map +1 -0
  104. package/dist/esm/formEngine/formActions.native.js +153 -0
  105. package/dist/esm/formEngine/formActions.native.js.map +1 -0
  106. package/dist/esm/formEngine/formLayout.mjs +217 -0
  107. package/dist/esm/formEngine/formLayout.mjs.map +1 -0
  108. package/dist/esm/formEngine/formLayout.native.js +357 -0
  109. package/dist/esm/formEngine/formLayout.native.js.map +1 -0
  110. package/dist/esm/formEngine/index.mjs +8 -0
  111. package/dist/esm/formEngine/index.mjs.map +1 -0
  112. package/dist/esm/formEngine/index.native.js +8 -0
  113. package/dist/esm/formEngine/index.native.js.map +1 -0
  114. package/dist/esm/formEngine/useFormEngine.mjs +362 -0
  115. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -0
  116. package/dist/esm/formEngine/useFormEngine.native.js +448 -0
  117. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -0
  118. package/dist/esm/frappeStoryTarget.mjs +4 -1
  119. package/dist/esm/frappeStoryTarget.mjs.map +1 -1
  120. package/dist/esm/frappeStoryTarget.native.js +4 -2
  121. package/dist/esm/frappeStoryTarget.native.js.map +1 -1
  122. package/dist/esm/index.mjs +4 -2
  123. package/dist/esm/index.mjs.map +1 -1
  124. package/dist/esm/index.native.js +4 -2
  125. package/dist/esm/index.native.js.map +1 -1
  126. package/dist/jsx/ChartCard.mjs +27 -0
  127. package/dist/jsx/ChartCard.mjs.map +1 -0
  128. package/dist/jsx/ChartCard.native.js +72 -0
  129. package/dist/jsx/ChartCard.native.js.map +1 -0
  130. package/dist/jsx/FileBrowser/AttachLibrary.mjs +2 -2
  131. package/dist/jsx/FileBrowser/AttachLibrary.mjs.map +1 -1
  132. package/dist/jsx/FileBrowser/AttachLibrary.native.js +8 -2
  133. package/dist/jsx/FileBrowser/AttachLibrary.native.js.map +1 -1
  134. package/dist/jsx/FrappeChartCard.mjs +1 -1
  135. package/dist/jsx/FrappeChartCard.native.js +3 -3
  136. package/dist/jsx/FrappeChartCard.native.js.map +1 -1
  137. package/dist/jsx/FrappeDashboard.mjs +6 -27
  138. package/dist/jsx/FrappeDashboard.mjs.map +1 -1
  139. package/dist/jsx/FrappeDashboard.native.js +6 -33
  140. package/dist/jsx/FrappeDashboard.native.js.map +1 -1
  141. package/dist/jsx/FrappeImageView.mjs +2 -7
  142. package/dist/jsx/FrappeImageView.mjs.map +1 -1
  143. package/dist/jsx/FrappeImageView.native.js +2 -9
  144. package/dist/jsx/FrappeImageView.native.js.map +1 -1
  145. package/dist/jsx/fields/CodeEditor/index.mjs +4 -0
  146. package/dist/jsx/fields/CodeEditor/index.mjs.map +1 -1
  147. package/dist/jsx/fields/CodeEditor/index.native.js +4 -0
  148. package/dist/jsx/fields/CodeEditor/index.native.js.map +1 -1
  149. package/dist/jsx/fields/LinkTree.mjs +5 -3
  150. package/dist/jsx/fields/LinkTree.mjs.map +1 -1
  151. package/dist/jsx/fields/LinkTree.native.js +9 -5
  152. package/dist/jsx/fields/LinkTree.native.js.map +1 -1
  153. package/dist/jsx/formEngine/createPrefill.mjs +11 -0
  154. package/dist/jsx/formEngine/createPrefill.mjs.map +1 -0
  155. package/dist/jsx/formEngine/createPrefill.native.js +40 -0
  156. package/dist/jsx/formEngine/createPrefill.native.js.map +1 -0
  157. package/dist/jsx/formEngine/dirtyTracking.mjs +28 -0
  158. package/dist/jsx/formEngine/dirtyTracking.mjs.map +1 -0
  159. package/dist/jsx/formEngine/dirtyTracking.native.js +77 -0
  160. package/dist/jsx/formEngine/dirtyTracking.native.js.map +1 -0
  161. package/dist/jsx/formEngine/doctypeMeta.mjs +2 -0
  162. package/dist/jsx/formEngine/doctypeMeta.mjs.map +1 -0
  163. package/dist/jsx/formEngine/doctypeMeta.native.js +21 -0
  164. package/dist/jsx/formEngine/doctypeMeta.native.js.map +1 -0
  165. package/dist/jsx/formEngine/fetchFrom.mjs +40 -0
  166. package/dist/jsx/formEngine/fetchFrom.mjs.map +1 -0
  167. package/dist/jsx/formEngine/fetchFrom.native.js +110 -0
  168. package/dist/jsx/formEngine/fetchFrom.native.js.map +1 -0
  169. package/dist/jsx/formEngine/fieldPermissions.mjs +28 -0
  170. package/dist/jsx/formEngine/fieldPermissions.mjs.map +1 -0
  171. package/dist/jsx/formEngine/fieldPermissions.native.js +80 -0
  172. package/dist/jsx/formEngine/fieldPermissions.native.js.map +1 -0
  173. package/dist/jsx/formEngine/formActions.mjs +146 -0
  174. package/dist/jsx/formEngine/formActions.mjs.map +1 -0
  175. package/dist/jsx/formEngine/formActions.native.js +199 -0
  176. package/dist/jsx/formEngine/formActions.native.js.map +1 -0
  177. package/dist/jsx/formEngine/formLayout.mjs +217 -0
  178. package/dist/jsx/formEngine/formLayout.mjs.map +1 -0
  179. package/dist/jsx/formEngine/formLayout.native.js +388 -0
  180. package/dist/jsx/formEngine/formLayout.native.js.map +1 -0
  181. package/dist/jsx/formEngine/index.mjs +8 -0
  182. package/dist/jsx/formEngine/index.mjs.map +1 -0
  183. package/dist/jsx/formEngine/index.native.js +51 -0
  184. package/dist/jsx/formEngine/index.native.js.map +1 -0
  185. package/dist/jsx/formEngine/useFormEngine.mjs +362 -0
  186. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -0
  187. package/dist/jsx/formEngine/useFormEngine.native.js +476 -0
  188. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -0
  189. package/dist/jsx/frappeStoryTarget.mjs +4 -1
  190. package/dist/jsx/frappeStoryTarget.mjs.map +1 -1
  191. package/dist/jsx/frappeStoryTarget.native.js +4 -3
  192. package/dist/jsx/frappeStoryTarget.native.js.map +1 -1
  193. package/dist/jsx/index.js +4 -2
  194. package/dist/jsx/index.js.map +1 -1
  195. package/dist/jsx/index.mjs +4 -2
  196. package/dist/jsx/index.mjs.map +1 -1
  197. package/dist/jsx/index.native.js +19 -1
  198. package/dist/jsx/index.native.js.map +1 -1
  199. package/package.json +23 -17
  200. package/src/ChartCard.tsx +31 -0
  201. package/src/FileBrowser/AttachLibrary.tsx +13 -1
  202. package/src/FileBrowser/FileBrowser.spec.tsx +16 -0
  203. package/src/FileBrowser/FileBrowser.stories.tsx +18 -1
  204. package/src/FileBrowser/folderTree.spec.tsx +68 -0
  205. package/src/FrappeCalendar.stories.tsx +1 -2
  206. package/src/FrappeChartCard.stories.tsx +1 -1
  207. package/src/FrappeChartCard.tsx +1 -1
  208. package/src/FrappeDashboard.stories.tsx +6 -5
  209. package/src/FrappeDashboard.tsx +6 -33
  210. package/src/FrappeGantt.stories.tsx +1 -2
  211. package/src/FrappeImageView.tsx +16 -15
  212. package/src/FrappeList.stories.tsx +3 -10
  213. package/src/classicScriptSafety.spec.ts +47 -0
  214. package/src/components/attachments.stories.tsx +9 -2
  215. package/src/dependsOn.ts +2 -2
  216. package/src/desk/DeskFormSidebar.stories.tsx +19 -1
  217. package/src/fields/CodeEditor/CodeEditor.spec.tsx +37 -0
  218. package/src/fields/CodeEditor/CodeEditor.stories.tsx +83 -66
  219. package/src/fields/CodeEditor/index.tsx +4 -0
  220. package/src/fields/DateRange/DateRange.spec.tsx +143 -0
  221. package/src/fields/FileUpload.spec.tsx +334 -0
  222. package/src/fields/FileUploadField/FileUploadField.spec.tsx +166 -0
  223. package/src/fields/HTML/Html.spec.tsx +179 -0
  224. package/src/fields/Icon/Icon.stories.tsx +138 -0
  225. package/src/fields/Link.stories.tsx +2 -0
  226. package/src/fields/LinkTree.tsx +18 -3
  227. package/src/fields/LongText/LongText.spec.tsx +217 -0
  228. package/src/fields/OTPInput/OTPInput.spec.tsx +167 -0
  229. package/src/fields/Password/Password.stories.tsx +21 -0
  230. package/src/fields/Phone/Phone.spec.tsx +151 -0
  231. package/src/fields/Progress/Progress.spec.tsx +160 -0
  232. package/src/fields/ReadOnly/ReadOnly.spec.tsx +229 -0
  233. package/src/fields/SmallText/SmallText.spec.tsx +175 -0
  234. package/src/fields/shared/EditorPreviewPanes.spec.tsx +122 -0
  235. package/src/formEngine/createPrefill.ts +28 -0
  236. package/src/formEngine/dirtyTracking.spec.ts +91 -0
  237. package/src/formEngine/dirtyTracking.ts +64 -0
  238. package/src/formEngine/doctypeMeta.ts +117 -0
  239. package/src/formEngine/fetchFrom.spec.ts +92 -0
  240. package/src/formEngine/fetchFrom.ts +111 -0
  241. package/src/formEngine/fieldPermissions.spec.ts +61 -0
  242. package/src/formEngine/fieldPermissions.ts +68 -0
  243. package/src/formEngine/formActions.ts +285 -0
  244. package/src/formEngine/formLayout.spec.ts +373 -0
  245. package/src/formEngine/formLayout.ts +392 -0
  246. package/src/formEngine/index.ts +33 -0
  247. package/src/formEngine/seam.spec.ts +148 -0
  248. package/src/formEngine/useFormEngine.ts +496 -0
  249. package/src/frappeRemainder.designLaw.spec.ts +9 -0
  250. package/src/frappeStoryTarget.tsx +25 -9
  251. package/src/frappeViews.designLaw.spec.ts +1 -0
  252. package/src/index.spec.ts +7 -3
  253. package/src/index.ts +51 -1
  254. package/src/schemaForm/SchemaForm.spec.tsx +4 -1
  255. package/types/ChartCard.d.ts +13 -0
  256. package/types/ChartCard.d.ts.map +1 -0
  257. package/types/FileBrowser/AttachLibrary.d.ts.map +1 -1
  258. package/types/FrappeDashboard.d.ts +2 -10
  259. package/types/FrappeDashboard.d.ts.map +1 -1
  260. package/types/FrappeImageView.d.ts.map +1 -1
  261. package/types/dependsOn.d.ts +2 -2
  262. package/types/fields/CodeEditor/index.d.ts.map +1 -1
  263. package/types/fields/LinkTree.d.ts.map +1 -1
  264. package/types/formEngine/createPrefill.d.ts +19 -0
  265. package/types/formEngine/createPrefill.d.ts.map +1 -0
  266. package/types/formEngine/dirtyTracking.d.ts +36 -0
  267. package/types/formEngine/dirtyTracking.d.ts.map +1 -0
  268. package/types/formEngine/doctypeMeta.d.ts +117 -0
  269. package/types/formEngine/doctypeMeta.d.ts.map +1 -0
  270. package/types/formEngine/fetchFrom.d.ts +43 -0
  271. package/types/formEngine/fetchFrom.d.ts.map +1 -0
  272. package/types/formEngine/fieldPermissions.d.ts +38 -0
  273. package/types/formEngine/fieldPermissions.d.ts.map +1 -0
  274. package/types/formEngine/formActions.d.ts +95 -0
  275. package/types/formEngine/formActions.d.ts.map +1 -0
  276. package/types/formEngine/formLayout.d.ts +91 -0
  277. package/types/formEngine/formLayout.d.ts.map +1 -0
  278. package/types/formEngine/index.d.ts +27 -0
  279. package/types/formEngine/index.d.ts.map +1 -0
  280. package/types/formEngine/useFormEngine.d.ts +61 -0
  281. package/types/formEngine/useFormEngine.d.ts.map +1 -0
  282. package/types/frappeStoryTarget.d.ts +2 -2
  283. package/types/frappeStoryTarget.d.ts.map +1 -1
  284. package/types/index.d.ts +4 -1
  285. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,167 @@
1
+ /**
2
+ * OTPInputField contract specs. The `OTP` fieldtype is a segmented code entry,
3
+ * so the adapter's promises are countable: `length` decides how many cells
4
+ * exist, `type` decides what a cell will accept, `secureTextEntry` decides
5
+ * whether the digits are legible, and `onComplete` fires exactly once the last
6
+ * cell is filled — a login screen wires its submit to that callback, so a
7
+ * dropped `onComplete` is a dead flow, not a cosmetic bug.
8
+ */
9
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
10
+ import { act, cleanup, fireEvent } from "@testing-library/react";
11
+ import type React from "react";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
14
+ import { OTPInputField } from ".";
15
+
16
+ afterEach(cleanup);
17
+
18
+ const cells = (container: HTMLElement) =>
19
+ Array.from(container.querySelectorAll("input")) as HTMLInputElement[];
20
+
21
+ describe("OTPInputField cell count", () => {
22
+ it("defaults to six cells, the stock one-time-code length", () => {
23
+ const result = renderWithProviders(<OTPInputField label="Code" value="" onChange={() => {}} />);
24
+ expect(cells(result.container)).toHaveLength(6);
25
+ });
26
+
27
+ it("honours an explicit length", () => {
28
+ const result = renderWithProviders(
29
+ <OTPInputField label="Code" value="" length={4} onChange={() => {}} />,
30
+ );
31
+ expect(cells(result.container)).toHaveLength(4);
32
+ });
33
+
34
+ it("paints a controlled value one character per cell", () => {
35
+ const result = renderWithProviders(
36
+ <OTPInputField label="Code" value="1234" length={4} onChange={() => {}} />,
37
+ );
38
+ expect(cells(result.container).map((c) => c.value)).toEqual(["1", "2", "3", "4"]);
39
+ });
40
+
41
+ it("leaves the tail blank for a partial value", () => {
42
+ const result = renderWithProviders(
43
+ <OTPInputField label="Code" value="12" length={4} onChange={() => {}} />,
44
+ );
45
+ expect(cells(result.container).map((c) => c.value)).toEqual(["1", "2", "", ""]);
46
+ });
47
+ });
48
+
49
+ describe("OTPInputField entry", () => {
50
+ it("emits the assembled code through canonical onChange", async () => {
51
+ const onChange = vi.fn();
52
+ const result = renderWithProviders(
53
+ <OTPInputField label="Code" value="" length={4} onChange={onChange} />,
54
+ );
55
+ await act(async () => {
56
+ fireEvent.change(cells(result.container)[0], { target: { value: "7" } });
57
+ });
58
+ expect(onChange).toHaveBeenCalled();
59
+ expect(onChange.mock.calls.at(-1)?.[0]).toBe("7");
60
+ });
61
+
62
+ it("fires onComplete when the last cell is filled, and not before", async () => {
63
+ const onComplete = vi.fn();
64
+ const result = renderWithProviders(
65
+ <OTPInputField
66
+ label="Code"
67
+ value="123"
68
+ length={4}
69
+ onComplete={onComplete}
70
+ onChange={() => {}}
71
+ />,
72
+ );
73
+ expect(onComplete).not.toHaveBeenCalled();
74
+ await act(async () => {
75
+ fireEvent.change(cells(result.container)[3], { target: { value: "4" } });
76
+ });
77
+ expect(onComplete).toHaveBeenCalledWith("1234");
78
+ });
79
+
80
+ it("numeric is the default cell mode", () => {
81
+ const result = renderWithProviders(
82
+ <OTPInputField label="Code" value="" length={4} onChange={() => {}} />,
83
+ );
84
+ expect(cells(result.container)[0].getAttribute("inputmode")).toBe("numeric");
85
+ });
86
+
87
+ it("alphanumeric widens the cell mode past digits", () => {
88
+ const result = renderWithProviders(
89
+ <OTPInputField label="Code" value="" length={4} type="alphanumeric" onChange={() => {}} />,
90
+ );
91
+ expect(cells(result.container)[0].getAttribute("inputmode")).not.toBe("numeric");
92
+ });
93
+
94
+ it("secureTextEntry masks the cells", () => {
95
+ const result = renderWithProviders(
96
+ <OTPInputField label="Code" value="1234" length={4} secureTextEntry onChange={() => {}} />,
97
+ );
98
+ expect(cells(result.container)[0].getAttribute("type")).toBe("password");
99
+ });
100
+
101
+ it("readOnly marks every cell readonly, which is how the browser refuses entry", async () => {
102
+ const onChange = vi.fn();
103
+ const result = renderWithProviders(
104
+ <OTPInputField label="Code" value="12" length={4} readOnly onChange={onChange} />,
105
+ );
106
+ // Every cell, not just the first: a partially readOnly OTP is a field a
107
+ // user can half-edit.
108
+ for (const cell of cells(result.container)) {
109
+ expect(cell.hasAttribute("readonly")).toBe(true);
110
+ }
111
+
112
+ // FINDING (MPO-123): the DOM attribute is the ONLY guard. Unlike
113
+ // forms/TextArea, whose onChangeText opens `if (disabled || readOnly)
114
+ // return;`, OTPInput's handleDigitChange has no such check — so a
115
+ // programmatic change still moves the digits and still emits. A real
116
+ // browser never fires change on a readonly input, so this is not a user
117
+ // -reachable bug today; it is a missing guard, asserted here as the
118
+ // behaviour that actually exists rather than the one that should.
119
+ await act(async () => {
120
+ fireEvent.change(cells(result.container)[2], { target: { value: "9" } });
121
+ });
122
+ expect(onChange).toHaveBeenCalled();
123
+ });
124
+
125
+ it("skeleton branch renders no cells", () => {
126
+ const result = renderWithProviders(
127
+ <OTPInputField label="Code" value="1234" length={4} skeleton onChange={() => {}} />,
128
+ );
129
+ expect(cells(result.container)).toHaveLength(0);
130
+ });
131
+ });
132
+
133
+ describe("registry resolution (OTP fieldtype)", () => {
134
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
135
+ return { value: "", onChange: () => {}, ...overrides };
136
+ }
137
+
138
+ it("resolves 'OTP' to OTPInputField", () => {
139
+ const Component = getFieldComponent("OTP")!;
140
+ expect(Component).toBeTruthy();
141
+ const element = (Component as Function)(
142
+ propsFor({ value: "123456", label: "Code" }),
143
+ ) as React.ReactElement<Record<string, any>>;
144
+ expect(element.type).toBe(OTPInputField);
145
+ expect(element.props.value).toBe("123456");
146
+ expect(element.props.label).toBe("Code");
147
+ });
148
+
149
+ it("substitutes an empty string for a null value so the cells stay controlled", () => {
150
+ const Component = getFieldComponent("OTP")!;
151
+ const element = (Component as Function)(
152
+ propsFor({ value: null as unknown as string }),
153
+ ) as React.ReactElement<Record<string, any>>;
154
+ expect(element.props.value).toBe("");
155
+ });
156
+
157
+ it("passes helperText / readOnly / skeleton / compact through", () => {
158
+ const Component = getFieldComponent("OTP")!;
159
+ const element = (Component as Function)(
160
+ propsFor({ helperText: "check your texts", readOnly: true, skeleton: true, compact: true }),
161
+ ) as React.ReactElement<Record<string, any>>;
162
+ expect(element.props.helperText).toBe("check your texts");
163
+ expect(element.props.readOnly).toBe(true);
164
+ expect(element.props.skeleton).toBe(true);
165
+ expect(element.props.compact).toBe(true);
166
+ });
167
+ });
@@ -1,5 +1,6 @@
1
1
  import { action } from "@multiplatform.one/storybook";
2
2
  import { DataTable, type DataTableColumn } from "@multiplatform.one/table";
3
+ import { SIZE_RECIPE_TOKENS } from "@multiplatform.one/theme";
3
4
  import { useState } from "react";
4
5
  import { YStack } from "tamagui";
5
6
  import { fieldtypeToTableField } from "../../fieldRegistry";
@@ -135,3 +136,23 @@ InTable.parameters = {
135
136
  },
136
137
  },
137
138
  };
139
+
140
+ /**
141
+ * Every size step, so AC1 ("a reveal toggle in the end-cap slot at every
142
+ * size step, generated from the size recipe rather than hardcoded") is
143
+ * measurable rather than argued. The end cap stamps `data-end-cap` with the
144
+ * square it took from `sizeRecipeForToken`, so the probe reads the recipe
145
+ * off the DOM instead of trusting the source.
146
+ */
147
+ export const Sizes = (args: StoryProps) => (
148
+ <YStack gap="$4" maxWidth={420}>
149
+ {SIZE_RECIPE_TOKENS.map((token) => (
150
+ <Controlled {...args} key={token} label={`size ${token}`} size={token as any} />
151
+ ))}
152
+ </YStack>
153
+ );
154
+ Sizes.args = {
155
+ value: "hunter2",
156
+ placeholder: "Enter password",
157
+ } as Partial<StoryProps>;
158
+ Sizes.storyName = "Sizes";
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Phone contract specs. The adapter is thin by design — compose the forms
3
+ * PhoneInput, emit canonical onChange — so what has to be pinned is that every
4
+ * prop the Frappe layer sets actually ARRIVES: the country the number is
5
+ * parsed against (`defaultCountry`, fed from df.options by the registry), the
6
+ * controlled value, and the read-only / disabled gates.
7
+ *
8
+ * A dropped `defaultCountry` is the failure that matters here: the same digits
9
+ * serialise to a different E.164 number under a different region, so a silent
10
+ * default of US would corrupt every non-US phone field on save.
11
+ */
12
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
13
+ import { act, cleanup, fireEvent } from "@testing-library/react";
14
+ import type React from "react";
15
+ import { afterEach, describe, expect, it, vi } from "vitest";
16
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
17
+ import { Phone } from ".";
18
+
19
+ afterEach(cleanup);
20
+
21
+ const phoneInput = (container: HTMLElement) =>
22
+ container.querySelector('[data-testid="phone-input"]') as HTMLInputElement | null;
23
+
24
+ describe("Phone composes the forms PhoneInput", () => {
25
+ it("renders the composite phone control, not a bare Input", () => {
26
+ const result = renderWithProviders(<Phone label="Mobile" value="" onChange={() => {}} />);
27
+ expect(phoneInput(result.container)).toBeTruthy();
28
+ expect(result.container.querySelector('[data-testid="phone-box"]')).toBeTruthy();
29
+ });
30
+
31
+ it("shows the national form of a controlled E.164 value", () => {
32
+ const result = renderWithProviders(
33
+ <Phone label="Mobile" value="+14155550123" onChange={() => {}} />,
34
+ );
35
+ const input = phoneInput(result.container);
36
+ expect(input?.value).toContain("415");
37
+ expect(input?.value).toContain("555");
38
+ });
39
+
40
+ it("emits a serialised number through canonical onChange", async () => {
41
+ const onChange = vi.fn();
42
+ const result = renderWithProviders(<Phone label="Mobile" value="" onChange={onChange} />);
43
+ const input = phoneInput(result.container) as HTMLInputElement;
44
+ await act(async () => {
45
+ fireEvent.change(input, { target: { value: "4155550123" } });
46
+ });
47
+ expect(onChange).toHaveBeenCalled();
48
+ const emitted = onChange.mock.calls.at(-1)?.[0] as string;
49
+ expect(emitted.startsWith("+")).toBe(true);
50
+ expect(emitted).toContain("4155550123");
51
+ });
52
+ });
53
+
54
+ describe("Phone defaultCountry", () => {
55
+ it("defaults to the +1 region when the field declares no country", () => {
56
+ const result = renderWithProviders(<Phone label="Mobile" value="" onChange={() => {}} />);
57
+ expect(result.container.textContent).toContain("+1");
58
+ });
59
+
60
+ it("honours defaultCountry, so the dial code follows the DocField", () => {
61
+ const result = renderWithProviders(
62
+ <Phone label="Mobile" value="" defaultCountry="GB" onChange={() => {}} />,
63
+ );
64
+ expect(result.container.textContent).toContain("+44");
65
+ });
66
+
67
+ it("parses the same digits under the declared region", async () => {
68
+ const onChange = vi.fn();
69
+ const result = renderWithProviders(
70
+ <Phone label="Mobile" value="" defaultCountry="IN" onChange={onChange} />,
71
+ );
72
+ await act(async () => {
73
+ fireEvent.change(phoneInput(result.container) as HTMLInputElement, {
74
+ target: { value: "8074052136" },
75
+ });
76
+ });
77
+ const emitted = onChange.mock.calls.at(-1)?.[0] as string;
78
+ expect(emitted.startsWith("+91")).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("Phone gates", () => {
83
+ it("readOnly marks the control read-only", () => {
84
+ const result = renderWithProviders(
85
+ <Phone label="Mobile" value="+14155550123" readOnly onChange={() => {}} />,
86
+ );
87
+ expect(phoneInput(result.container)?.hasAttribute("readonly")).toBe(true);
88
+ });
89
+
90
+ it("skeleton branch renders no phone control", () => {
91
+ const result = renderWithProviders(
92
+ <Phone label="Mobile" value="+14155550123" skeleton onChange={() => {}} />,
93
+ );
94
+ expect(phoneInput(result.container)).toBeNull();
95
+ });
96
+
97
+ it("shows the label, helperText and error", () => {
98
+ const result = renderWithProviders(
99
+ <Phone
100
+ label="Mobile"
101
+ value=""
102
+ helperText="include the country code"
103
+ error="not a number"
104
+ onChange={() => {}}
105
+ />,
106
+ );
107
+ expect(result.container.textContent).toContain("Mobile");
108
+ expect(result.container.textContent).toContain("not a number");
109
+ });
110
+ });
111
+
112
+ describe("registry resolution (Phone fieldtype)", () => {
113
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
114
+ return { value: "", onChange: () => {}, ...overrides };
115
+ }
116
+
117
+ it("resolves 'Phone' to the Phone adapter", () => {
118
+ const Component = getFieldComponent("Phone")!;
119
+ expect(Component).toBeTruthy();
120
+ const element = (Component as Function)(
121
+ propsFor({ value: "+14155550123", label: "Mobile" }),
122
+ ) as React.ReactElement<Record<string, any>>;
123
+ expect(element.type).toBe(Phone);
124
+ expect(element.props.value).toBe("+14155550123");
125
+ expect(element.props.label).toBe("Mobile");
126
+ });
127
+
128
+ it("takes defaultCountry from df.options — this is the DocField's own region", () => {
129
+ const Component = getFieldComponent("Phone")!;
130
+ const element = (Component as Function)(
131
+ propsFor({ fieldMeta: { options: "IN" } } as Partial<FieldRenderProps>),
132
+ ) as React.ReactElement<Record<string, any>>;
133
+ expect(element.props.defaultCountry).toBe("IN");
134
+ });
135
+
136
+ it("leaves defaultCountry unset when df.options is empty, so PhoneInput owns the default", () => {
137
+ const Component = getFieldComponent("Phone")!;
138
+ const element = (Component as Function)(
139
+ propsFor({ fieldMeta: { options: "" } } as Partial<FieldRenderProps>),
140
+ ) as React.ReactElement<Record<string, any>>;
141
+ expect(element.props.defaultCountry).toBeUndefined();
142
+ });
143
+
144
+ it("substitutes an empty string for a null value", () => {
145
+ const Component = getFieldComponent("Phone")!;
146
+ const element = (Component as Function)(
147
+ propsFor({ value: null as unknown as string }),
148
+ ) as React.ReactElement<Record<string, any>>;
149
+ expect(element.props.value).toBe("");
150
+ });
151
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * ProgressField contract specs.
3
+ *
4
+ * ProgressField is a thin adapter: it takes the Frappe field props and hands
5
+ * them to the forms Progress, which owns the track, the clamp and the label
6
+ * chrome. So what is worth pinning here is the FORWARDING — every prop the
7
+ * adapter is documented to pass (readOnly / skeleton / compact / max /
8
+ * helperText / error) has to actually arrive, because the failure mode of a
9
+ * thin adapter is a silently dropped prop that makes three story variants
10
+ * render identically (the ContractProbe story exists for exactly that).
11
+ *
12
+ * The registry half matters too: "Progress" is a display fieldtype, so its
13
+ * adapter must coerce a non-number to 0 rather than hand the track NaN.
14
+ */
15
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
16
+ import { cleanup } from "@testing-library/react";
17
+ import type React from "react";
18
+ import { afterEach, describe, expect, it } from "vitest";
19
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
20
+ import { ProgressField } from ".";
21
+
22
+ afterEach(cleanup);
23
+
24
+ describe("ProgressField chrome", () => {
25
+ it("renders the label and a track", () => {
26
+ const { container } = renderWithProviders(
27
+ <ProgressField label="Completion" value={65} max={100} />,
28
+ );
29
+ expect(container.textContent).toContain("Completion");
30
+ expect(container.firstElementChild).toBeTruthy();
31
+ });
32
+
33
+ it("shows helperText", () => {
34
+ const { container } = renderWithProviders(
35
+ <ProgressField label="Completion" value={65} helperText="how far along" />,
36
+ );
37
+ expect(container.textContent).toContain("how far along");
38
+ });
39
+
40
+ it("shows the error message", () => {
41
+ const { container } = renderWithProviders(
42
+ <ProgressField label="Completion" value={65} error="Value out of range" />,
43
+ );
44
+ expect(container.textContent).toContain("Value out of range");
45
+ });
46
+
47
+ it("marks a required field", () => {
48
+ const { container } = renderWithProviders(
49
+ <ProgressField label="Completion" value={65} required />,
50
+ );
51
+ expect(container.textContent).toContain("Completion");
52
+ expect(container.querySelector("[aria-required], [aria-hidden]")).toBeTruthy();
53
+ });
54
+
55
+ it("a value with no max still paints (max defaults to 100)", () => {
56
+ const { container } = renderWithProviders(<ProgressField label="Completion" value={40} />);
57
+ expect(container.firstElementChild).toBeTruthy();
58
+ expect(container.textContent).toContain("Completion");
59
+ });
60
+ });
61
+
62
+ /**
63
+ * The ContractProbe story asserts these three visually. If the adapter drops
64
+ * any of them the probe variants render identically to Main and nobody
65
+ * notices, so pin them structurally too.
66
+ */
67
+ describe("ProgressField forwards the thin-adapter contract", () => {
68
+ it("skeleton renders a different tree from the loaded field", () => {
69
+ const loaded = renderWithProviders(<ProgressField label="Completion" value={65} />);
70
+ const loadedHtml = loaded.container.innerHTML;
71
+ cleanup();
72
+ const skeleton = renderWithProviders(<ProgressField label="Completion" value={65} skeleton />);
73
+ expect(skeleton.container.innerHTML).not.toBe(loadedHtml);
74
+ });
75
+
76
+ it("compact renders a different tree from the default density", () => {
77
+ const normal = renderWithProviders(<ProgressField label="Completion" value={65} />);
78
+ const normalHtml = normal.container.innerHTML;
79
+ cleanup();
80
+ const compact = renderWithProviders(<ProgressField label="Completion" value={65} compact />);
81
+ expect(compact.container.innerHTML).not.toBe(normalHtml);
82
+ });
83
+
84
+ it("readOnly is accepted and still paints the value", () => {
85
+ const { container } = renderWithProviders(
86
+ <ProgressField label="Completion" value={65} readOnly />,
87
+ );
88
+ expect(container.textContent).toContain("Completion");
89
+ expect(container.firstElementChild).toBeTruthy();
90
+ });
91
+
92
+ it("max reaches the track: the same value at two maxes renders differently", () => {
93
+ const wide = renderWithProviders(<ProgressField label="Completion" value={50} max={100} />);
94
+ const wideHtml = wide.container.innerHTML;
95
+ cleanup();
96
+ const narrow = renderWithProviders(<ProgressField label="Completion" value={50} max={50} />);
97
+ expect(narrow.container.innerHTML).not.toBe(wideHtml);
98
+ });
99
+ });
100
+
101
+ describe("registry resolution (Progress fieldtype)", () => {
102
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
103
+ return { value: "", onChange: () => {}, ...overrides };
104
+ }
105
+
106
+ it("resolves 'Progress' to ProgressField", () => {
107
+ const Component = getFieldComponent("Progress")!;
108
+ expect(Component).toBeTruthy();
109
+ const element = (Component as Function)(
110
+ propsFor({ value: 65, label: "Completion" }),
111
+ ) as React.ReactElement<Record<string, any>>;
112
+ expect(element.type).toBe(ProgressField);
113
+ expect(element.props.value).toBe(65);
114
+ expect(element.props.label).toBe("Completion");
115
+ });
116
+
117
+ it("coerces a non-number to 0 rather than handing the track NaN", () => {
118
+ const Component = getFieldComponent("Progress")!;
119
+ const element = (Component as Function)(
120
+ propsFor({ value: "not a number" }),
121
+ ) as React.ReactElement<Record<string, any>>;
122
+ expect(element.props.value).toBe(0);
123
+ });
124
+
125
+ it("a null value becomes 0, not null", () => {
126
+ const Component = getFieldComponent("Progress")!;
127
+ const element = (Component as Function)(
128
+ propsFor({ value: null as unknown as string }),
129
+ ) as React.ReactElement<Record<string, any>>;
130
+ expect(element.props.value).toBe(0);
131
+ });
132
+
133
+ it("forwards the passthrough contract (helperText / readOnly / skeleton / compact)", () => {
134
+ const Component = getFieldComponent("Progress")!;
135
+ const element = (Component as Function)(
136
+ propsFor({
137
+ value: 20,
138
+ helperText: "from the doc",
139
+ readOnly: true,
140
+ skeleton: true,
141
+ compact: true,
142
+ } as Partial<FieldRenderProps>),
143
+ ) as React.ReactElement<Record<string, any>>;
144
+ expect(element.props.helperText).toBe("from the doc");
145
+ expect(element.props.readOnly).toBe(true);
146
+ expect(element.props.skeleton).toBe(true);
147
+ expect(element.props.compact).toBe(true);
148
+ });
149
+
150
+ it("falls back to the DocField description when no helperText is given", () => {
151
+ const Component = getFieldComponent("Progress")!;
152
+ const element = (Component as Function)(
153
+ propsFor({
154
+ value: 20,
155
+ fieldMeta: { description: "percent complete" },
156
+ } as Partial<FieldRenderProps>),
157
+ ) as React.ReactElement<Record<string, any>>;
158
+ expect(element.props.helperText).toBe("percent complete");
159
+ });
160
+ });