@multiplatform.one/frappe-ui 7.6.2 → 7.7.0

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 (229) 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/FrappeChartCard.cjs +3 -3
  5. package/dist/cjs/FrappeChartCard.native.js +3 -3
  6. package/dist/cjs/FrappeChartCard.native.js.map +1 -1
  7. package/dist/cjs/FrappeDashboard.cjs +6 -27
  8. package/dist/cjs/FrappeDashboard.native.js +6 -33
  9. package/dist/cjs/FrappeDashboard.native.js.map +1 -1
  10. package/dist/cjs/fields/CodeEditor/index.cjs +4 -0
  11. package/dist/cjs/fields/CodeEditor/index.native.js +4 -0
  12. package/dist/cjs/fields/CodeEditor/index.native.js.map +1 -1
  13. package/dist/cjs/formEngine/createPrefill.cjs +37 -0
  14. package/dist/cjs/formEngine/createPrefill.native.js +40 -0
  15. package/dist/cjs/formEngine/createPrefill.native.js.map +1 -0
  16. package/dist/cjs/formEngine/dirtyTracking.cjs +55 -0
  17. package/dist/cjs/formEngine/dirtyTracking.native.js +77 -0
  18. package/dist/cjs/formEngine/dirtyTracking.native.js.map +1 -0
  19. package/dist/cjs/formEngine/doctypeMeta.cjs +18 -0
  20. package/dist/cjs/formEngine/doctypeMeta.native.js +21 -0
  21. package/dist/cjs/formEngine/doctypeMeta.native.js.map +1 -0
  22. package/dist/cjs/formEngine/fetchFrom.cjs +67 -0
  23. package/dist/cjs/formEngine/fetchFrom.native.js +110 -0
  24. package/dist/cjs/formEngine/fetchFrom.native.js.map +1 -0
  25. package/dist/cjs/formEngine/fieldPermissions.cjs +55 -0
  26. package/dist/cjs/formEngine/fieldPermissions.native.js +80 -0
  27. package/dist/cjs/formEngine/fieldPermissions.native.js.map +1 -0
  28. package/dist/cjs/formEngine/formActions.cjs +189 -0
  29. package/dist/cjs/formEngine/formActions.native.js +199 -0
  30. package/dist/cjs/formEngine/formActions.native.js.map +1 -0
  31. package/dist/cjs/formEngine/formLayout.cjs +245 -0
  32. package/dist/cjs/formEngine/formLayout.native.js +388 -0
  33. package/dist/cjs/formEngine/formLayout.native.js.map +1 -0
  34. package/dist/cjs/formEngine/index.cjs +48 -0
  35. package/dist/cjs/formEngine/index.native.js +51 -0
  36. package/dist/cjs/formEngine/index.native.js.map +1 -0
  37. package/dist/cjs/formEngine/useFormEngine.cjs +387 -0
  38. package/dist/cjs/formEngine/useFormEngine.native.js +476 -0
  39. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -0
  40. package/dist/cjs/index.cjs +19 -1
  41. package/dist/cjs/index.native.js +19 -1
  42. package/dist/cjs/index.native.js.map +1 -1
  43. package/dist/esm/ChartCard.mjs +27 -0
  44. package/dist/esm/ChartCard.mjs.map +1 -0
  45. package/dist/esm/ChartCard.native.js +33 -0
  46. package/dist/esm/ChartCard.native.js.map +1 -0
  47. package/dist/esm/FrappeChartCard.mjs +1 -1
  48. package/dist/esm/FrappeChartCard.native.js +1 -1
  49. package/dist/esm/FrappeChartCard.native.js.map +1 -1
  50. package/dist/esm/FrappeDashboard.mjs +6 -27
  51. package/dist/esm/FrappeDashboard.mjs.map +1 -1
  52. package/dist/esm/FrappeDashboard.native.js +6 -33
  53. package/dist/esm/FrappeDashboard.native.js.map +1 -1
  54. package/dist/esm/fields/CodeEditor/index.mjs +4 -0
  55. package/dist/esm/fields/CodeEditor/index.mjs.map +1 -1
  56. package/dist/esm/fields/CodeEditor/index.native.js +4 -0
  57. package/dist/esm/fields/CodeEditor/index.native.js.map +1 -1
  58. package/dist/esm/formEngine/createPrefill.mjs +11 -0
  59. package/dist/esm/formEngine/createPrefill.mjs.map +1 -0
  60. package/dist/esm/formEngine/createPrefill.native.js +11 -0
  61. package/dist/esm/formEngine/createPrefill.native.js.map +1 -0
  62. package/dist/esm/formEngine/dirtyTracking.mjs +28 -0
  63. package/dist/esm/formEngine/dirtyTracking.mjs.map +1 -0
  64. package/dist/esm/formEngine/dirtyTracking.native.js +47 -0
  65. package/dist/esm/formEngine/dirtyTracking.native.js.map +1 -0
  66. package/dist/esm/formEngine/doctypeMeta.mjs +2 -0
  67. package/dist/esm/formEngine/doctypeMeta.mjs.map +1 -0
  68. package/dist/esm/formEngine/doctypeMeta.native.js +2 -0
  69. package/dist/esm/formEngine/doctypeMeta.native.js.map +1 -0
  70. package/dist/esm/formEngine/fetchFrom.mjs +40 -0
  71. package/dist/esm/formEngine/fetchFrom.mjs.map +1 -0
  72. package/dist/esm/formEngine/fetchFrom.native.js +80 -0
  73. package/dist/esm/formEngine/fetchFrom.native.js.map +1 -0
  74. package/dist/esm/formEngine/fieldPermissions.mjs +28 -0
  75. package/dist/esm/formEngine/fieldPermissions.mjs.map +1 -0
  76. package/dist/esm/formEngine/fieldPermissions.native.js +50 -0
  77. package/dist/esm/formEngine/fieldPermissions.native.js.map +1 -0
  78. package/dist/esm/formEngine/formActions.mjs +146 -0
  79. package/dist/esm/formEngine/formActions.mjs.map +1 -0
  80. package/dist/esm/formEngine/formActions.native.js +153 -0
  81. package/dist/esm/formEngine/formActions.native.js.map +1 -0
  82. package/dist/esm/formEngine/formLayout.mjs +217 -0
  83. package/dist/esm/formEngine/formLayout.mjs.map +1 -0
  84. package/dist/esm/formEngine/formLayout.native.js +357 -0
  85. package/dist/esm/formEngine/formLayout.native.js.map +1 -0
  86. package/dist/esm/formEngine/index.mjs +8 -0
  87. package/dist/esm/formEngine/index.mjs.map +1 -0
  88. package/dist/esm/formEngine/index.native.js +8 -0
  89. package/dist/esm/formEngine/index.native.js.map +1 -0
  90. package/dist/esm/formEngine/useFormEngine.mjs +362 -0
  91. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -0
  92. package/dist/esm/formEngine/useFormEngine.native.js +448 -0
  93. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -0
  94. package/dist/esm/index.mjs +4 -2
  95. package/dist/esm/index.mjs.map +1 -1
  96. package/dist/esm/index.native.js +4 -2
  97. package/dist/esm/index.native.js.map +1 -1
  98. package/dist/jsx/ChartCard.mjs +27 -0
  99. package/dist/jsx/ChartCard.mjs.map +1 -0
  100. package/dist/jsx/ChartCard.native.js +72 -0
  101. package/dist/jsx/ChartCard.native.js.map +1 -0
  102. package/dist/jsx/FrappeChartCard.mjs +1 -1
  103. package/dist/jsx/FrappeChartCard.native.js +3 -3
  104. package/dist/jsx/FrappeChartCard.native.js.map +1 -1
  105. package/dist/jsx/FrappeDashboard.mjs +6 -27
  106. package/dist/jsx/FrappeDashboard.mjs.map +1 -1
  107. package/dist/jsx/FrappeDashboard.native.js +6 -33
  108. package/dist/jsx/FrappeDashboard.native.js.map +1 -1
  109. package/dist/jsx/fields/CodeEditor/index.mjs +4 -0
  110. package/dist/jsx/fields/CodeEditor/index.mjs.map +1 -1
  111. package/dist/jsx/fields/CodeEditor/index.native.js +4 -0
  112. package/dist/jsx/fields/CodeEditor/index.native.js.map +1 -1
  113. package/dist/jsx/formEngine/createPrefill.mjs +11 -0
  114. package/dist/jsx/formEngine/createPrefill.mjs.map +1 -0
  115. package/dist/jsx/formEngine/createPrefill.native.js +40 -0
  116. package/dist/jsx/formEngine/createPrefill.native.js.map +1 -0
  117. package/dist/jsx/formEngine/dirtyTracking.mjs +28 -0
  118. package/dist/jsx/formEngine/dirtyTracking.mjs.map +1 -0
  119. package/dist/jsx/formEngine/dirtyTracking.native.js +77 -0
  120. package/dist/jsx/formEngine/dirtyTracking.native.js.map +1 -0
  121. package/dist/jsx/formEngine/doctypeMeta.mjs +2 -0
  122. package/dist/jsx/formEngine/doctypeMeta.mjs.map +1 -0
  123. package/dist/jsx/formEngine/doctypeMeta.native.js +21 -0
  124. package/dist/jsx/formEngine/doctypeMeta.native.js.map +1 -0
  125. package/dist/jsx/formEngine/fetchFrom.mjs +40 -0
  126. package/dist/jsx/formEngine/fetchFrom.mjs.map +1 -0
  127. package/dist/jsx/formEngine/fetchFrom.native.js +110 -0
  128. package/dist/jsx/formEngine/fetchFrom.native.js.map +1 -0
  129. package/dist/jsx/formEngine/fieldPermissions.mjs +28 -0
  130. package/dist/jsx/formEngine/fieldPermissions.mjs.map +1 -0
  131. package/dist/jsx/formEngine/fieldPermissions.native.js +80 -0
  132. package/dist/jsx/formEngine/fieldPermissions.native.js.map +1 -0
  133. package/dist/jsx/formEngine/formActions.mjs +146 -0
  134. package/dist/jsx/formEngine/formActions.mjs.map +1 -0
  135. package/dist/jsx/formEngine/formActions.native.js +199 -0
  136. package/dist/jsx/formEngine/formActions.native.js.map +1 -0
  137. package/dist/jsx/formEngine/formLayout.mjs +217 -0
  138. package/dist/jsx/formEngine/formLayout.mjs.map +1 -0
  139. package/dist/jsx/formEngine/formLayout.native.js +388 -0
  140. package/dist/jsx/formEngine/formLayout.native.js.map +1 -0
  141. package/dist/jsx/formEngine/index.mjs +8 -0
  142. package/dist/jsx/formEngine/index.mjs.map +1 -0
  143. package/dist/jsx/formEngine/index.native.js +51 -0
  144. package/dist/jsx/formEngine/index.native.js.map +1 -0
  145. package/dist/jsx/formEngine/useFormEngine.mjs +362 -0
  146. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -0
  147. package/dist/jsx/formEngine/useFormEngine.native.js +476 -0
  148. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -0
  149. package/dist/jsx/index.js +4 -2
  150. package/dist/jsx/index.js.map +1 -1
  151. package/dist/jsx/index.mjs +4 -2
  152. package/dist/jsx/index.mjs.map +1 -1
  153. package/dist/jsx/index.native.js +19 -1
  154. package/dist/jsx/index.native.js.map +1 -1
  155. package/package.json +23 -17
  156. package/src/ChartCard.tsx +31 -0
  157. package/src/FileBrowser/FileBrowser.spec.tsx +16 -0
  158. package/src/FileBrowser/FileBrowser.stories.tsx +18 -1
  159. package/src/FileBrowser/folderTree.spec.tsx +68 -0
  160. package/src/FrappeCalendar.stories.tsx +1 -2
  161. package/src/FrappeChartCard.stories.tsx +1 -1
  162. package/src/FrappeChartCard.tsx +1 -1
  163. package/src/FrappeDashboard.stories.tsx +6 -5
  164. package/src/FrappeDashboard.tsx +6 -33
  165. package/src/FrappeGantt.stories.tsx +1 -2
  166. package/src/FrappeList.stories.tsx +3 -10
  167. package/src/components/attachments.stories.tsx +9 -2
  168. package/src/dependsOn.ts +2 -2
  169. package/src/fields/CodeEditor/CodeEditor.spec.tsx +37 -0
  170. package/src/fields/CodeEditor/CodeEditor.stories.tsx +83 -66
  171. package/src/fields/CodeEditor/index.tsx +4 -0
  172. package/src/fields/DateRange/DateRange.spec.tsx +143 -0
  173. package/src/fields/FileUpload.spec.tsx +334 -0
  174. package/src/fields/FileUploadField/FileUploadField.spec.tsx +166 -0
  175. package/src/fields/HTML/Html.spec.tsx +179 -0
  176. package/src/fields/Icon/Icon.stories.tsx +138 -0
  177. package/src/fields/Link.stories.tsx +2 -0
  178. package/src/fields/LongText/LongText.spec.tsx +217 -0
  179. package/src/fields/OTPInput/OTPInput.spec.tsx +167 -0
  180. package/src/fields/Password/Password.stories.tsx +21 -0
  181. package/src/fields/Phone/Phone.spec.tsx +151 -0
  182. package/src/fields/ReadOnly/ReadOnly.spec.tsx +229 -0
  183. package/src/fields/SmallText/SmallText.spec.tsx +175 -0
  184. package/src/fields/shared/EditorPreviewPanes.spec.tsx +122 -0
  185. package/src/formEngine/createPrefill.ts +28 -0
  186. package/src/formEngine/dirtyTracking.spec.ts +91 -0
  187. package/src/formEngine/dirtyTracking.ts +64 -0
  188. package/src/formEngine/doctypeMeta.ts +117 -0
  189. package/src/formEngine/fetchFrom.spec.ts +92 -0
  190. package/src/formEngine/fetchFrom.ts +111 -0
  191. package/src/formEngine/fieldPermissions.spec.ts +61 -0
  192. package/src/formEngine/fieldPermissions.ts +68 -0
  193. package/src/formEngine/formActions.ts +285 -0
  194. package/src/formEngine/formLayout.spec.ts +373 -0
  195. package/src/formEngine/formLayout.ts +392 -0
  196. package/src/formEngine/index.ts +33 -0
  197. package/src/formEngine/seam.spec.ts +148 -0
  198. package/src/formEngine/useFormEngine.ts +496 -0
  199. package/src/frappeRemainder.designLaw.spec.ts +9 -0
  200. package/src/frappeViews.designLaw.spec.ts +1 -0
  201. package/src/index.spec.ts +7 -3
  202. package/src/index.ts +51 -1
  203. package/src/schemaForm/SchemaForm.spec.tsx +4 -1
  204. package/types/ChartCard.d.ts +13 -0
  205. package/types/ChartCard.d.ts.map +1 -0
  206. package/types/FrappeDashboard.d.ts +2 -10
  207. package/types/FrappeDashboard.d.ts.map +1 -1
  208. package/types/dependsOn.d.ts +2 -2
  209. package/types/fields/CodeEditor/index.d.ts.map +1 -1
  210. package/types/formEngine/createPrefill.d.ts +19 -0
  211. package/types/formEngine/createPrefill.d.ts.map +1 -0
  212. package/types/formEngine/dirtyTracking.d.ts +36 -0
  213. package/types/formEngine/dirtyTracking.d.ts.map +1 -0
  214. package/types/formEngine/doctypeMeta.d.ts +117 -0
  215. package/types/formEngine/doctypeMeta.d.ts.map +1 -0
  216. package/types/formEngine/fetchFrom.d.ts +43 -0
  217. package/types/formEngine/fetchFrom.d.ts.map +1 -0
  218. package/types/formEngine/fieldPermissions.d.ts +38 -0
  219. package/types/formEngine/fieldPermissions.d.ts.map +1 -0
  220. package/types/formEngine/formActions.d.ts +95 -0
  221. package/types/formEngine/formActions.d.ts.map +1 -0
  222. package/types/formEngine/formLayout.d.ts +91 -0
  223. package/types/formEngine/formLayout.d.ts.map +1 -0
  224. package/types/formEngine/index.d.ts +27 -0
  225. package/types/formEngine/index.d.ts.map +1 -0
  226. package/types/formEngine/useFormEngine.d.ts +61 -0
  227. package/types/formEngine/useFormEngine.d.ts.map +1 -0
  228. package/types/index.d.ts +4 -1
  229. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,138 @@
1
+ import { action } from "@multiplatform.one/storybook";
2
+ import { DataTable, type DataTableColumn } from "@multiplatform.one/table";
3
+ import { useState } from "react";
4
+ import { YStack } from "tamagui";
5
+ import { fieldtypeToTableField, getFieldComponent, initDefaultRegistry } from "../../fieldRegistry";
6
+ import { FrappeStoryProvider } from "../../frappeStoryTarget";
7
+ import { Icon, type IconProps } from ".";
8
+
9
+ export default {
10
+ title: "FrappeUI/Icon",
11
+ component: Icon,
12
+ parameters: {
13
+ status: { type: "stable" },
14
+ docs: {
15
+ description: {
16
+ component:
17
+ "Icon picker for the Frappe Icon field type. Options come from the DG-IDN-06 drawn set and open in a FloatingPanel; df.options narrows the catalog to a subset.",
18
+ },
19
+ },
20
+ },
21
+ argTypes: {
22
+ label: { control: "text", description: "Field label" },
23
+ value: { control: "text", description: "Selected drawn-icon name" },
24
+ placeholder: { control: "text", description: "Placeholder text" },
25
+ disabled: { control: "boolean", description: "Disable the field" },
26
+ readOnly: { control: "boolean", description: "Render read-only" },
27
+ required: { control: "boolean", description: "Mark as required" },
28
+ compact: { control: "boolean", description: "Compact (table cell) presentation" },
29
+ options: { control: "text", description: "df.options — newline list narrowing the catalog" },
30
+ error: { control: "text", description: "Error message to display" },
31
+ },
32
+ };
33
+
34
+ function ControlledIcon(props: Omit<IconProps, "onChange">) {
35
+ const [value, setValue] = useState(props.value || "");
36
+ return (
37
+ <Icon
38
+ {...props}
39
+ value={value}
40
+ onChange={(v) => {
41
+ setValue(v);
42
+ action("onChange")(v);
43
+ }}
44
+ />
45
+ );
46
+ }
47
+
48
+ export const Default = (args: IconProps) => <ControlledIcon {...args} />;
49
+ Default.args = {
50
+ label: "Workspace icon",
51
+ placeholder: "Choose an icon",
52
+ } satisfies Partial<IconProps>;
53
+
54
+ Default.storyName = "Main";
55
+
56
+ export const AllStates = () => (
57
+ <YStack gap="$4" width={400}>
58
+ <ControlledIcon label="Default" placeholder="Choose an icon" />
59
+ <ControlledIcon label="With Value" value="mail" />
60
+ <ControlledIcon label="Required" required />
61
+ <ControlledIcon label="With Error" error="Pick an icon for this workspace" />
62
+ <ControlledIcon label="Disabled" value="upload" disabled />
63
+ <ControlledIcon label="Read Only" value="disk" readOnly />
64
+ <ControlledIcon label="Narrowed by options" value="check" options={"check\nwarn\nx"} />
65
+ </YStack>
66
+ );
67
+ AllStates.parameters = {
68
+ docs: {
69
+ description: {
70
+ story: "All possible states of the Icon field component.",
71
+ },
72
+ },
73
+ };
74
+
75
+ // MPO-51 AC-1. A DocField of fieldtype Icon has to reach this picker through
76
+ // the registry, not just through a direct import. MPO-60 (035867446) clobbered
77
+ // registerField("Icon", …) once already; this story renders whatever the
78
+ // registry hands back, so a repeat shows up as the "Unsupported field type"
79
+ // string on the board rather than only in union_vs_registry.py.
80
+ export const ViaRegistry = () => {
81
+ initDefaultRegistry();
82
+ const RegisteredIcon = getFieldComponent("Icon");
83
+ const [value, setValue] = useState("mail");
84
+ if (!RegisteredIcon) {
85
+ throw new Error("fieldtype Icon is not registered — initDefaultRegistry() lost registerField");
86
+ }
87
+ return (
88
+ <YStack gap="$4" width={400}>
89
+ <RegisteredIcon
90
+ label="Workspace icon (through the registry)"
91
+ value={value}
92
+ onChange={(v: unknown) => {
93
+ setValue(String(v ?? ""));
94
+ action("onChange")(v);
95
+ }}
96
+ />
97
+ </YStack>
98
+ );
99
+ };
100
+ ViaRegistry.storyName = "Via registry";
101
+ ViaRegistry.parameters = {
102
+ docs: {
103
+ description: {
104
+ story:
105
+ "Resolves the component through getFieldComponent('Icon') the way a DocField-driven form does, so a lost registration is visible here and not only in the census script.",
106
+ },
107
+ },
108
+ };
109
+
110
+ interface IconRow {
111
+ id: string;
112
+ label: string;
113
+ value: string;
114
+ }
115
+
116
+ export const InTable = () => {
117
+ const [data, setData] = useState<IconRow[]>([
118
+ { id: "1", label: "Inbox", value: "mail" },
119
+ { id: "2", label: "Uploads", value: "upload" },
120
+ { id: "3", label: "Unset", value: "" },
121
+ ]);
122
+ const columns: DataTableColumn<IconRow>[] = [
123
+ { accessorKey: "label", header: "Row" },
124
+ {
125
+ accessorKey: "value",
126
+ header: "Icon",
127
+ ...fieldtypeToTableField("Icon"),
128
+ },
129
+ ];
130
+ return (
131
+ <FrappeStoryProvider>
132
+ <YStack width="100%" height={280}>
133
+ <DataTable data={data} columns={columns} enablePagination={false} onDataChange={setData} />
134
+ </YStack>
135
+ </FrappeStoryProvider>
136
+ );
137
+ };
138
+ InTable.storyName = "In table";
@@ -50,6 +50,8 @@ const MOCK_HOST = "https://frappelink.mock.test";
50
50
  function contactMeta() {
51
51
  return {
52
52
  name: "Contact",
53
+ // FixtureDoc requires `doctype`; a DocType meta doc's own doctype is DocType.
54
+ doctype: "DocType",
53
55
  module: "Contacts",
54
56
  title_field: "first_name",
55
57
  fields: [
@@ -0,0 +1,217 @@
1
+ /**
2
+ * LongText contract specs. Frappe's `Long Text` and `Text` fieldtypes both land
3
+ * here, and the adapter's whole job is: compose the forms TextArea (never fork
4
+ * Input chrome), emit canonical onChange, and translate the Frappe-shaped
5
+ * `minHeight` in pixels into the TextArea's row budget.
6
+ *
7
+ * The row budget is the one piece of real arithmetic in the file
8
+ * (`minRows = minHeight ? max(4, round(minHeight / 24)) : 6`, maxRows 16).
9
+ *
10
+ * It does NOT reach the DOM as the textarea's `rows` attribute. TextArea
11
+ * spreads `numberOfLines: minRows` onto the Tamagui TextArea, and on web that
12
+ * prop is dropped: `rows` measured 3 for every adapter at every minHeight
13
+ * (SmallText's 2 included). What the budget DOES drive is the rendered minimum
14
+ * height, `getFieldHeight(size, minRows)`, so the rows are asserted through
15
+ * that instead — one row of pixels is read off a known 2-row render rather
16
+ * than hard-coded, so a size-token change moves both sides together.
17
+ */
18
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
19
+ import { cleanup, fireEvent } from "@testing-library/react";
20
+ import type React from "react";
21
+ import { afterEach, describe, expect, it, vi } from "vitest";
22
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
23
+ import { SmallText } from "../SmallText";
24
+ import { LongText } from ".";
25
+
26
+ afterEach(cleanup);
27
+
28
+ /** Rendered minimum height in px, off the Tamagui `_mih-<n>px` class. */
29
+ const minHeightPxOf = (container: HTMLElement) => {
30
+ const cls = container.querySelector("textarea")?.getAttribute("class") ?? "";
31
+ const match = /(?:^|\s)_mih-(\d+)px(?:\s|$)/.exec(cls);
32
+ return match ? Number(match[1]) : undefined;
33
+ };
34
+
35
+ /**
36
+ * One text row in px, derived from SmallText's fixed two-row budget rather
37
+ * than hard-coded, so this spec follows a size-token change instead of
38
+ * breaking on one.
39
+ */
40
+ function rowPx(): number {
41
+ const probe = renderWithProviders(<SmallText label="probe" value="" onChange={() => {}} />);
42
+ const px = minHeightPxOf(probe.container);
43
+ cleanup();
44
+ if (!px) throw new Error("could not measure a row: SmallText rendered no _mih- class");
45
+ return px / 2;
46
+ }
47
+
48
+ /** Rendered rows = rendered minimum height / one row. */
49
+ const rowsOf = (container: HTMLElement) => {
50
+ const px = minHeightPxOf(container);
51
+ return px === undefined ? undefined : String(px / rowPx());
52
+ };
53
+
54
+ describe("LongText composes the forms TextArea", () => {
55
+ it("renders a textarea, not a single-line input", () => {
56
+ const result = renderWithProviders(<LongText label="Notes" value="" onChange={() => {}} />);
57
+ expect(result.getTextArea()).toBeTruthy();
58
+ expect(result.container.querySelector('input[type="text"]')).toBeNull();
59
+ });
60
+
61
+ it("shows the controlled value", () => {
62
+ const result = renderWithProviders(
63
+ <LongText label="Notes" value="line one" onChange={() => {}} />,
64
+ );
65
+ expect((result.getTextArea() as HTMLTextAreaElement).value).toBe("line one");
66
+ });
67
+
68
+ it("emits the text through canonical onChange", () => {
69
+ const onChange = vi.fn();
70
+ const result = renderWithProviders(<LongText label="Notes" value="" onChange={onChange} />);
71
+ fireEvent.change(result.getTextArea() as HTMLTextAreaElement, {
72
+ target: { value: "typed" },
73
+ });
74
+ expect(onChange).toHaveBeenCalledWith("typed");
75
+ });
76
+
77
+ it("readOnly blocks emission", () => {
78
+ const onChange = vi.fn();
79
+ const result = renderWithProviders(
80
+ <LongText label="Notes" value="original" readOnly onChange={onChange} />,
81
+ );
82
+ const textarea = result.getTextArea() as HTMLTextAreaElement;
83
+ expect(textarea.hasAttribute("readonly")).toBe(true);
84
+ fireEvent.change(textarea, { target: { value: "sneaky" } });
85
+ expect(onChange).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it("disabled blocks emission", () => {
89
+ const onChange = vi.fn();
90
+ const result = renderWithProviders(
91
+ <LongText label="Notes" value="original" disabled onChange={onChange} />,
92
+ );
93
+ fireEvent.change(result.getTextArea() as HTMLTextAreaElement, {
94
+ target: { value: "sneaky" },
95
+ });
96
+ expect(onChange).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("forwards maxLength to the control", () => {
100
+ const result = renderWithProviders(
101
+ <LongText label="Notes" value="" maxLength={140} onChange={() => {}} />,
102
+ );
103
+ expect((result.getTextArea() as HTMLTextAreaElement).getAttribute("maxlength")).toBe("140");
104
+ });
105
+
106
+ it("forwards placeholder and textAreaProps", () => {
107
+ const result = renderWithProviders(
108
+ <LongText
109
+ label="Notes"
110
+ value=""
111
+ placeholder="Say something"
112
+ textAreaProps={{ tabIndex: -1 }}
113
+ onChange={() => {}}
114
+ />,
115
+ );
116
+ const textarea = result.getTextArea() as HTMLTextAreaElement;
117
+ expect(textarea.getAttribute("placeholder")).toBe("Say something");
118
+ expect(textarea.getAttribute("tabindex")).toBe("-1");
119
+ });
120
+ });
121
+
122
+ describe("LongText row budget", () => {
123
+ it("defaults to six rows when no minHeight is given", () => {
124
+ const result = renderWithProviders(<LongText label="Notes" value="" onChange={() => {}} />);
125
+ expect(rowsOf(result.container)).toBe("6");
126
+ });
127
+
128
+ it("derives rows from a pixel minHeight at 24px a row", () => {
129
+ const result = renderWithProviders(
130
+ <LongText label="Notes" value="" minHeight={240} onChange={() => {}} />,
131
+ );
132
+ expect(rowsOf(result.container)).toBe("10");
133
+ });
134
+
135
+ it("rounds rather than truncates", () => {
136
+ const result = renderWithProviders(
137
+ <LongText label="Notes" value="" minHeight={250} onChange={() => {}} />,
138
+ );
139
+ expect(rowsOf(result.container)).toBe("10");
140
+ });
141
+
142
+ it("never goes below four rows, however small the minHeight", () => {
143
+ const result = renderWithProviders(
144
+ <LongText label="Notes" value="" minHeight={24} onChange={() => {}} />,
145
+ );
146
+ expect(rowsOf(result.container)).toBe("4");
147
+ });
148
+
149
+ it("treats a zero minHeight as unset, not as zero rows", () => {
150
+ const result = renderWithProviders(
151
+ <LongText label="Notes" value="" minHeight={0} onChange={() => {}} />,
152
+ );
153
+ expect(rowsOf(result.container)).toBe("6");
154
+ });
155
+
156
+ it("is taller than SmallText: LongText starts at six rows, SmallText at two", () => {
157
+ const long = renderWithProviders(<LongText label="Notes" value="" onChange={() => {}} />);
158
+ expect(Number(rowsOf(long.container))).toBeGreaterThan(2);
159
+ });
160
+
161
+ it("caps nothing on web: maxRows={16} leaves no rendered maximum height", () => {
162
+ // maxRows only reaches the DOM through the autoResize branch, and the
163
+ // adapter never turns autoResize on. Recorded so a future autoResize
164
+ // switch has to come past this assertion rather than silently changing
165
+ // how tall a Long Text can grow. Reported on MPO-123.
166
+ const result = renderWithProviders(
167
+ <LongText label="Notes" value="" minHeight={999} onChange={() => {}} />,
168
+ );
169
+ const cls = result.container.querySelector("textarea")?.getAttribute("class") ?? "";
170
+ expect(cls).not.toMatch(/(?:^|\s)_mah-/);
171
+ });
172
+ });
173
+
174
+ describe("registry resolution (Long Text / Text fieldtypes)", () => {
175
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
176
+ return { value: "", onChange: () => {}, ...overrides };
177
+ }
178
+
179
+ it("resolves both 'Long Text' and 'Text' to the same adapter", () => {
180
+ const longText = getFieldComponent("Long Text")!;
181
+ const text = getFieldComponent("Text")!;
182
+ expect(longText).toBeTruthy();
183
+ expect(text).toBe(longText);
184
+ });
185
+
186
+ it("renders LongText and forwards label / value", () => {
187
+ const Component = getFieldComponent("Long Text")!;
188
+ const element = (Component as Function)(
189
+ propsFor({ value: "body", label: "Description" }),
190
+ ) as React.ReactElement<Record<string, any>>;
191
+ expect(element.type).toBe(LongText);
192
+ expect(element.props.value).toBe("body");
193
+ expect(element.props.label).toBe("Description");
194
+ });
195
+
196
+ it("substitutes an empty string for a null value so the control stays controlled", () => {
197
+ const Component = getFieldComponent("Long Text")!;
198
+ const element = (Component as Function)(
199
+ propsFor({ value: null as unknown as string }),
200
+ ) as React.ReactElement<Record<string, any>>;
201
+ expect(element.props.value).toBe("");
202
+ });
203
+
204
+ it("a disabled field also blocks the DOM textarea (Tamagui disabled is aria-only)", () => {
205
+ const Component = getFieldComponent("Long Text")!;
206
+ const element = (Component as Function)(propsFor({ disabled: true })) as React.ReactElement<
207
+ Record<string, any>
208
+ >;
209
+ expect(element.props.textAreaProps).toEqual({ readOnly: true, tabIndex: -1 });
210
+ });
211
+
212
+ it("an enabled field adds no textAreaProps block", () => {
213
+ const Component = getFieldComponent("Long Text")!;
214
+ const element = (Component as Function)(propsFor()) as React.ReactElement<Record<string, any>>;
215
+ expect(element.props.textAreaProps).toBeUndefined();
216
+ });
217
+ });
@@ -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";