react-native-platform-components 1.2.0 → 1.4.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 (242) hide show
  1. package/README.md +33 -31
  2. package/android/src/main/java/com/platformcomponents/PCButtonGroupView.kt +498 -0
  3. package/android/src/main/java/com/platformcomponents/PCButtonGroupViewManager.kt +191 -0
  4. package/android/src/main/java/com/platformcomponents/PCButtonSupport.kt +127 -0
  5. package/android/src/main/java/com/platformcomponents/PCButtonView.kt +345 -0
  6. package/android/src/main/java/com/platformcomponents/PCButtonViewManager.kt +126 -0
  7. package/android/src/main/java/com/platformcomponents/PCExpressive.kt +107 -0
  8. package/android/src/main/java/com/platformcomponents/PCFloatingToolbarView.kt +115 -0
  9. package/android/src/main/java/com/platformcomponents/PCFloatingToolbarViewManager.kt +65 -0
  10. package/android/src/main/java/com/platformcomponents/PCNativeThemeModule.kt +3 -0
  11. package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +13 -5
  12. package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +6 -1
  13. package/android/src/main/java/com/platformcomponents/PCTextFieldView.kt +1018 -0
  14. package/android/src/main/java/com/platformcomponents/PCTextFieldViewManager.kt +248 -0
  15. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +13 -6
  16. package/android/src/main/jni/CMakeLists.txt +13 -1
  17. package/android/src/main/res/values/attrs.xml +13 -0
  18. package/android/src/main/res/values/styles.xml +183 -0
  19. package/ios/PCButton.h +10 -0
  20. package/ios/PCButton.mm +196 -0
  21. package/ios/PCButtonGroup.h +10 -0
  22. package/ios/PCButtonGroup.mm +256 -0
  23. package/ios/PCButtonGroupView.swift +254 -0
  24. package/ios/PCButtonSupport.swift +141 -0
  25. package/ios/PCButtonView.swift +155 -0
  26. package/ios/PCFloatingToolbar.h +10 -0
  27. package/ios/PCFloatingToolbar.mm +76 -0
  28. package/ios/PCFloatingToolbarView.swift +95 -0
  29. package/ios/PCNativeThemeModule.mm +13 -0
  30. package/ios/PCSelectionMenu.swift +9 -1
  31. package/ios/PCTextField.h +10 -0
  32. package/ios/PCTextField.mm +391 -0
  33. package/ios/PCTextFieldView.swift +1012 -0
  34. package/lib/commonjs/Button.js +77 -0
  35. package/lib/commonjs/Button.js.map +1 -0
  36. package/lib/commonjs/ButtonGroup.js +99 -0
  37. package/lib/commonjs/ButtonGroup.js.map +1 -0
  38. package/lib/commonjs/ButtonGroupNativeComponent.ts +121 -0
  39. package/lib/commonjs/ButtonNativeComponent.ts +76 -0
  40. package/lib/commonjs/ContextMenuNativeComponent.ts +5 -4
  41. package/lib/commonjs/DatePickerNativeComponent.ts +17 -11
  42. package/lib/commonjs/FloatingToolbar.js +69 -0
  43. package/lib/commonjs/FloatingToolbar.js.map +1 -0
  44. package/lib/commonjs/FloatingToolbarNativeComponent.ts +31 -0
  45. package/lib/commonjs/LiquidGlass.js +24 -4
  46. package/lib/commonjs/LiquidGlass.js.map +1 -1
  47. package/lib/commonjs/LiquidGlassNativeComponent.ts +6 -5
  48. package/lib/commonjs/NativePlatformComponentsTheme.js.map +1 -1
  49. package/lib/commonjs/SegmentedControl.js +11 -85
  50. package/lib/commonjs/SegmentedControl.js.map +1 -1
  51. package/lib/commonjs/SegmentedControlNativeComponent.ts +15 -11
  52. package/lib/commonjs/SelectionMenuNativeComponent.ts +6 -5
  53. package/lib/commonjs/TextField.js +195 -0
  54. package/lib/commonjs/TextField.js.map +1 -0
  55. package/lib/commonjs/TextFieldNativeComponent.ts +218 -0
  56. package/lib/commonjs/codegenTypes.js +6 -0
  57. package/lib/commonjs/codegenTypes.js.map +1 -0
  58. package/lib/commonjs/focusRegistry.js +78 -0
  59. package/lib/commonjs/focusRegistry.js.map +1 -0
  60. package/lib/commonjs/icons.js +92 -0
  61. package/lib/commonjs/icons.js.map +1 -0
  62. package/lib/commonjs/index.js +66 -0
  63. package/lib/commonjs/index.js.map +1 -1
  64. package/lib/commonjs/index.web.js +32 -2
  65. package/lib/commonjs/index.web.js.map +1 -1
  66. package/lib/commonjs/labelStyle.js +27 -0
  67. package/lib/commonjs/labelStyle.js.map +1 -0
  68. package/lib/commonjs/reactNativeInternals.d.js +2 -0
  69. package/lib/commonjs/reactNativeInternals.d.js.map +1 -0
  70. package/lib/module/Button.js +70 -0
  71. package/lib/module/Button.js.map +1 -0
  72. package/lib/module/ButtonGroup.js +92 -0
  73. package/lib/module/ButtonGroup.js.map +1 -0
  74. package/lib/module/ButtonGroupNativeComponent.ts +121 -0
  75. package/lib/module/ButtonNativeComponent.ts +76 -0
  76. package/lib/module/ContextMenuNativeComponent.ts +5 -4
  77. package/lib/module/DatePickerNativeComponent.ts +17 -11
  78. package/lib/module/FloatingToolbar.js +62 -0
  79. package/lib/module/FloatingToolbar.js.map +1 -0
  80. package/lib/module/FloatingToolbarNativeComponent.ts +31 -0
  81. package/lib/module/LiquidGlass.js +24 -4
  82. package/lib/module/LiquidGlass.js.map +1 -1
  83. package/lib/module/LiquidGlassNativeComponent.ts +6 -5
  84. package/lib/module/NativePlatformComponentsTheme.js.map +1 -1
  85. package/lib/module/SegmentedControl.js +12 -87
  86. package/lib/module/SegmentedControl.js.map +1 -1
  87. package/lib/module/SegmentedControlNativeComponent.ts +15 -11
  88. package/lib/module/SelectionMenuNativeComponent.ts +6 -5
  89. package/lib/module/TextField.js +189 -0
  90. package/lib/module/TextField.js.map +1 -0
  91. package/lib/module/TextFieldNativeComponent.ts +218 -0
  92. package/lib/module/codegenTypes.js +4 -0
  93. package/lib/module/codegenTypes.js.map +1 -0
  94. package/lib/module/focusRegistry.js +74 -0
  95. package/lib/module/focusRegistry.js.map +1 -0
  96. package/lib/module/icons.js +87 -0
  97. package/lib/module/icons.js.map +1 -0
  98. package/lib/module/index.js +6 -0
  99. package/lib/module/index.js.map +1 -1
  100. package/lib/module/index.web.js +28 -0
  101. package/lib/module/index.web.js.map +1 -1
  102. package/lib/module/labelStyle.js +23 -0
  103. package/lib/module/labelStyle.js.map +1 -0
  104. package/lib/module/reactNativeInternals.d.js +2 -0
  105. package/lib/module/reactNativeInternals.d.js.map +1 -0
  106. package/lib/typescript/commonjs/docs/sidebars.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/src/Button.d.ts +76 -0
  108. package/lib/typescript/commonjs/src/Button.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/src/ButtonGroup.d.ts +102 -0
  110. package/lib/typescript/commonjs/src/ButtonGroup.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts +93 -0
  112. package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts +57 -0
  114. package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts +5 -4
  116. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts +12 -11
  118. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/src/FloatingToolbar.d.ts +45 -0
  120. package/lib/typescript/commonjs/src/FloatingToolbar.d.ts.map +1 -0
  121. package/lib/typescript/commonjs/src/FloatingToolbarNativeComponent.d.ts +24 -0
  122. package/lib/typescript/commonjs/src/FloatingToolbarNativeComponent.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/src/LiquidGlass.d.ts +4 -3
  124. package/lib/typescript/commonjs/src/LiquidGlass.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/src/LiquidGlassNativeComponent.d.ts +6 -5
  126. package/lib/typescript/commonjs/src/LiquidGlassNativeComponent.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/src/NativePlatformComponentsTheme.d.ts +10 -2
  128. package/lib/typescript/commonjs/src/NativePlatformComponentsTheme.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/src/SegmentedControl.d.ts +17 -46
  130. package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +9 -6
  132. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts +6 -5
  134. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -1
  135. package/lib/typescript/commonjs/src/TextField.d.ts +174 -0
  136. package/lib/typescript/commonjs/src/TextField.d.ts.map +1 -0
  137. package/lib/typescript/commonjs/src/TextFieldNativeComponent.d.ts +162 -0
  138. package/lib/typescript/commonjs/src/TextFieldNativeComponent.d.ts.map +1 -0
  139. package/lib/typescript/commonjs/src/codegenTypes.d.ts +12 -0
  140. package/lib/typescript/commonjs/src/codegenTypes.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/src/focusRegistry.d.ts +13 -0
  142. package/lib/typescript/commonjs/src/focusRegistry.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/src/icons.d.ts +52 -0
  144. package/lib/typescript/commonjs/src/icons.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/src/index.d.ts +6 -0
  146. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  147. package/lib/typescript/commonjs/src/index.web.d.ts +121 -0
  148. package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/src/labelStyle.d.ts +20 -0
  150. package/lib/typescript/commonjs/src/labelStyle.d.ts.map +1 -0
  151. package/lib/typescript/commonjs/src/sharedTypes.d.ts +8 -3
  152. package/lib/typescript/commonjs/src/sharedTypes.d.ts.map +1 -1
  153. package/lib/typescript/module/docs/sidebars.d.ts.map +1 -1
  154. package/lib/typescript/module/src/Button.d.ts +76 -0
  155. package/lib/typescript/module/src/Button.d.ts.map +1 -0
  156. package/lib/typescript/module/src/ButtonGroup.d.ts +102 -0
  157. package/lib/typescript/module/src/ButtonGroup.d.ts.map +1 -0
  158. package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts +93 -0
  159. package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts.map +1 -0
  160. package/lib/typescript/module/src/ButtonNativeComponent.d.ts +57 -0
  161. package/lib/typescript/module/src/ButtonNativeComponent.d.ts.map +1 -0
  162. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +5 -4
  163. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -1
  164. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +12 -11
  165. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -1
  166. package/lib/typescript/module/src/FloatingToolbar.d.ts +45 -0
  167. package/lib/typescript/module/src/FloatingToolbar.d.ts.map +1 -0
  168. package/lib/typescript/module/src/FloatingToolbarNativeComponent.d.ts +24 -0
  169. package/lib/typescript/module/src/FloatingToolbarNativeComponent.d.ts.map +1 -0
  170. package/lib/typescript/module/src/LiquidGlass.d.ts +4 -3
  171. package/lib/typescript/module/src/LiquidGlass.d.ts.map +1 -1
  172. package/lib/typescript/module/src/LiquidGlassNativeComponent.d.ts +6 -5
  173. package/lib/typescript/module/src/LiquidGlassNativeComponent.d.ts.map +1 -1
  174. package/lib/typescript/module/src/NativePlatformComponentsTheme.d.ts +10 -2
  175. package/lib/typescript/module/src/NativePlatformComponentsTheme.d.ts.map +1 -1
  176. package/lib/typescript/module/src/SegmentedControl.d.ts +17 -46
  177. package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -1
  178. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +9 -6
  179. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -1
  180. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +6 -5
  181. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -1
  182. package/lib/typescript/module/src/TextField.d.ts +174 -0
  183. package/lib/typescript/module/src/TextField.d.ts.map +1 -0
  184. package/lib/typescript/module/src/TextFieldNativeComponent.d.ts +162 -0
  185. package/lib/typescript/module/src/TextFieldNativeComponent.d.ts.map +1 -0
  186. package/lib/typescript/module/src/codegenTypes.d.ts +12 -0
  187. package/lib/typescript/module/src/codegenTypes.d.ts.map +1 -0
  188. package/lib/typescript/module/src/focusRegistry.d.ts +13 -0
  189. package/lib/typescript/module/src/focusRegistry.d.ts.map +1 -0
  190. package/lib/typescript/module/src/icons.d.ts +52 -0
  191. package/lib/typescript/module/src/icons.d.ts.map +1 -0
  192. package/lib/typescript/module/src/index.d.ts +6 -0
  193. package/lib/typescript/module/src/index.d.ts.map +1 -1
  194. package/lib/typescript/module/src/index.web.d.ts +121 -0
  195. package/lib/typescript/module/src/index.web.d.ts.map +1 -1
  196. package/lib/typescript/module/src/labelStyle.d.ts +20 -0
  197. package/lib/typescript/module/src/labelStyle.d.ts.map +1 -0
  198. package/lib/typescript/module/src/sharedTypes.d.ts +8 -3
  199. package/lib/typescript/module/src/sharedTypes.d.ts.map +1 -1
  200. package/package.json +28 -6
  201. package/react-native.config.js +3 -0
  202. package/shared/PCButtonComponentDescriptors-custom.h +22 -0
  203. package/shared/PCButtonGroupComponentDescriptors-custom.h +22 -0
  204. package/shared/PCButtonGroupShadowNode-custom.cpp +48 -0
  205. package/shared/PCButtonGroupShadowNode-custom.h +55 -0
  206. package/shared/PCButtonGroupState-custom.h +61 -0
  207. package/shared/PCButtonShadowNode-custom.cpp +48 -0
  208. package/shared/PCButtonShadowNode-custom.h +55 -0
  209. package/shared/PCButtonState-custom.h +61 -0
  210. package/shared/PCDatePickerState-custom.h +2 -2
  211. package/shared/PCSegmentedControlState-custom.h +2 -2
  212. package/shared/PCSelectionMenuState-custom.h +2 -2
  213. package/shared/PCTextFieldComponentDescriptors-custom.h +22 -0
  214. package/shared/PCTextFieldShadowNode-custom.cpp +47 -0
  215. package/shared/PCTextFieldShadowNode-custom.h +55 -0
  216. package/shared/PCTextFieldState-custom.h +61 -0
  217. package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +3 -0
  218. package/src/Button.tsx +144 -0
  219. package/src/ButtonGroup.tsx +233 -0
  220. package/src/ButtonGroupNativeComponent.ts +121 -0
  221. package/src/ButtonNativeComponent.ts +76 -0
  222. package/src/ContextMenuNativeComponent.ts +5 -4
  223. package/src/DatePickerNativeComponent.ts +17 -11
  224. package/src/FloatingToolbar.tsx +113 -0
  225. package/src/FloatingToolbarNativeComponent.ts +31 -0
  226. package/src/LiquidGlass.tsx +24 -5
  227. package/src/LiquidGlassNativeComponent.ts +6 -5
  228. package/src/NativePlatformComponentsTheme.ts +11 -2
  229. package/src/SegmentedControl.tsx +26 -118
  230. package/src/SegmentedControlNativeComponent.ts +15 -11
  231. package/src/SelectionMenuNativeComponent.ts +6 -5
  232. package/src/TextField.tsx +494 -0
  233. package/src/TextFieldNativeComponent.ts +218 -0
  234. package/src/codegenTypes.ts +40 -0
  235. package/src/focusRegistry.ts +89 -0
  236. package/src/icons.ts +116 -0
  237. package/src/index.tsx +6 -0
  238. package/src/index.web.tsx +194 -0
  239. package/src/labelStyle.ts +36 -0
  240. package/src/reactNativeInternals.d.ts +22 -0
  241. package/src/sharedTypes.ts +9 -3
  242. /package/{app.plugin.cjs → app.plugin.js} +0 -0
@@ -0,0 +1,1012 @@
1
+ import UIKit
2
+
3
+ /// A native text field: a caption label, a `UITextField` (or a growing
4
+ /// `UITextView` for multi-line text) and supporting text below, with the
5
+ /// text traits iOS adds each year exposed as props.
6
+ ///
7
+ /// The text and its edit counter live here. User edits report the counter
8
+ /// with the text; JS pushes a controlled value back with the last counter it
9
+ /// saw, and a push older than the latest edit is dropped so a slow JS
10
+ /// round-trip can't erase what was typed meanwhile.
11
+ @objcMembers
12
+ public final class PCTextFieldView: UIView, UITextFieldDelegate, UITextViewDelegate {
13
+ // MARK: - Props (set from ObjC++)
14
+
15
+ public var label: String = "" {
16
+ didSet { if oldValue != label { applyLabel() } }
17
+ }
18
+
19
+ public var placeholder: String = "" {
20
+ didSet { if oldValue != placeholder { applyPlaceholder() } }
21
+ }
22
+
23
+ public var supportingText: String = "" {
24
+ didSet { if oldValue != supportingText { applyFooter() } }
25
+ }
26
+
27
+ /// "none" | "error"
28
+ public var errorState: String = "none" {
29
+ didSet { if oldValue != errorState { applyFooter(); applyColors() } }
30
+ }
31
+
32
+ public var errorText: String = "" {
33
+ didSet { if oldValue != errorText { applyFooter() } }
34
+ }
35
+
36
+ public var prefix: String = "" {
37
+ didSet { if oldValue != prefix { applyAccessories() } }
38
+ }
39
+
40
+ public var suffix: String = "" {
41
+ didSet { if oldValue != suffix { applyAccessories() } }
42
+ }
43
+
44
+ /// "never" | "while-editing" | "unless-editing" | "always"
45
+ public var clearButtonMode: String = "never" {
46
+ didSet { if oldValue != clearButtonMode { applyAccessories() } }
47
+ }
48
+
49
+ public var passwordToggle: Bool = false {
50
+ didSet { if oldValue != passwordToggle { applyAccessories() } }
51
+ }
52
+
53
+ public var characterCount: Bool = false {
54
+ didSet { if oldValue != characterCount { applyFooter() } }
55
+ }
56
+
57
+ /// 0 = unlimited
58
+ public var maxLength: Int = 0 {
59
+ didSet { if oldValue != maxLength { applyFooter() } }
60
+ }
61
+
62
+ public var keyboardType: String = "default" {
63
+ didSet { if oldValue != keyboardType { applyTraits() } }
64
+ }
65
+
66
+ public var returnKeyType: String = "default" {
67
+ didSet { if oldValue != returnKeyType { applyTraits() } }
68
+ }
69
+
70
+ public var autoCapitalize: String = "sentences" {
71
+ didSet { if oldValue != autoCapitalize { applyTraits() } }
72
+ }
73
+
74
+ public var autoCorrect: Bool = true {
75
+ didSet { if oldValue != autoCorrect { applyTraits() } }
76
+ }
77
+
78
+ public var secureTextEntry: Bool = false {
79
+ didSet { if oldValue != secureTextEntry { revealed = false; applyTraits(); applyAccessories() } }
80
+ }
81
+
82
+ public var multiline: Bool = false {
83
+ didSet { if oldValue != multiline { rebuildInput() } }
84
+ }
85
+
86
+ /// "enabled" | "disabled"
87
+ public var interactivity: String = "enabled" {
88
+ didSet { if oldValue != interactivity { applyEnabled() } }
89
+ }
90
+
91
+ public var autoFocus: Bool = false {
92
+ didSet { if autoFocus, window != nil { performAutoFocus() } }
93
+ }
94
+
95
+ public var selectTextOnFocus: Bool = false
96
+
97
+ /// React Native autoComplete value; "" = default
98
+ public var autoComplete: String = "" {
99
+ didSet { if oldValue != autoComplete { applyTraits() } }
100
+ }
101
+
102
+ /// "default" | "light" | "dark"
103
+ public var keyboardAppearance: String = "default" {
104
+ didSet { if oldValue != keyboardAppearance { applyTraits() } }
105
+ }
106
+
107
+ /// Input font; nil keeps the body text style
108
+ public var textFont: UIFont? {
109
+ didSet { applyFonts() }
110
+ }
111
+
112
+ /// Screen-reader label; empty uses the label
113
+ public var spokenLabel: String = "" {
114
+ didSet { applyAccessibility() }
115
+ }
116
+
117
+ // iOS text traits, "" for the system default
118
+ public var writingTools: String = "" {
119
+ didSet { if oldValue != writingTools { applyTraits() } }
120
+ }
121
+
122
+ public var inlinePrediction: String = "" {
123
+ didSet { if oldValue != inlinePrediction { applyTraits() } }
124
+ }
125
+
126
+ public var smartQuotes: String = "" {
127
+ didSet { if oldValue != smartQuotes { applyTraits() } }
128
+ }
129
+
130
+ public var smartDashes: String = "" {
131
+ didSet { if oldValue != smartDashes { applyTraits() } }
132
+ }
133
+
134
+ public var smartInsertDelete: String = "" {
135
+ didSet { if oldValue != smartInsertDelete { applyTraits() } }
136
+ }
137
+
138
+ public var mathExpressionCompletion: String = "" {
139
+ didSet { if oldValue != mathExpressionCompletion { applyTraits() } }
140
+ }
141
+
142
+ /// "" | "roundedRect" | "none" | "line" | "bezel"
143
+ public var borderStyle: String = "" {
144
+ didSet { if oldValue != borderStyle { applyBorderStyle() } }
145
+ }
146
+
147
+ /// "" | "above" | "leading": the caption above the field, or in a column
148
+ /// beside it, the layout of the grouped forms in Contacts and Settings
149
+ public var labelPlacement: String = "" {
150
+ didSet { if oldValue != labelPlacement { applyLabel(); applyFonts(); applyColors() } }
151
+ }
152
+
153
+ /// Width of the leading label column; 0 = default
154
+ public var labelWidth: CGFloat = 0 {
155
+ didSet { if oldValue != labelWidth { setNeedsLayout(); onNeedsRemeasure?() } }
156
+ }
157
+
158
+ private var leadingLabel: Bool { labelPlacement == "leading" && !label.isEmpty }
159
+ private var leadingColumnWidth: CGFloat { labelWidth > 0 ? labelWidth : 100 }
160
+ private let leadingSpacing: CGFloat = 8
161
+
162
+ // MARK: - Events back to ObjC++
163
+
164
+ public var onChange: ((String, Int) -> Void)?
165
+ public var onFocusChange: ((Bool, String) -> Void)?
166
+ public var onSubmit: ((String) -> Void)?
167
+ public var onTrailingIconPress: (() -> Void)?
168
+
169
+ /// Called when content changed in a way that affects the height (a
170
+ /// multi-line edit, an error message appearing), so the Fabric
171
+ /// measurement can be refreshed.
172
+ public var onNeedsRemeasure: (() -> Void)?
173
+
174
+ // MARK: - Text state
175
+
176
+ public private(set) var text: String = ""
177
+ private var nativeEventCount = 0
178
+ private var initialTextApplied = false
179
+ private var autoFocusDone = false
180
+
181
+ /// Whether a secure field is currently showing its text (password toggle)
182
+ private var revealed = false
183
+
184
+ // MARK: - Subviews
185
+
186
+ private let captionLabel = UILabel()
187
+ private let textField = UITextField()
188
+ private let textView = PCPlaceholderTextView()
189
+ private let footerLabel = UILabel()
190
+ private let counterLabel = UILabel()
191
+
192
+ private var leadingIcon = PCButtonSupport.Icon.none
193
+ private var trailingIcon = PCButtonSupport.Icon.none
194
+ private var leadingImage: UIImage?
195
+ private var trailingImage: UIImage?
196
+ private var iconGeneration = 0
197
+
198
+ // MARK: - Layout metrics
199
+
200
+ private let captionSpacing: CGFloat = 6
201
+ private let footerSpacing: CGFloat = 4
202
+ private let singleLineMinHeight: CGFloat = 34
203
+ private let textViewInsets = UIEdgeInsets(top: 7, left: 4, bottom: 7, right: 4)
204
+
205
+ // MARK: - Init
206
+
207
+ public override init(frame: CGRect) {
208
+ super.init(frame: frame)
209
+ setup()
210
+ }
211
+
212
+ public required init?(coder: NSCoder) {
213
+ super.init(coder: coder)
214
+ setup()
215
+ }
216
+
217
+ private func setup() {
218
+ captionLabel.numberOfLines = 1
219
+ captionLabel.adjustsFontForContentSizeCategory = true
220
+
221
+ footerLabel.numberOfLines = 0
222
+ footerLabel.adjustsFontForContentSizeCategory = true
223
+
224
+ counterLabel.numberOfLines = 1
225
+ counterLabel.adjustsFontForContentSizeCategory = true
226
+ counterLabel.textAlignment = .right
227
+ counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
228
+
229
+ textField.delegate = self
230
+ textField.borderStyle = .roundedRect
231
+ textField.adjustsFontForContentSizeCategory = true
232
+ textField.addTarget(self, action: #selector(fieldEditingChanged), for: .editingChanged)
233
+
234
+ textView.delegate = self
235
+ textView.isScrollEnabled = false
236
+ textView.adjustsFontForContentSizeCategory = true
237
+ textView.textContainerInset = textViewInsets
238
+ textView.textContainer.lineFragmentPadding = 4
239
+ textView.backgroundColor = .clear
240
+
241
+ addSubview(captionLabel)
242
+ addSubview(textField)
243
+ addSubview(footerLabel)
244
+ addSubview(counterLabel)
245
+
246
+ applyFonts()
247
+ applyColors()
248
+ applyBorderStyle()
249
+ applyTraits()
250
+ applyLabel()
251
+ applyPlaceholder()
252
+ applyFooter()
253
+ applyAccessories()
254
+ applyAccessibility()
255
+ }
256
+
257
+ // MARK: - Text
258
+
259
+ /// The text the field starts with; only the first value is used (see setText).
260
+ public func applyInitialText(_ value: String) {
261
+ guard !initialTextApplied else { return }
262
+ initialTextApplied = true
263
+ setTextInternal(value, moveCursorToEnd: true)
264
+ }
265
+
266
+ /// A controlled value from JS. Dropped when the user has edited since
267
+ /// `eventCount` was reported: the edit event that follows carries the
268
+ /// text JS should reconcile against.
269
+ public func setText(_ value: String, eventCount: Int) {
270
+ guard eventCount >= nativeEventCount, value != text else { return }
271
+ // Replacing text mid-composition (Japanese, Chinese keyboards) would
272
+ // break the composition; the next edit reconciles instead
273
+ guard activeInput.markedTextRange == nil else { return }
274
+ setTextInternal(value, moveCursorToEnd: false)
275
+ }
276
+
277
+ /// Clears the text as if the user had, so JS gets a change event.
278
+ public func clear() {
279
+ guard !text.isEmpty else { return }
280
+ setTextInternal("", moveCursorToEnd: true)
281
+ reportEdit()
282
+ }
283
+
284
+ public func focus() {
285
+ activeInput.becomeFirstResponder()
286
+ }
287
+
288
+ public func blur() {
289
+ activeInput.resignFirstResponder()
290
+ }
291
+
292
+ public func setLeadingIcon(type: String, name: String, uri: String, scale: CGFloat, tinted: Bool) {
293
+ let next = PCButtonSupport.Icon(type: type, name: name, uri: uri, scale: scale, tinted: tinted)
294
+ guard next != leadingIcon else { return }
295
+ leadingIcon = next
296
+ iconGeneration += 1
297
+ let generation = iconGeneration
298
+ leadingImage = PCButtonSupport.image(for: next) { [weak self] image in
299
+ guard let self, self.iconGeneration == generation else { return }
300
+ self.leadingImage = image
301
+ self.applyAccessories()
302
+ }
303
+ applyAccessories()
304
+ }
305
+
306
+ public func setTrailingIcon(type: String, name: String, uri: String, scale: CGFloat, tinted: Bool) {
307
+ let next = PCButtonSupport.Icon(type: type, name: name, uri: uri, scale: scale, tinted: tinted)
308
+ guard next != trailingIcon else { return }
309
+ trailingIcon = next
310
+ iconGeneration += 1
311
+ let generation = iconGeneration
312
+ trailingImage = PCButtonSupport.image(for: next) { [weak self] image in
313
+ guard let self, self.iconGeneration == generation else { return }
314
+ self.trailingImage = image
315
+ self.applyAccessories()
316
+ }
317
+ applyAccessories()
318
+ }
319
+
320
+ /// Resets the text state for a recycled view.
321
+ public func resetForRecycle() {
322
+ blur()
323
+ initialTextApplied = false
324
+ autoFocusDone = false
325
+ nativeEventCount = 0
326
+ revealed = false
327
+ setTextInternal("", moveCursorToEnd: true)
328
+ }
329
+
330
+ private var activeInput: UIView & UITextInput {
331
+ multiline ? textView : textField
332
+ }
333
+
334
+ private func setTextInternal(_ value: String, moveCursorToEnd: Bool) {
335
+ if multiline {
336
+ let wasAtEnd = textView.selectedRange.location == (textView.text as NSString).length
337
+ let cursor = textView.selectedRange.location
338
+ textView.text = value
339
+ let length = (value as NSString).length
340
+ textView.selectedRange = NSRange(location: moveCursorToEnd || wasAtEnd ? length : min(cursor, length), length: 0)
341
+ textView.updatePlaceholderVisibility()
342
+ } else {
343
+ let wasAtEnd: Bool
344
+ if let selected = textField.selectedTextRange {
345
+ wasAtEnd = textField.compare(selected.end, to: textField.endOfDocument) == .orderedSame
346
+ } else {
347
+ wasAtEnd = true
348
+ }
349
+ let cursorOffset = textField.selectedTextRange.map {
350
+ textField.offset(from: textField.beginningOfDocument, to: $0.start)
351
+ } ?? 0
352
+ textField.text = value
353
+ let length = (value as NSString).length
354
+ let offset = moveCursorToEnd || wasAtEnd ? length : min(cursorOffset, length)
355
+ if let position = textField.position(from: textField.beginningOfDocument, offset: offset) {
356
+ textField.selectedTextRange = textField.textRange(from: position, to: position)
357
+ }
358
+ }
359
+ text = value
360
+ applyFooter()
361
+ onNeedsRemeasure?()
362
+ }
363
+
364
+ private func reportEdit() {
365
+ nativeEventCount += 1
366
+ onChange?(text, nativeEventCount)
367
+ }
368
+
369
+ // MARK: - UITextField
370
+
371
+ @objc private func fieldEditingChanged() {
372
+ text = textField.text ?? ""
373
+ applyFooter()
374
+ reportEdit()
375
+ }
376
+
377
+ public func textFieldDidBeginEditing(_ field: UITextField) {
378
+ if selectTextOnFocus {
379
+ field.selectAll(nil)
380
+ }
381
+ applyColors()
382
+ onFocusChange?(true, text)
383
+ }
384
+
385
+ public func textFieldDidEndEditing(_ field: UITextField) {
386
+ applyColors()
387
+ onFocusChange?(false, text)
388
+ }
389
+
390
+ public func textFieldShouldReturn(_ field: UITextField) -> Bool {
391
+ onSubmit?(text)
392
+ // Single-line fields blur on submit, as the core TextInput does by default
393
+ field.resignFirstResponder()
394
+ return true
395
+ }
396
+
397
+ public func textField(_ field: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
398
+ allowsChange(current: field.text ?? "", range: range, replacement: string, input: field)
399
+ }
400
+
401
+ // MARK: - UITextView
402
+
403
+ public func textViewDidChange(_ view: UITextView) {
404
+ text = view.text ?? ""
405
+ textView.updatePlaceholderVisibility()
406
+ applyFooter()
407
+ reportEdit()
408
+ onNeedsRemeasure?()
409
+ }
410
+
411
+ public func textViewDidBeginEditing(_ view: UITextView) {
412
+ if selectTextOnFocus {
413
+ view.selectAll(nil)
414
+ }
415
+ applyColors()
416
+ onFocusChange?(true, text)
417
+ }
418
+
419
+ public func textViewDidEndEditing(_ view: UITextView) {
420
+ applyColors()
421
+ onFocusChange?(false, text)
422
+ }
423
+
424
+ public func textView(_ view: UITextView, shouldChangeTextIn range: NSRange, replacementText string: String) -> Bool {
425
+ allowsChange(current: view.text ?? "", range: range, replacement: string, input: view)
426
+ }
427
+
428
+ /// Enforces `maxLength` in UTF-16 units, like the core TextInput and the
429
+ /// Android length filter, leaving IME composition alone.
430
+ private func allowsChange(current: String, range: NSRange, replacement: String, input: UITextInput) -> Bool {
431
+ guard maxLength > 0, input.markedTextRange == nil else { return true }
432
+ let currentLength = (current as NSString).length
433
+ let nextLength = currentLength - range.length + (replacement as NSString).length
434
+ return nextLength <= maxLength
435
+ }
436
+
437
+ // MARK: - Props handling
438
+
439
+ private func rebuildInput() {
440
+ let focused = activeInput.isFirstResponder
441
+ textField.removeFromSuperview()
442
+ textView.removeFromSuperview()
443
+ if multiline {
444
+ textView.text = text
445
+ textView.updatePlaceholderVisibility()
446
+ insertSubview(textView, at: 1)
447
+ } else {
448
+ textField.text = text
449
+ insertSubview(textField, at: 1)
450
+ }
451
+ applyFonts()
452
+ applyColors()
453
+ applyBorderStyle()
454
+ applyTraits()
455
+ applyPlaceholder()
456
+ applyAccessories()
457
+ applyEnabled()
458
+ applyAccessibility()
459
+ if focused { activeInput.becomeFirstResponder() }
460
+ setNeedsLayout()
461
+ onNeedsRemeasure?()
462
+ }
463
+
464
+ private func applyLabel() {
465
+ captionLabel.text = label
466
+ captionLabel.isHidden = label.isEmpty
467
+ applyAccessibility()
468
+ setNeedsLayout()
469
+ onNeedsRemeasure?()
470
+ }
471
+
472
+ private func applyPlaceholder() {
473
+ textField.placeholder = placeholder.isEmpty ? nil : placeholder
474
+ textView.placeholder = placeholder
475
+ }
476
+
477
+ /// Supporting text, error message and counter below the field.
478
+ private func applyFooter() {
479
+ let showsError = errorState == "error"
480
+ let message = showsError && !errorText.isEmpty ? errorText : supportingText
481
+ footerLabel.text = message
482
+ footerLabel.isHidden = message.isEmpty
483
+
484
+ if characterCount {
485
+ let count = (text as NSString).length
486
+ counterLabel.text = maxLength > 0 ? "\(count) / \(maxLength)" : "\(count)"
487
+ counterLabel.isHidden = false
488
+ } else {
489
+ counterLabel.text = nil
490
+ counterLabel.isHidden = true
491
+ }
492
+ applyColors()
493
+ setNeedsLayout()
494
+ onNeedsRemeasure?()
495
+ }
496
+
497
+ private func applyFonts() {
498
+ captionLabel.font = .preferredFont(forTextStyle: leadingLabel ? .body : .subheadline)
499
+ footerLabel.font = .preferredFont(forTextStyle: .footnote)
500
+ counterLabel.font = .preferredFont(forTextStyle: .footnote)
501
+ let inputFont = textFont ?? .preferredFont(forTextStyle: .body)
502
+ textField.font = inputFont
503
+ textView.font = inputFont
504
+ textView.placeholderLabel.font = inputFont
505
+ setNeedsLayout()
506
+ onNeedsRemeasure?()
507
+ }
508
+
509
+ private func applyColors() {
510
+ let showsError = errorState == "error"
511
+ let focused = activeInput.isFirstResponder
512
+ captionLabel.textColor = showsError
513
+ ? .systemRed
514
+ : (leadingLabel ? .label : (focused ? tintColor : .secondaryLabel))
515
+ footerLabel.textColor = showsError ? .systemRed : .secondaryLabel
516
+ counterLabel.textColor = showsError && maxLength > 0 && (text as NSString).length > maxLength
517
+ ? .systemRed
518
+ : .secondaryLabel
519
+ textView.layer.borderColor = (showsError ? UIColor.systemRed : UIColor.systemGray4).cgColor
520
+ }
521
+
522
+ private func applyBorderStyle() {
523
+ let style: UITextField.BorderStyle
524
+ switch borderStyle {
525
+ case "none": style = .none
526
+ case "line": style = .line
527
+ case "bezel": style = .bezel
528
+ default: style = .roundedRect
529
+ }
530
+ textField.borderStyle = style
531
+ // The text view mirrors the rounded-rect field
532
+ let bordered = style != .none
533
+ textView.layer.borderWidth = bordered ? 1 : 0
534
+ textView.layer.cornerRadius = style == .roundedRect ? 5 : 0
535
+ textView.backgroundColor = bordered ? .systemBackground : .clear
536
+ setNeedsLayout()
537
+ onNeedsRemeasure?()
538
+ }
539
+
540
+ private func applyEnabled() {
541
+ let enabled = interactivity != "disabled"
542
+ textField.isEnabled = enabled
543
+ textView.isEditable = enabled
544
+ textView.isUserInteractionEnabled = enabled
545
+ textView.alpha = enabled ? 1 : 0.5
546
+ }
547
+
548
+ private func applyAccessibility() {
549
+ let spoken = spokenLabel.isEmpty ? (label.isEmpty ? nil : label) : spokenLabel
550
+ textField.accessibilityLabel = spoken
551
+ textView.accessibilityLabel = spoken
552
+ }
553
+
554
+ private func applyTraits() {
555
+ applyTraits(to: textField)
556
+ applyTraits(to: textView)
557
+ // The keyboard only picks the traits up when input restarts
558
+ if activeInput.isFirstResponder {
559
+ activeInput.reloadInputViews()
560
+ }
561
+ }
562
+
563
+ /// Both inputs get the traits: UITextField and UITextView declare the
564
+ /// same UITextInputTraits properties, but as their own members.
565
+ private func applyTraits(to input: UITextField) {
566
+ input.keyboardType = PCTextFieldView.keyboardType(keyboardType)
567
+ input.returnKeyType = PCTextFieldView.returnKeyType(returnKeyType)
568
+ input.autocapitalizationType = PCTextFieldView.autocapitalization(autoCapitalize)
569
+ input.autocorrectionType = autoCorrect ? .default : .no
570
+ input.spellCheckingType = autoCorrect ? .default : .no
571
+ input.isSecureTextEntry = secureTextEntry && !revealed
572
+ input.textContentType = PCTextFieldView.contentType(autoComplete)
573
+ input.keyboardAppearance = PCTextFieldView.keyboardAppearance(keyboardAppearance)
574
+ input.smartQuotesType = PCTextFieldView.smartQuotesType(smartQuotes)
575
+ input.smartDashesType = PCTextFieldView.smartDashesType(smartDashes)
576
+ input.smartInsertDeleteType = PCTextFieldView.smartInsertDeleteType(smartInsertDelete)
577
+ if #available(iOS 17.0, *) {
578
+ input.inlinePredictionType = PCTextFieldView.inlinePredictionType(inlinePrediction)
579
+ }
580
+ if #available(iOS 18.0, *) {
581
+ input.mathExpressionCompletionType = PCTextFieldView.mathCompletionType(mathExpressionCompletion)
582
+ input.writingToolsBehavior = PCTextFieldView.writingToolsBehavior(writingTools)
583
+ }
584
+ }
585
+
586
+ private func applyTraits(to input: UITextView) {
587
+ input.keyboardType = PCTextFieldView.keyboardType(keyboardType)
588
+ input.returnKeyType = PCTextFieldView.returnKeyType(returnKeyType)
589
+ input.autocapitalizationType = PCTextFieldView.autocapitalization(autoCapitalize)
590
+ input.autocorrectionType = autoCorrect ? .default : .no
591
+ input.spellCheckingType = autoCorrect ? .default : .no
592
+ input.isSecureTextEntry = secureTextEntry && !revealed
593
+ input.textContentType = PCTextFieldView.contentType(autoComplete)
594
+ input.keyboardAppearance = PCTextFieldView.keyboardAppearance(keyboardAppearance)
595
+ input.smartQuotesType = PCTextFieldView.smartQuotesType(smartQuotes)
596
+ input.smartDashesType = PCTextFieldView.smartDashesType(smartDashes)
597
+ input.smartInsertDeleteType = PCTextFieldView.smartInsertDeleteType(smartInsertDelete)
598
+ if #available(iOS 17.0, *) {
599
+ input.inlinePredictionType = PCTextFieldView.inlinePredictionType(inlinePrediction)
600
+ }
601
+ if #available(iOS 18.0, *) {
602
+ input.mathExpressionCompletionType = PCTextFieldView.mathCompletionType(mathExpressionCompletion)
603
+ input.writingToolsBehavior = PCTextFieldView.writingToolsBehavior(writingTools)
604
+ }
605
+ }
606
+
607
+ /// Prefix and suffix labels, icons, the clear button and the password
608
+ /// toggle. Single-line only: a text view has no accessory views.
609
+ private func applyAccessories() {
610
+ textField.leftView = nil
611
+ textField.rightView = nil
612
+ textField.leftViewMode = .never
613
+ textField.rightViewMode = .never
614
+
615
+ if let leading = accessoryView(image: leadingImage, tinted: leadingIcon.tinted, text: prefix, action: nil) {
616
+ textField.leftView = leading
617
+ textField.leftViewMode = .always
618
+ }
619
+
620
+ let trailingAction: (() -> Void)?
621
+ let trailingImageForSlot: UIImage?
622
+ if trailingImage != nil {
623
+ trailingImageForSlot = trailingImage
624
+ trailingAction = { [weak self] in self?.onTrailingIconPress?() }
625
+ } else if passwordToggle, secureTextEntry || revealed {
626
+ trailingImageForSlot = UIImage(systemName: revealed ? "eye.slash" : "eye")
627
+ trailingAction = { [weak self] in self?.toggleReveal() }
628
+ } else {
629
+ trailingImageForSlot = nil
630
+ trailingAction = nil
631
+ }
632
+ let trailingTinted = trailingImage != nil ? trailingIcon.tinted : true
633
+ if let trailing = accessoryView(image: trailingImageForSlot, tinted: trailingTinted, text: suffix, action: trailingAction) {
634
+ textField.rightView = trailing
635
+ textField.rightViewMode = .always
636
+ }
637
+
638
+ // A right view takes the clear button's place, as UIKit draws them in the same slot
639
+ switch clearButtonMode {
640
+ case "while-editing": textField.clearButtonMode = .whileEditing
641
+ case "unless-editing": textField.clearButtonMode = .unlessEditing
642
+ case "always": textField.clearButtonMode = .always
643
+ default: textField.clearButtonMode = .never
644
+ }
645
+ setNeedsLayout()
646
+ }
647
+
648
+ /// A label and/or an image button for the field's left or right slot.
649
+ private func accessoryView(image: UIImage?, tinted: Bool, text: String, action: (() -> Void)?) -> UIView? {
650
+ guard image != nil || !text.isEmpty else { return nil }
651
+ let stack = UIStackView()
652
+ stack.axis = .horizontal
653
+ stack.alignment = .center
654
+ stack.spacing = 6
655
+ stack.isLayoutMarginsRelativeArrangement = true
656
+ stack.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
657
+
658
+ if let image {
659
+ let config = UIImage.SymbolConfiguration(textStyle: .body)
660
+ if let action {
661
+ let button = PCAccessoryButton(type: .system)
662
+ button.setImage(image.applyingSymbolConfiguration(config) ?? image, for: .normal)
663
+ button.tintColor = tinted ? .secondaryLabel : nil
664
+ button.action = action
665
+ button.accessibilityLabel = passwordToggle && trailingImage == nil
666
+ ? (revealed ? "Hide password" : "Show password")
667
+ : nil
668
+ stack.addArrangedSubview(button)
669
+ } else {
670
+ let imageView = UIImageView(image: image.applyingSymbolConfiguration(config) ?? image)
671
+ imageView.tintColor = tinted ? .secondaryLabel : nil
672
+ imageView.contentMode = .scaleAspectFit
673
+ stack.addArrangedSubview(imageView)
674
+ }
675
+ }
676
+ if !text.isEmpty {
677
+ let label = UILabel()
678
+ label.text = text
679
+ label.font = textFont ?? .preferredFont(forTextStyle: .body)
680
+ label.textColor = .secondaryLabel
681
+ label.adjustsFontForContentSizeCategory = true
682
+ stack.addArrangedSubview(label)
683
+ }
684
+ stack.frame.size = stack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
685
+ return stack
686
+ }
687
+
688
+ /// Shows or hides the text of a secure field. Toggling `isSecureTextEntry`
689
+ /// makes UIKit clear the field on the next keystroke; re-inserting the
690
+ /// text through the text range keeps it and the caret.
691
+ private func toggleReveal() {
692
+ revealed.toggle()
693
+ let current = textField.text ?? ""
694
+ textField.isSecureTextEntry = secureTextEntry && !revealed
695
+ if textField.isFirstResponder, let range = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument) {
696
+ textField.replace(range, withText: current)
697
+ }
698
+ applyAccessories()
699
+ }
700
+
701
+ private func performAutoFocus() {
702
+ guard !autoFocusDone else { return }
703
+ autoFocusDone = true
704
+ DispatchQueue.main.async { [weak self] in self?.focus() }
705
+ }
706
+
707
+ public override func didMoveToWindow() {
708
+ super.didMoveToWindow()
709
+ if window != nil, autoFocus { performAutoFocus() }
710
+ }
711
+
712
+ public override func tintColorDidChange() {
713
+ super.tintColorDidChange()
714
+ applyColors()
715
+ }
716
+
717
+ public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
718
+ super.traitCollectionDidChange(previousTraitCollection)
719
+ if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
720
+ applyFonts()
721
+ }
722
+ if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true {
723
+ applyColors()
724
+ }
725
+ }
726
+
727
+ // MARK: - Layout
728
+
729
+ private struct Metrics {
730
+ var captionHeight: CGFloat
731
+ var inputHeight: CGFloat
732
+ var footerHeight: CGFloat
733
+ var leading: Bool
734
+ var total: CGFloat {
735
+ var height = leading ? max(inputHeight, captionHeight) : inputHeight
736
+ if captionHeight > 0 && !leading { height += captionHeight + 6 }
737
+ if footerHeight > 0 { height += footerHeight + 4 }
738
+ return height
739
+ }
740
+ }
741
+
742
+ /// The x where the field (and the footer) starts: after a leading label column
743
+ private func fieldOffset() -> CGFloat {
744
+ leadingLabel ? leadingColumnWidth + leadingSpacing : 0
745
+ }
746
+
747
+ private func metrics(for width: CGFloat) -> Metrics {
748
+ let captionHeight = label.isEmpty ? 0 : ceil(captionLabel.font.lineHeight)
749
+ let fieldWidth = max(1, width - fieldOffset())
750
+
751
+ let inputHeight: CGFloat
752
+ if multiline {
753
+ let fitted = textView.sizeThatFits(CGSize(width: fieldWidth, height: .greatestFiniteMagnitude))
754
+ inputHeight = max(singleLineMinHeight, ceil(fitted.height))
755
+ } else {
756
+ let fitted = textField.sizeThatFits(CGSize(width: fieldWidth, height: .greatestFiniteMagnitude))
757
+ inputHeight = max(singleLineMinHeight, ceil(fitted.height))
758
+ }
759
+
760
+ var footerHeight: CGFloat = 0
761
+ if !footerLabel.isHidden || !counterLabel.isHidden {
762
+ let counterWidth = counterLabel.isHidden
763
+ ? 0
764
+ : ceil(counterLabel.sizeThatFits(CGSize(width: fieldWidth, height: .greatestFiniteMagnitude)).width) + 8
765
+ let messageWidth = max(1, fieldWidth - counterWidth)
766
+ let messageHeight = footerLabel.isHidden
767
+ ? 0
768
+ : ceil(footerLabel.sizeThatFits(CGSize(width: messageWidth, height: .greatestFiniteMagnitude)).height)
769
+ let counterHeight = counterLabel.isHidden ? 0 : ceil(counterLabel.font.lineHeight)
770
+ footerHeight = max(messageHeight, counterHeight)
771
+ }
772
+ return Metrics(captionHeight: captionHeight, inputHeight: inputHeight, footerHeight: footerHeight, leading: leadingLabel)
773
+ }
774
+
775
+ public override func layoutSubviews() {
776
+ super.layoutSubviews()
777
+ let width = bounds.width
778
+ let m = metrics(for: width)
779
+ let x = fieldOffset()
780
+ let fieldWidth = max(0, width - x)
781
+ var y: CGFloat = 0
782
+
783
+ if m.leading {
784
+ // Label column and field side by side, centred on the taller one
785
+ let rowHeight = max(m.inputHeight, m.captionHeight)
786
+ captionLabel.frame = CGRect(
787
+ x: 0,
788
+ y: (rowHeight - m.captionHeight) / 2,
789
+ width: leadingColumnWidth,
790
+ height: m.captionHeight
791
+ )
792
+ activeInput.frame = CGRect(x: x, y: (rowHeight - m.inputHeight) / 2, width: fieldWidth, height: m.inputHeight)
793
+ y += rowHeight
794
+ } else {
795
+ if m.captionHeight > 0 {
796
+ captionLabel.frame = CGRect(x: 0, y: y, width: width, height: m.captionHeight)
797
+ y += m.captionHeight + captionSpacing
798
+ }
799
+ activeInput.frame = CGRect(x: 0, y: y, width: width, height: m.inputHeight)
800
+ y += m.inputHeight
801
+ }
802
+
803
+ if m.footerHeight > 0 {
804
+ y += footerSpacing
805
+ let counterWidth = counterLabel.isHidden
806
+ ? 0
807
+ : ceil(counterLabel.sizeThatFits(CGSize(width: fieldWidth, height: .greatestFiniteMagnitude)).width)
808
+ let messageWidth = max(0, fieldWidth - (counterWidth > 0 ? counterWidth + 8 : 0))
809
+ footerLabel.frame = CGRect(x: x, y: y, width: messageWidth, height: m.footerHeight)
810
+ counterLabel.frame = CGRect(x: width - counterWidth, y: y, width: counterWidth, height: m.footerHeight)
811
+ }
812
+ }
813
+
814
+ /// Called by the measuring pipeline to get the size for Yoga layout: the
815
+ /// height the field needs at the given width. The width is whatever Yoga
816
+ /// offers; a text field fills its row.
817
+ @objc public func sizeForLayout(withConstrainedTo constrainedSize: CGSize) -> CGSize {
818
+ let width: CGFloat
819
+ if constrainedSize.width > 0 && constrainedSize.width < CGFloat.greatestFiniteMagnitude {
820
+ width = constrainedSize.width
821
+ } else if bounds.width > 0 {
822
+ width = bounds.width
823
+ } else {
824
+ width = PCConstants.fallbackWidth
825
+ }
826
+ return CGSize(width: 0, height: ceil(metrics(for: width).total))
827
+ }
828
+
829
+ // MARK: - Trait mapping
830
+
831
+ private static func keyboardType(_ value: String) -> UIKeyboardType {
832
+ switch value {
833
+ case "number-pad": return .numberPad
834
+ case "decimal-pad": return .decimalPad
835
+ case "numeric": return .decimalPad
836
+ case "email-address": return .emailAddress
837
+ case "phone-pad": return .phonePad
838
+ case "url": return .URL
839
+ case "ascii-capable": return .asciiCapable
840
+ case "numbers-and-punctuation": return .numbersAndPunctuation
841
+ case "name-phone-pad": return .namePhonePad
842
+ case "twitter": return .twitter
843
+ case "web-search": return .webSearch
844
+ case "visible-password": return .asciiCapable
845
+ default: return .default
846
+ }
847
+ }
848
+
849
+ private static func returnKeyType(_ value: String) -> UIReturnKeyType {
850
+ switch value {
851
+ case "done": return .done
852
+ case "go": return .go
853
+ case "next": return .next
854
+ case "search": return .search
855
+ case "send": return .send
856
+ default: return .default
857
+ }
858
+ }
859
+
860
+ private static func autocapitalization(_ value: String) -> UITextAutocapitalizationType {
861
+ switch value {
862
+ case "none": return .none
863
+ case "words": return .words
864
+ case "characters": return .allCharacters
865
+ default: return .sentences
866
+ }
867
+ }
868
+
869
+ private static func keyboardAppearance(_ value: String) -> UIKeyboardAppearance {
870
+ switch value {
871
+ case "light": return .light
872
+ case "dark": return .dark
873
+ default: return .default
874
+ }
875
+ }
876
+
877
+ private static func contentType(_ value: String) -> UITextContentType? {
878
+ switch value {
879
+ case "username": return .username
880
+ case "password": return .password
881
+ case "new-password": return .newPassword
882
+ case "one-time-code": return .oneTimeCode
883
+ case "email": return .emailAddress
884
+ case "name": return .name
885
+ case "given-name": return .givenName
886
+ case "family-name": return .familyName
887
+ case "tel": return .telephoneNumber
888
+ case "street-address": return .fullStreetAddress
889
+ case "postal-code": return .postalCode
890
+ case "country": return .countryName
891
+ case "cc-number": return .creditCardNumber
892
+ case "cc-exp":
893
+ if #available(iOS 17.0, *) { return .creditCardExpiration }
894
+ return nil
895
+ case "cc-csc":
896
+ if #available(iOS 17.0, *) { return .creditCardSecurityCode }
897
+ return nil
898
+ case "url": return .URL
899
+ default: return nil
900
+ }
901
+ }
902
+
903
+ private static func smartQuotesType(_ value: String) -> UITextSmartQuotesType {
904
+ switch value {
905
+ case "yes": return .yes
906
+ case "no": return .no
907
+ default: return .default
908
+ }
909
+ }
910
+
911
+ private static func smartDashesType(_ value: String) -> UITextSmartDashesType {
912
+ switch value {
913
+ case "yes": return .yes
914
+ case "no": return .no
915
+ default: return .default
916
+ }
917
+ }
918
+
919
+ private static func smartInsertDeleteType(_ value: String) -> UITextSmartInsertDeleteType {
920
+ switch value {
921
+ case "yes": return .yes
922
+ case "no": return .no
923
+ default: return .default
924
+ }
925
+ }
926
+
927
+ @available(iOS 17.0, *)
928
+ private static func inlinePredictionType(_ value: String) -> UITextInlinePredictionType {
929
+ switch value {
930
+ case "yes": return .yes
931
+ case "no": return .no
932
+ default: return .default
933
+ }
934
+ }
935
+
936
+ @available(iOS 18.0, *)
937
+ private static func mathCompletionType(_ value: String) -> UITextMathExpressionCompletionType {
938
+ switch value {
939
+ case "yes": return .yes
940
+ case "no": return .no
941
+ default: return .default
942
+ }
943
+ }
944
+
945
+ @available(iOS 18.0, *)
946
+ private static func writingToolsBehavior(_ value: String) -> UIWritingToolsBehavior {
947
+ switch value {
948
+ case "complete": return .complete
949
+ case "limited": return .limited
950
+ case "none": return .none
951
+ default: return .default
952
+ }
953
+ }
954
+ }
955
+
956
+ // MARK: - Helpers
957
+
958
+ /// A `UITextView` with a placeholder, drawn where the text starts.
959
+ final class PCPlaceholderTextView: UITextView {
960
+ let placeholderLabel = UILabel()
961
+
962
+ var placeholder: String = "" {
963
+ didSet {
964
+ placeholderLabel.text = placeholder
965
+ updatePlaceholderVisibility()
966
+ }
967
+ }
968
+
969
+ override init(frame: CGRect, textContainer: NSTextContainer?) {
970
+ super.init(frame: frame, textContainer: textContainer)
971
+ placeholderLabel.textColor = .placeholderText
972
+ placeholderLabel.numberOfLines = 0
973
+ placeholderLabel.adjustsFontForContentSizeCategory = true
974
+ placeholderLabel.isUserInteractionEnabled = false
975
+ addSubview(placeholderLabel)
976
+ }
977
+
978
+ required init?(coder: NSCoder) {
979
+ super.init(coder: coder)
980
+ }
981
+
982
+ func updatePlaceholderVisibility() {
983
+ placeholderLabel.isHidden = !(text ?? "").isEmpty || placeholder.isEmpty
984
+ }
985
+
986
+ override func layoutSubviews() {
987
+ super.layoutSubviews()
988
+ let x = textContainerInset.left + textContainer.lineFragmentPadding
989
+ let width = max(0, bounds.width - x - textContainerInset.right - textContainer.lineFragmentPadding)
990
+ let height = placeholderLabel.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height
991
+ placeholderLabel.frame = CGRect(x: x, y: textContainerInset.top, width: width, height: height)
992
+ }
993
+ }
994
+
995
+ /// A button with a closure action, for the accessory slots.
996
+ final class PCAccessoryButton: UIButton {
997
+ var action: (() -> Void)?
998
+
999
+ override init(frame: CGRect) {
1000
+ super.init(frame: frame)
1001
+ addTarget(self, action: #selector(pressed), for: .touchUpInside)
1002
+ }
1003
+
1004
+ required init?(coder: NSCoder) {
1005
+ super.init(coder: coder)
1006
+ addTarget(self, action: #selector(pressed), for: .touchUpInside)
1007
+ }
1008
+
1009
+ @objc private func pressed() {
1010
+ action?()
1011
+ }
1012
+ }