@hzblj/kara-components 0.16.0 → 0.18.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 (211) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/lib/module/atoms/badge/badge.android.js +5 -0
  3. package/lib/module/atoms/badge/badge.android.js.map +1 -1
  4. package/lib/module/atoms/badge/badge.ios.js +2 -0
  5. package/lib/module/atoms/badge/badge.ios.js.map +1 -1
  6. package/lib/module/atoms/button/button-tokens.js +8 -0
  7. package/lib/module/atoms/button/button-tokens.js.map +1 -1
  8. package/lib/module/atoms/button/button.android.js +43 -15
  9. package/lib/module/atoms/button/button.android.js.map +1 -1
  10. package/lib/module/atoms/button/button.ios.js +66 -10
  11. package/lib/module/atoms/button/button.ios.js.map +1 -1
  12. package/lib/module/atoms/checkbox/checkbox.android.js +5 -1
  13. package/lib/module/atoms/checkbox/checkbox.android.js.map +1 -1
  14. package/lib/module/atoms/checkbox/checkbox.ios.js +3 -1
  15. package/lib/module/atoms/checkbox/checkbox.ios.js.map +1 -1
  16. package/lib/module/atoms/color-picker/color-picker.android.js +4 -0
  17. package/lib/module/atoms/color-picker/color-picker.android.js.map +1 -1
  18. package/lib/module/atoms/color-picker/color-picker.ios.js +24 -2
  19. package/lib/module/atoms/color-picker/color-picker.ios.js.map +1 -1
  20. package/lib/module/atoms/date-picker/date-picker.android.js +85 -10
  21. package/lib/module/atoms/date-picker/date-picker.android.js.map +1 -1
  22. package/lib/module/atoms/date-picker/date-picker.ios.js +9 -7
  23. package/lib/module/atoms/date-picker/date-picker.ios.js.map +1 -1
  24. package/lib/module/atoms/divider/divider.android.js +17 -4
  25. package/lib/module/atoms/divider/divider.android.js.map +1 -1
  26. package/lib/module/atoms/divider/divider.ios.js +4 -0
  27. package/lib/module/atoms/divider/divider.ios.js.map +1 -1
  28. package/lib/module/atoms/fab/fab.android.js +64 -8
  29. package/lib/module/atoms/fab/fab.android.js.map +1 -1
  30. package/lib/module/atoms/fab/fab.ios.js +68 -12
  31. package/lib/module/atoms/fab/fab.ios.js.map +1 -1
  32. package/lib/module/atoms/icon-button/icon-button.android.js +48 -5
  33. package/lib/module/atoms/icon-button/icon-button.android.js.map +1 -1
  34. package/lib/module/atoms/icon-button/icon-button.ios.js +60 -10
  35. package/lib/module/atoms/icon-button/icon-button.ios.js.map +1 -1
  36. package/lib/module/atoms/list/list.android.js +32 -4
  37. package/lib/module/atoms/list/list.android.js.map +1 -1
  38. package/lib/module/atoms/list/list.ios.js +15 -5
  39. package/lib/module/atoms/list/list.ios.js.map +1 -1
  40. package/lib/module/atoms/picker/picker.ios.js +27 -15
  41. package/lib/module/atoms/picker/picker.ios.js.map +1 -1
  42. package/lib/module/atoms/progress/progress.android.js +21 -7
  43. package/lib/module/atoms/progress/progress.android.js.map +1 -1
  44. package/lib/module/atoms/progress/progress.ios.js +5 -1
  45. package/lib/module/atoms/progress/progress.ios.js.map +1 -1
  46. package/lib/module/atoms/slider/slider.android.js +16 -2
  47. package/lib/module/atoms/slider/slider.android.js.map +1 -1
  48. package/lib/module/atoms/slider/slider.ios.js +22 -2
  49. package/lib/module/atoms/slider/slider.ios.js.map +1 -1
  50. package/lib/module/atoms/switch/switch.android.js +5 -1
  51. package/lib/module/atoms/switch/switch.android.js.map +1 -1
  52. package/lib/module/atoms/switch/switch.ios.js +3 -1
  53. package/lib/module/atoms/switch/switch.ios.js.map +1 -1
  54. package/lib/module/atoms/text/spans.js +2 -1
  55. package/lib/module/atoms/text/spans.js.map +1 -1
  56. package/lib/module/atoms/text/text.android.js +18 -6
  57. package/lib/module/atoms/text/text.android.js.map +1 -1
  58. package/lib/module/atoms/text/text.ios.js +6 -2
  59. package/lib/module/atoms/text/text.ios.js.map +1 -1
  60. package/lib/module/atoms/text-field/text-field-input.js +14 -0
  61. package/lib/module/atoms/text-field/text-field-input.js.map +1 -0
  62. package/lib/module/atoms/text-field/text-field.android.js +113 -6
  63. package/lib/module/atoms/text-field/text-field.android.js.map +1 -1
  64. package/lib/module/atoms/text-field/text-field.ios.js +75 -3
  65. package/lib/module/atoms/text-field/text-field.ios.js.map +1 -1
  66. package/lib/module/molecules/collapsible/collapsible.android.js +4 -3
  67. package/lib/module/molecules/collapsible/collapsible.android.js.map +1 -1
  68. package/lib/module/molecules/collapsible/collapsible.ios.js +3 -1
  69. package/lib/module/molecules/collapsible/collapsible.ios.js.map +1 -1
  70. package/lib/module/molecules/radio-group/radio-group.android.js +10 -2
  71. package/lib/module/molecules/radio-group/radio-group.android.js.map +1 -1
  72. package/lib/module/molecules/radio-group/radio-group.ios.js +6 -2
  73. package/lib/module/molecules/radio-group/radio-group.ios.js.map +1 -1
  74. package/lib/module/molecules/search-bar/search-bar.js +9 -0
  75. package/lib/module/molecules/search-bar/search-bar.js.map +1 -1
  76. package/lib/module/molecules/stepper/stepper.android.js +25 -13
  77. package/lib/module/molecules/stepper/stepper.android.js.map +1 -1
  78. package/lib/module/molecules/stepper/stepper.ios.js +12 -6
  79. package/lib/module/molecules/stepper/stepper.ios.js.map +1 -1
  80. package/lib/module/organisms/action-sheet/action-sheet.android.js +16 -8
  81. package/lib/module/organisms/action-sheet/action-sheet.android.js.map +1 -1
  82. package/lib/module/organisms/action-sheet/action-sheet.ios.js +16 -3
  83. package/lib/module/organisms/action-sheet/action-sheet.ios.js.map +1 -1
  84. package/lib/module/organisms/alert/alert.android.js +4 -3
  85. package/lib/module/organisms/alert/alert.android.js.map +1 -1
  86. package/lib/module/organisms/alert/alert.ios.js +3 -2
  87. package/lib/module/organisms/alert/alert.ios.js.map +1 -1
  88. package/lib/module/organisms/context-menu/context-menu.android.js +3 -1
  89. package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
  90. package/lib/module/organisms/context-menu/context-menu.ios.js +1 -0
  91. package/lib/module/organisms/context-menu/context-menu.ios.js.map +1 -1
  92. package/lib/module/organisms/fab-menu/fab-menu.android.js +3 -0
  93. package/lib/module/organisms/fab-menu/fab-menu.android.js.map +1 -1
  94. package/lib/module/organisms/fab-menu/fab-menu.ios.js +3 -0
  95. package/lib/module/organisms/fab-menu/fab-menu.ios.js.map +1 -1
  96. package/lib/module/organisms/menu/menu-parts.js +20 -0
  97. package/lib/module/organisms/menu/menu-parts.js.map +1 -0
  98. package/lib/module/organisms/menu/menu.android.js +51 -19
  99. package/lib/module/organisms/menu/menu.android.js.map +1 -1
  100. package/lib/module/organisms/menu/menu.ios.js +71 -8
  101. package/lib/module/organisms/menu/menu.ios.js.map +1 -1
  102. package/lib/module/organisms/sheet/sheet.js +2 -1
  103. package/lib/module/organisms/sheet/sheet.js.map +1 -1
  104. package/lib/typescript/atoms/badge/badge.types.d.ts +2 -0
  105. package/lib/typescript/atoms/button/button-tokens.d.ts +6 -2
  106. package/lib/typescript/atoms/button/button.types.d.ts +19 -5
  107. package/lib/typescript/atoms/checkbox/checkbox.types.d.ts +2 -0
  108. package/lib/typescript/atoms/color-picker/color-picker.types.d.ts +2 -0
  109. package/lib/typescript/atoms/date-picker/date-picker.ios.d.ts +5 -5
  110. package/lib/typescript/atoms/date-picker/date-picker.types.d.ts +30 -7
  111. package/lib/typescript/atoms/divider/divider.ios.d.ts +4 -0
  112. package/lib/typescript/atoms/divider/divider.types.d.ts +13 -2
  113. package/lib/typescript/atoms/fab/fab.types.d.ts +21 -4
  114. package/lib/typescript/atoms/icon-button/icon-button.types.d.ts +23 -4
  115. package/lib/typescript/atoms/list/list.types.d.ts +15 -1
  116. package/lib/typescript/atoms/picker/picker.ios.d.ts +5 -3
  117. package/lib/typescript/atoms/picker/picker.types.d.ts +11 -3
  118. package/lib/typescript/atoms/progress/progress.types.d.ts +13 -2
  119. package/lib/typescript/atoms/slider/slider.ios.d.ts +4 -0
  120. package/lib/typescript/atoms/slider/slider.types.d.ts +7 -0
  121. package/lib/typescript/atoms/switch/switch.types.d.ts +2 -0
  122. package/lib/typescript/atoms/text/spans.d.ts +2 -0
  123. package/lib/typescript/atoms/text/text.types.d.ts +9 -1
  124. package/lib/typescript/atoms/text-field/text-field-input.d.ts +7 -0
  125. package/lib/typescript/atoms/text-field/text-field.types.d.ts +77 -4
  126. package/lib/typescript/molecules/collapsible/collapsible.types.d.ts +2 -0
  127. package/lib/typescript/molecules/radio-group/radio-group.types.d.ts +4 -0
  128. package/lib/typescript/molecules/search-bar/search-bar.types.d.ts +11 -1
  129. package/lib/typescript/molecules/stepper/stepper.ios.d.ts +2 -1
  130. package/lib/typescript/molecules/stepper/stepper.types.d.ts +17 -0
  131. package/lib/typescript/organisms/action-sheet/action-sheet.ios.d.ts +4 -0
  132. package/lib/typescript/organisms/action-sheet/action-sheet.types.d.ts +8 -3
  133. package/lib/typescript/organisms/alert/alert.types.d.ts +8 -3
  134. package/lib/typescript/organisms/menu/menu-parts.d.ts +14 -0
  135. package/lib/typescript/organisms/menu/menu.types.d.ts +35 -7
  136. package/lib/typescript/organisms/sheet/sheet.types.d.ts +7 -1
  137. package/package.json +4 -4
  138. package/src/atoms/badge/badge.android.tsx +9 -2
  139. package/src/atoms/badge/badge.ios.tsx +2 -2
  140. package/src/atoms/badge/badge.types.ts +2 -0
  141. package/src/atoms/button/button-tokens.ts +6 -3
  142. package/src/atoms/button/button.android.tsx +35 -11
  143. package/src/atoms/button/button.ios.tsx +72 -8
  144. package/src/atoms/button/button.types.ts +20 -5
  145. package/src/atoms/checkbox/checkbox.android.tsx +5 -2
  146. package/src/atoms/checkbox/checkbox.ios.tsx +2 -2
  147. package/src/atoms/checkbox/checkbox.types.ts +2 -0
  148. package/src/atoms/color-picker/color-picker.android.tsx +10 -2
  149. package/src/atoms/color-picker/color-picker.ios.tsx +24 -1
  150. package/src/atoms/color-picker/color-picker.types.ts +2 -0
  151. package/src/atoms/date-picker/date-picker.android.tsx +86 -16
  152. package/src/atoms/date-picker/date-picker.ios.tsx +20 -8
  153. package/src/atoms/date-picker/date-picker.types.ts +31 -7
  154. package/src/atoms/divider/divider.android.tsx +14 -3
  155. package/src/atoms/divider/divider.ios.tsx +4 -0
  156. package/src/atoms/divider/divider.types.ts +14 -2
  157. package/src/atoms/fab/fab.android.tsx +61 -11
  158. package/src/atoms/fab/fab.ios.tsx +48 -13
  159. package/src/atoms/fab/fab.types.ts +22 -4
  160. package/src/atoms/icon-button/icon-button.android.tsx +60 -10
  161. package/src/atoms/icon-button/icon-button.ios.tsx +77 -8
  162. package/src/atoms/icon-button/icon-button.types.ts +24 -4
  163. package/src/atoms/list/list.android.tsx +26 -4
  164. package/src/atoms/list/list.ios.tsx +20 -5
  165. package/src/atoms/list/list.types.ts +16 -1
  166. package/src/atoms/picker/picker.ios.tsx +46 -16
  167. package/src/atoms/picker/picker.types.ts +12 -3
  168. package/src/atoms/progress/progress.android.tsx +32 -6
  169. package/src/atoms/progress/progress.ios.tsx +11 -5
  170. package/src/atoms/progress/progress.types.ts +13 -2
  171. package/src/atoms/slider/slider.android.tsx +18 -2
  172. package/src/atoms/slider/slider.ios.tsx +38 -2
  173. package/src/atoms/slider/slider.types.ts +7 -0
  174. package/src/atoms/switch/switch.android.tsx +5 -2
  175. package/src/atoms/switch/switch.ios.tsx +2 -2
  176. package/src/atoms/switch/switch.types.ts +2 -0
  177. package/src/atoms/text/spans.ts +2 -1
  178. package/src/atoms/text/text.android.tsx +20 -7
  179. package/src/atoms/text/text.ios.tsx +7 -0
  180. package/src/atoms/text/text.types.ts +10 -1
  181. package/src/atoms/text-field/text-field-input.ts +17 -0
  182. package/src/atoms/text-field/text-field.android.tsx +132 -9
  183. package/src/atoms/text-field/text-field.ios.tsx +110 -6
  184. package/src/atoms/text-field/text-field.types.ts +98 -4
  185. package/src/molecules/collapsible/collapsible.android.tsx +10 -2
  186. package/src/molecules/collapsible/collapsible.ios.tsx +8 -2
  187. package/src/molecules/collapsible/collapsible.types.ts +2 -0
  188. package/src/molecules/radio-group/radio-group.android.tsx +18 -4
  189. package/src/molecules/radio-group/radio-group.ios.tsx +19 -5
  190. package/src/molecules/radio-group/radio-group.types.ts +4 -0
  191. package/src/molecules/search-bar/search-bar.tsx +9 -0
  192. package/src/molecules/search-bar/search-bar.types.ts +12 -1
  193. package/src/molecules/stepper/stepper.android.tsx +24 -4
  194. package/src/molecules/stepper/stepper.ios.tsx +23 -8
  195. package/src/molecules/stepper/stepper.types.ts +17 -0
  196. package/src/organisms/action-sheet/action-sheet.android.tsx +23 -11
  197. package/src/organisms/action-sheet/action-sheet.ios.tsx +20 -3
  198. package/src/organisms/action-sheet/action-sheet.types.ts +8 -3
  199. package/src/organisms/alert/alert.android.tsx +11 -8
  200. package/src/organisms/alert/alert.ios.tsx +2 -1
  201. package/src/organisms/alert/alert.types.ts +8 -3
  202. package/src/organisms/context-menu/context-menu.android.tsx +3 -2
  203. package/src/organisms/context-menu/context-menu.ios.tsx +1 -0
  204. package/src/organisms/fab-menu/fab-menu.android.tsx +3 -0
  205. package/src/organisms/fab-menu/fab-menu.ios.tsx +9 -1
  206. package/src/organisms/menu/menu-parts.ts +21 -0
  207. package/src/organisms/menu/menu.android.tsx +39 -10
  208. package/src/organisms/menu/menu.ios.tsx +55 -5
  209. package/src/organisms/menu/menu.types.ts +30 -7
  210. package/src/organisms/sheet/sheet.tsx +2 -1
  211. package/src/organisms/sheet/sheet.types.ts +7 -1
@@ -1,11 +1,27 @@
1
- import {SecureField, TextField as SwiftUITextField, useNativeState, VStack} from '@expo/ui/swift-ui'
2
- import {disabled as disabledModifier, textFieldStyle} from '@expo/ui/swift-ui/modifiers'
1
+ import {
2
+ SecureField,
3
+ TextField as SwiftUITextField,
4
+ type TextFieldRef as SwiftUITextFieldRef,
5
+ useNativeState,
6
+ VStack,
7
+ } from '@expo/ui/swift-ui'
8
+ import {
9
+ autocorrectionDisabled,
10
+ disabled as disabledModifier,
11
+ keyboardType,
12
+ onSubmit as onSubmitModifier,
13
+ submitLabel as submitLabelModifier,
14
+ textContentType,
15
+ textFieldStyle,
16
+ textInputAutocapitalization,
17
+ } from '@expo/ui/swift-ui/modifiers'
3
18
  import {useSxModifiers} from '@hzblj/kara-sx'
4
19
  import {type ColorToken, spacing} from '@hzblj/kara-theme'
5
- import {useMemo} from 'react'
20
+ import {useImperativeHandle, useMemo, useRef} from 'react'
6
21
  import {useHostGuard} from '../../host-context'
7
22
  import {Text} from '../text'
8
- import type {TextFieldComponent, TextFieldProps} from './text-field.types'
23
+ import type {TextFieldAutoCapitalize, TextFieldComponent, TextFieldKeyboard, TextFieldProps} from './text-field.types'
24
+ import {autoCorrectFor, capitalizationFor, ignoreDetachedView} from './text-field-input'
9
25
 
10
26
  type FieldTone = 'default' | 'disabled' | 'error'
11
27
 
@@ -21,6 +37,23 @@ const TONE = {
21
37
  error: {bg: 'surface.primary', border: 'border.destructive', label: 'content.destructive'},
22
38
  } as const satisfies Record<FieldTone, FieldToneColors>
23
39
 
40
+ /** UIKit's keyboards. `number` is the digits-only pad, which, like `decimal` and `phone`, has no return key. */
41
+ const KEYBOARD = {
42
+ decimal: 'decimal-pad',
43
+ default: 'default',
44
+ email: 'email-address',
45
+ number: 'numeric',
46
+ phone: 'phone-pad',
47
+ url: 'url',
48
+ } as const satisfies Record<TextFieldKeyboard, Parameters<typeof keyboardType>[0]>
49
+
50
+ const CAPITALIZATION = {
51
+ characters: 'characters',
52
+ none: 'never',
53
+ sentences: 'sentences',
54
+ words: 'words',
55
+ } as const satisfies Record<TextFieldAutoCapitalize, Parameters<typeof textInputAutocapitalization>[0]>
56
+
24
57
  /**
25
58
  * Disabled outranks error — a field nobody can edit should recede rather than shout. `.disabled` only
26
59
  * dims the text, so the chrome has to carry the state itself.
@@ -34,19 +67,50 @@ const toneOf = (disabled: boolean, error: boolean): FieldTone => {
34
67
  }
35
68
 
36
69
  export const TextField: TextFieldComponent = ({
70
+ autoCapitalize,
71
+ autoCorrect,
72
+ autoFocus = false,
73
+ contentType,
37
74
  defaultValue = '',
38
75
  disabled = false,
39
76
  error = false,
77
+ keyboard = 'default',
40
78
  label,
79
+ maxLength,
41
80
  multiline = false,
42
81
  onChangeText,
82
+ onFocusChange,
83
+ onSubmit,
84
+ placeholder,
85
+ ref,
43
86
  secure = false,
87
+ submitLabel,
44
88
  testID,
45
89
  }: TextFieldProps) => {
46
90
  useHostGuard('TextField')
47
91
  const text = useNativeState(defaultValue)
92
+ // One handle for either field: `SecureFieldRef` is `TextFieldRef` without the selection.
93
+ const field = useRef<SwiftUITextFieldRef>(null)
48
94
  const tone = TONE[toneOf(disabled, Boolean(error))]
49
95
  const message = tone === TONE.error && typeof error === 'string' && error ? error : undefined
96
+ const capitalization = capitalizationFor(keyboard, autoCapitalize)
97
+ const corrects = autoCorrectFor(keyboard, autoCorrect)
98
+ // A `SecureField` is one line whatever `multiline` says; a vertical `TextField`'s return key starts a new line.
99
+ const singleLine = secure || !multiline
100
+
101
+ useImperativeHandle(
102
+ ref,
103
+ () => ({
104
+ blur: () => field.current?.blur().catch(ignoreDetachedView),
105
+ // Neither field reports a text set from code, so the change is reported here.
106
+ clear: () => {
107
+ field.current?.clear().catch(ignoreDetachedView)
108
+ onChangeText?.('')
109
+ },
110
+ focus: () => field.current?.focus().catch(ignoreDetachedView),
111
+ }),
112
+ [onChangeText]
113
+ )
50
114
 
51
115
  const sxModifiers = useSxModifiers({
52
116
  bg: tone.bg,
@@ -65,8 +129,33 @@ export const TextField: TextFieldComponent = ({
65
129
  list.push(disabledModifier(true))
66
130
  }
67
131
 
132
+ if (keyboard !== 'default') {
133
+ list.push(keyboardType(KEYBOARD[keyboard]))
134
+ }
135
+
136
+ if (capitalization) {
137
+ list.push(textInputAutocapitalization(CAPITALIZATION[capitalization]))
138
+ }
139
+
140
+ if (!corrects) {
141
+ list.push(autocorrectionDisabled(true))
142
+ }
143
+
144
+ if (contentType) {
145
+ list.push(textContentType(contentType))
146
+ }
147
+
148
+ if (singleLine && submitLabel) {
149
+ list.push(submitLabelModifier(submitLabel))
150
+ }
151
+
152
+ if (singleLine && onSubmit) {
153
+ // `onSubmit` reports no text, so it is read off the field's state, which the typing has already written.
154
+ list.push(onSubmitModifier(() => onSubmit(text.get())))
155
+ }
156
+
68
157
  return list
69
- }, [disabled, sxModifiers])
158
+ }, [capitalization, contentType, corrects, disabled, keyboard, onSubmit, singleLine, submitLabel, sxModifiers, text])
70
159
 
71
160
  return (
72
161
  <VStack alignment="leading" spacing={spacing.s4}>
@@ -76,12 +165,27 @@ export const TextField: TextFieldComponent = ({
76
165
  </Text>
77
166
  ) : null}
78
167
  {secure ? (
79
- <SecureField modifiers={modifiers} onTextChange={onChangeText} testID={testID} text={text} />
168
+ <SecureField
169
+ autoFocus={autoFocus}
170
+ maxLength={maxLength}
171
+ modifiers={modifiers}
172
+ onFocusChange={onFocusChange}
173
+ onTextChange={onChangeText}
174
+ placeholder={placeholder}
175
+ ref={field}
176
+ testID={testID}
177
+ text={text}
178
+ />
80
179
  ) : (
81
180
  <SwiftUITextField
181
+ autoFocus={autoFocus}
82
182
  axis={multiline ? 'vertical' : 'horizontal'}
183
+ maxLength={maxLength}
83
184
  modifiers={modifiers}
185
+ onFocusChange={onFocusChange}
84
186
  onTextChange={onChangeText}
187
+ placeholder={placeholder}
188
+ ref={field}
85
189
  testID={testID}
86
190
  text={text}
87
191
  />
@@ -1,10 +1,72 @@
1
- import type {ReactNode} from 'react'
1
+ import type {ReactNode, Ref} from 'react'
2
+
3
+ /** The keyboard a field brings up. */
4
+ export type TextFieldKeyboard = 'decimal' | 'default' | 'email' | 'number' | 'phone' | 'url'
5
+
6
+ /** When the keyboard turns Shift on by itself. */
7
+ export type TextFieldAutoCapitalize = 'characters' | 'none' | 'sentences' | 'words'
8
+
9
+ /** What the return key says. */
10
+ export type TextFieldSubmitLabel = 'done' | 'go' | 'next' | 'search' | 'send'
11
+
12
+ /**
13
+ * What a field holds, so the system can offer to fill it in: an account's email or password, a new strong password,
14
+ * the code from a text message, the user's own name, phone number or address.
15
+ */
16
+ export type TextFieldContentType =
17
+ | 'addressCity'
18
+ | 'countryName'
19
+ | 'creditCardNumber'
20
+ | 'emailAddress'
21
+ | 'familyName'
22
+ | 'fullStreetAddress'
23
+ | 'givenName'
24
+ | 'name'
25
+ | 'newPassword'
26
+ | 'oneTimeCode'
27
+ | 'password'
28
+ | 'postalCode'
29
+ | 'streetAddressLine1'
30
+ | 'streetAddressLine2'
31
+ | 'telephoneNumber'
32
+ | 'username'
33
+
34
+ /** Drives a `TextField` from code, as when `onSubmit` moves on to the next field. */
35
+ export type TextFieldRef = {
36
+ /** Ends editing: the field gives up focus and the keyboard goes down. */
37
+ blur: () => void
38
+ /** Empties the field and calls `onChangeText` with `''`. */
39
+ clear: () => void
40
+ /** Starts editing: the field takes focus and brings up the keyboard. */
41
+ focus: () => void
42
+ }
2
43
 
3
44
  /**
4
45
  * An uncontrolled text field with an optional label above it: SwiftUI `TextField` (`SecureField` when `secure`) /
5
- * Compose `OutlinedTextField`.
46
+ * Compose `OutlinedTextField`. `keyboard`, `autoCapitalize`, `autoCorrect` and `contentType` fit it to the text it
47
+ * takes; `submitLabel`, `onSubmit` and `ref` chain fields so the return key moves on to the next.
6
48
  */
7
49
  export type TextFieldProps = {
50
+ /**
51
+ * When the keyboard turns Shift on by itself: never (`none`), at the start of `sentences`, of `words`, or for all
52
+ * `characters`. Unset, it is `none` for the `email` and `url` keyboards, and otherwise each platform's own:
53
+ * `sentences` on iOS, `none` on Android.
54
+ */
55
+ autoCapitalize?: TextFieldAutoCapitalize
56
+ /**
57
+ * Lets the keyboard correct words as they are typed and suggest the next. On unless the keyboard is `email` or
58
+ * `url`, where a correction would only break an address. A `secure` field never corrects.
59
+ */
60
+ autoCorrect?: boolean
61
+ /** Takes focus, and brings up the keyboard, as soon as the field appears. Off by default. */
62
+ autoFocus?: boolean
63
+ /**
64
+ * What the field holds, so the system can offer to fill it in: a saved `username` or `password` from the keychain
65
+ * or a password manager, a strong `newPassword` for a sign-up, the `oneTimeCode` from a text message, the user's own
66
+ * `emailAddress`, `name`, `telephoneNumber` or address. iOS's `textContentType`; on Android a Compose autofill
67
+ * hint, which an autofill service (Google's, a password manager) answers.
68
+ */
69
+ contentType?: TextFieldContentType
8
70
  /** The text it starts with. Read once, on mount: the field is uncontrolled. */
9
71
  defaultValue?: string
10
72
  /** Stops editing and dims the field. On iOS it outranks `error`. */
@@ -15,15 +77,47 @@ export type TextFieldProps = {
15
77
  * colour is a pale red in dark mode. `true` marks it without a message.
16
78
  */
17
79
  error?: boolean | string
80
+ /**
81
+ * The keyboard it brings up. `default` by default. `email` and `url` add their `@` and `.` keys and turn
82
+ * capitals and corrections off unless `autoCapitalize` or `autoCorrect` say otherwise; `number` is a pad of digits,
83
+ * `decimal` adds the locale's decimal separator, `phone` is a dial pad. With `secure`, Android brings up its password
84
+ * keyboard (a numeric one for `number`).
85
+ */
86
+ keyboard?: TextFieldKeyboard
18
87
  /** Shown above the field, in `labelMedium`. */
19
88
  label?: string
89
+ /**
90
+ * The most characters it takes. What is typed past it is cut off as the user types; `defaultValue` is not. No limit
91
+ * by default.
92
+ */
93
+ maxLength?: number
20
94
  /** Wraps the text and grows the field to fit it. iOS ignores it when `secure`. */
21
95
  multiline?: boolean
22
- /** Called with the whole text after every change. */
96
+ /** Called with the whole text after every change, `clear()` included. */
23
97
  onChangeText?: (text: string) => void
98
+ /** Called with `true` when the field starts editing and `false` when it stops. */
99
+ onFocusChange?: (focused: boolean) => void
100
+ /**
101
+ * Called with the whole text when the return key is pressed. A `multiline` field never calls it: its return key
102
+ * starts a new line. Neither do iOS's `number`, `decimal` and `phone` pads, which have no return key.
103
+ */
104
+ onSubmit?: (text: string) => void
105
+ /**
106
+ * Shown in the empty field, in the platform's placeholder colour, until the first character is typed. A hint at
107
+ * the format (`name@example.com`), not a replacement for `label`.
108
+ */
109
+ placeholder?: string
110
+ /** `focus`, `blur` and `clear`, for moving to the next field from `onSubmit`. */
111
+ ref?: Ref<TextFieldRef>
24
112
  /** Masks the text, for a password. */
25
113
  secure?: boolean
26
- /** iOS: forwarded to the native field. Android ignores it. */
114
+ /**
115
+ * What the return key says. Unset, each platform's own (`return` on iOS). It names the key; what pressing it does
116
+ * is up to `onSubmit`, so a `next` focuses the following field through its `ref`. Android's `next` also moves focus
117
+ * to the next field in the same host by itself. A `multiline` field ignores it: its return key starts a new line.
118
+ */
119
+ submitLabel?: TextFieldSubmitLabel
120
+ /** Test identifier, forwarded to the native field (not the label above it). */
27
121
  testID?: string
28
122
  }
29
123
 
@@ -13,6 +13,7 @@ import {
13
13
  defaultMinSize,
14
14
  fillMaxWidth,
15
15
  graphicsLayer,
16
+ testID as testIdModifier,
16
17
  tween,
17
18
  } from '@expo/ui/jetpack-compose/modifiers'
18
19
  import {useAccessibilityModifiers} from '@hzblj/kara-sx'
@@ -44,7 +45,13 @@ const ROW_HEIGHT = 48
44
45
  * toward it, as a Material disclosure does — it used to jump between two glyphs. The row spans its parent, the chevron
45
46
  * at the far end, and is never shorter than a touch target.
46
47
  */
47
- export const Collapsible: CollapsibleComponent = ({children, expanded, label, onExpandedChange}: CollapsibleProps) => {
48
+ export const Collapsible: CollapsibleComponent = ({
49
+ children,
50
+ expanded,
51
+ label,
52
+ onExpandedChange,
53
+ testID,
54
+ }: CollapsibleProps) => {
48
55
  useHostGuard('Collapsible')
49
56
  const theme = useAppTheme()
50
57
  const reduced = useReducedMotion()
@@ -53,10 +60,11 @@ export const Collapsible: CollapsibleComponent = ({children, expanded, label, on
53
60
  () => [
54
61
  fillMaxWidth(),
55
62
  defaultMinSize({minHeight: ROW_HEIGHT}),
63
+ ...(testID ? [testIdModifier(testID)] : []),
56
64
  clickable(() => onExpandedChange(!expanded)),
57
65
  ...accessibilityModifiers,
58
66
  ],
59
- [accessibilityModifiers, expanded, onExpandedChange]
67
+ [accessibilityModifiers, expanded, onExpandedChange, testID]
60
68
  )
61
69
  const turn = useMemo(
62
70
  () => [graphicsLayer({rotationZ: animated(expanded ? 90 : 0, reduced ? SNAP : TURN)})],
@@ -3,11 +3,17 @@ import {DisclosureGroup} from '@expo/ui/swift-ui'
3
3
  import {useHostGuard} from '../../host-context'
4
4
  import type {CollapsibleComponent, CollapsibleProps} from './collapsible.types'
5
5
 
6
- export const Collapsible: CollapsibleComponent = ({children, expanded, label, onExpandedChange}: CollapsibleProps) => {
6
+ export const Collapsible: CollapsibleComponent = ({
7
+ children,
8
+ expanded,
9
+ label,
10
+ onExpandedChange,
11
+ testID,
12
+ }: CollapsibleProps) => {
7
13
  useHostGuard('Collapsible')
8
14
 
9
15
  return (
10
- <DisclosureGroup isExpanded={expanded} label={label} onIsExpandedChange={onExpandedChange}>
16
+ <DisclosureGroup isExpanded={expanded} label={label} onIsExpandedChange={onExpandedChange} testID={testID}>
11
17
  {children}
12
18
  </DisclosureGroup>
13
19
  )
@@ -13,6 +13,8 @@ export type CollapsibleProps = {
13
13
  label: string
14
14
  /** The user pressed the row; it passes the state the row asks for. */
15
15
  onExpandedChange: (expanded: boolean) => void
16
+ /** Test identifier: on SwiftUI's `DisclosureGroup` on iOS, on the pressable row on Android. */
17
+ testID?: string
16
18
  }
17
19
 
18
20
  export type CollapsibleComponent = (props: CollapsibleProps) => ReactNode
@@ -5,25 +5,39 @@ import {VStack} from '../../atoms/v-stack'
5
5
  import {useHostGuard} from '../../host-context'
6
6
  import type {RadioGroupComponent, RadioGroupProps} from './radio-group.types'
7
7
 
8
- export const RadioGroup: RadioGroupComponent = ({onValueChange, options, value}: RadioGroupProps) => {
8
+ /**
9
+ * Material's radio buttons, each in a full-width row that selects it as a whole. A disabled group keeps every
10
+ * button's handler, so Material keeps the touch target an enabled one has; `enabled` is what stops the input.
11
+ */
12
+ export const RadioGroup: RadioGroupComponent = ({
13
+ disabled = false,
14
+ onValueChange,
15
+ options,
16
+ testID,
17
+ value,
18
+ }: RadioGroupProps) => {
9
19
  useHostGuard('RadioGroup')
10
20
 
11
21
  const theme = useAppTheme()
12
22
 
13
23
  return (
14
- <VStack spacing="s4" wide>
24
+ <VStack spacing="s4" testID={testID} wide>
15
25
  {options.map(option => (
16
26
  <Row
17
27
  key={option.value}
18
- modifiers={[fillMaxWidth(), clickable(() => onValueChange?.(option.value))]}
28
+ modifiers={disabled ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => onValueChange?.(option.value))]}
19
29
  verticalAlignment="center"
20
30
  >
21
31
  <RadioButton
22
32
  colors={{selectedColor: theme.color('fill.accent.primary')}}
33
+ enabled={!disabled}
23
34
  onClick={() => onValueChange?.(option.value)}
24
35
  selected={option.value === value}
25
36
  />
26
- <ComposeText color={theme.colorString('content.primary')} style={theme.typography.body}>
37
+ <ComposeText
38
+ color={theme.colorString(disabled ? 'content.tertiary' : 'content.primary')}
39
+ style={theme.typography.body}
40
+ >
27
41
  {option.label}
28
42
  </ComposeText>
29
43
  </Row>
@@ -6,16 +6,30 @@ import {Glyph} from '../../glyph'
6
6
  import {useHostGuard} from '../../host-context'
7
7
  import type {RadioGroupComponent, RadioGroupProps} from './radio-group.types'
8
8
 
9
- export const RadioGroup: RadioGroupComponent = ({onValueChange, options, value}: RadioGroupProps) => {
9
+ export const RadioGroup: RadioGroupComponent = ({
10
+ disabled = false,
11
+ onValueChange,
12
+ options,
13
+ testID,
14
+ value,
15
+ }: RadioGroupProps) => {
10
16
  useHostGuard('RadioGroup')
11
17
 
12
18
  return (
13
- <VStack spacing="s4" wide>
19
+ <VStack spacing="s4" testID={testID} wide>
14
20
  {options.map(option => (
15
- <HStack align="center" key={option.value} onPress={() => onValueChange?.(option.value)} py="s8" wide>
16
- <Text>{option.label}</Text>
21
+ <HStack
22
+ align="center"
23
+ key={option.value}
24
+ onPress={disabled ? undefined : () => onValueChange?.(option.value)}
25
+ py="s8"
26
+ wide
27
+ >
28
+ <Text color={disabled ? 'content.tertiary' : 'content.primary'}>{option.label}</Text>
17
29
  <Spacer />
18
- {option.value === value ? <Glyph color="content.accent" name="check" size="s20" /> : null}
30
+ {option.value === value ? (
31
+ <Glyph color={disabled ? 'content.tertiary' : 'content.accent'} name="check" size="s20" />
32
+ ) : null}
19
33
  </HStack>
20
34
  ))}
21
35
  </VStack>
@@ -13,10 +13,14 @@ export type RadioOption = {
13
13
  * native atom: it renders inside `<Screen>` or `<Island>`.
14
14
  */
15
15
  export type RadioGroupProps = {
16
+ /** Stops the user changing the selection, and dims the options. */
17
+ disabled?: boolean
16
18
  /** The user pressed an option; it passes that option's `value`. */
17
19
  onValueChange?: (value: string) => void
18
20
  /** The choices, top to bottom. */
19
21
  options: RadioOption[]
22
+ /** Test identifier, on the whole group. Reach an option by its label. */
23
+ testID?: string
20
24
  /** The selected option's `value`. Update it from `onValueChange`. */
21
25
  value: string
22
26
  }
@@ -7,7 +7,11 @@ import {RnGlyph} from '../../glyph'
7
7
  import type {SearchBarComponent, SearchBarProps} from './search-bar.types'
8
8
 
9
9
  export const SearchBar: SearchBarComponent = ({
10
+ autoCapitalize = 'none',
11
+ autoCorrect = false,
12
+ autoFocus = false,
10
13
  onChangeText,
14
+ onSubmit,
11
15
  placeholder = 'Search',
12
16
  testID,
13
17
  value,
@@ -35,9 +39,14 @@ export const SearchBar: SearchBarComponent = ({
35
39
  >
36
40
  <RnGlyph color="content.tertiary" name="search" size="s20" />
37
41
  <TextInput
42
+ autoCapitalize={autoCapitalize}
43
+ autoCorrect={autoCorrect}
44
+ autoFocus={autoFocus}
38
45
  onChangeText={onChangeText}
46
+ onSubmitEditing={onSubmit ? event => onSubmit(event.nativeEvent.text) : undefined}
39
47
  placeholder={placeholder}
40
48
  placeholderTextColor={theme.color('content.quaternary')}
49
+ returnKeyType="search"
41
50
  style={[text, {color: theme.color('content.primary'), flex: 1, padding: 0}]}
42
51
  value={value}
43
52
  />
@@ -1,12 +1,23 @@
1
1
  import type {ReactNode} from 'react'
2
2
 
3
+ import type {TextFieldAutoCapitalize} from '../../atoms/text-field'
4
+
3
5
  /**
4
6
  * A search field drawn the same on both platforms: a rounded box with a magnifier, a `TextInput` and a clear button
5
- * while there is text. The React Native tier: it needs no host.
7
+ * while there is text. The keyboard's return key reads Search, and a query is taken as typed: no capitals and no
8
+ * corrections unless asked for. The React Native tier: it needs no host.
6
9
  */
7
10
  export type SearchBarProps = {
11
+ /** When the keyboard turns Shift on by itself. `none` by default: a query is not a sentence. */
12
+ autoCapitalize?: TextFieldAutoCapitalize
13
+ /** Lets the keyboard correct words as they are typed. Off by default, so a query is searched as typed. */
14
+ autoCorrect?: boolean
15
+ /** Takes focus, and brings up the keyboard, as soon as the field appears. Off by default. */
16
+ autoFocus?: boolean
8
17
  /** Every edit, and `''` when the clear button is pressed. */
9
18
  onChangeText?: (text: string) => void
19
+ /** Called with the query when the keyboard's Search key is pressed. The keyboard goes down after it. */
20
+ onSubmit?: (text: string) => void
10
21
  /** Shown while the field is empty. `Search` by default. */
11
22
  placeholder?: string
12
23
  /** Test identifier, put on the field's container view rather than on the `TextInput`. */
@@ -7,25 +7,45 @@ import {useHostGuard} from '../../host-context'
7
7
  import type {StepperComponent, StepperProps} from './stepper.types'
8
8
 
9
9
  export const Stepper: StepperComponent = ({
10
+ decrementLabel = 'Decrement',
11
+ disabled = false,
12
+ incrementLabel = 'Increment',
10
13
  label,
11
14
  max = Number.MAX_SAFE_INTEGER,
12
15
  min = Number.MIN_SAFE_INTEGER,
13
16
  onValueChange,
14
17
  step = 1,
18
+ testID,
15
19
  value,
16
20
  }: StepperProps) => {
17
21
  useHostGuard('Stepper')
18
22
 
19
23
  const onDecrement = useCallback(() => onValueChange(Math.max(min, value - step)), [min, onValueChange, step, value])
20
24
  const onIncrement = useCallback(() => onValueChange(Math.min(max, value + step)), [max, onValueChange, step, value])
25
+ const color = disabled ? 'content.tertiary' : 'content.primary'
21
26
 
22
27
  return (
23
28
  <HStack align="center" spacing="s8" wide>
24
- <Text>{label}</Text>
29
+ <Text color={color}>{label}</Text>
25
30
  <Spacer />
26
- <GlyphButton accessibilityLabel="Decrement" disabled={value <= min} name="minus" onPress={onDecrement} />
27
- <Text variant="labelMedium">{String(value)}</Text>
28
- <GlyphButton accessibilityLabel="Increment" disabled={value >= max} name="plus" onPress={onIncrement} />
31
+ {/* The control in a row of its own, the same gap apart, so the id lands on it and not on the label. */}
32
+ <HStack align="center" spacing="s8" testID={testID}>
33
+ <GlyphButton
34
+ accessibilityLabel={decrementLabel}
35
+ disabled={disabled || value <= min}
36
+ name="minus"
37
+ onPress={onDecrement}
38
+ />
39
+ <Text color={color} variant="labelMedium">
40
+ {String(value)}
41
+ </Text>
42
+ <GlyphButton
43
+ accessibilityLabel={incrementLabel}
44
+ disabled={disabled || value >= max}
45
+ name="plus"
46
+ onPress={onIncrement}
47
+ />
48
+ </HStack>
29
49
  </HStack>
30
50
  )
31
51
  }
@@ -1,5 +1,5 @@
1
1
  import {Stepper as SwiftUIStepper} from '@expo/ui/swift-ui'
2
- import {labelsHidden} from '@expo/ui/swift-ui/modifiers'
2
+ import {disabled as disabledModifier, labelsHidden} from '@expo/ui/swift-ui/modifiers'
3
3
  import {useReducedMotion} from '@hzblj/kara-theme'
4
4
  import {useRef} from 'react'
5
5
 
@@ -12,12 +12,24 @@ import type {StepperComponent, StepperProps} from './stepper.types'
12
12
  /** The label stays VoiceOver's, on the control; on screen it is the row's own text. */
13
13
  const LABEL_HIDDEN = [labelsHidden()]
14
14
 
15
+ const LABEL_HIDDEN_DISABLED = [labelsHidden(), disabledModifier(true)]
16
+
15
17
  /**
16
18
  * SwiftUI's `Stepper` at the end of a row, the label leading and the value just before the control — the way iOS's own
17
19
  * settings show a stepped value. The value rolls to the next one (`numericText`), up or down with the change, in
18
- * monospaced digits so the row does not jump. With motion reduced it cross-fades instead.
20
+ * monospaced digits so the row does not jump. With motion reduced it cross-fades instead. Its buttons are labelled by
21
+ * the system, so `decrementLabel` and `incrementLabel` are Android's alone.
19
22
  */
20
- export const Stepper: StepperComponent = ({label, max, min, onValueChange, step = 1, value}: StepperProps) => {
23
+ export const Stepper: StepperComponent = ({
24
+ disabled = false,
25
+ label,
26
+ max,
27
+ min,
28
+ onValueChange,
29
+ step = 1,
30
+ testID,
31
+ value,
32
+ }: StepperProps) => {
21
33
  useHostGuard('Stepper')
22
34
  const reduced = useReducedMotion()
23
35
  const previous = useRef(value)
@@ -26,10 +38,10 @@ export const Stepper: StepperComponent = ({label, max, min, onValueChange, step
26
38
 
27
39
  return (
28
40
  <HStack align="center" spacing="s12" wide>
29
- <Text>{label}</Text>
41
+ <Text color={disabled ? 'content.tertiary' : 'content.primary'}>{label}</Text>
30
42
  <Spacer />
31
43
  <Text
32
- color="content.secondary"
44
+ color={disabled ? 'content.tertiary' : 'content.secondary'}
33
45
  ios={{
34
46
  animation: {value},
35
47
  contentTransition: reduced ? {type: 'opacity'} : {countsDown, type: 'numericText'},
@@ -38,13 +50,16 @@ export const Stepper: StepperComponent = ({label, max, min, onValueChange, step
38
50
  >
39
51
  {String(value)}
40
52
  </Text>
53
+ {/* iOS 27 stops a disabled stepper but draws its buttons at full strength. Pinned to its value, UIKit greys
54
+ both buttons, as it greys one at a limit. */}
41
55
  <SwiftUIStepper
42
56
  label={label}
43
- max={max}
44
- min={min}
45
- modifiers={LABEL_HIDDEN}
57
+ max={disabled ? value : max}
58
+ min={disabled ? value : min}
59
+ modifiers={disabled ? LABEL_HIDDEN_DISABLED : LABEL_HIDDEN}
46
60
  onValueChange={onValueChange}
47
61
  step={step}
62
+ testID={testID}
48
63
  value={value}
49
64
  />
50
65
  </HStack>
@@ -5,6 +5,18 @@ import type {ReactNode} from 'react'
5
5
  * two icon buttons in a row. A native atom: it renders inside `<Screen>` or `<Island>`.
6
6
  */
7
7
  export type StepperProps = {
8
+ /**
9
+ * What TalkBack says for the minus button. `Decrement` by default: pass the app's own words, in its language.
10
+ * Android only. iOS ignores it: its native stepper labels its buttons itself, in the device's language.
11
+ */
12
+ decrementLabel?: string
13
+ /** Stops the user changing the value, and dims the row. */
14
+ disabled?: boolean
15
+ /**
16
+ * What TalkBack says for the plus button. `Increment` by default: pass the app's own words, in its language. Android
17
+ * only. iOS ignores it: its native stepper labels its buttons itself, in the device's language.
18
+ */
19
+ incrementLabel?: string
8
20
  /** The leading text. The value is shown too: before the control on iOS, between the buttons on Android. */
9
21
  label: string
10
22
  /** The highest value. When unset, Android has no upper limit and iOS stops at `100`. */
@@ -15,6 +27,11 @@ export type StepperProps = {
15
27
  onValueChange: (value: number) => void
16
28
  /** How much one press adds or takes away. `1` by default. */
17
29
  step?: number
30
+ /**
31
+ * Test identifier, on the control rather than the row: SwiftUI's `Stepper` on iOS, the minus and plus buttons with
32
+ * the value between them on Android. Reach a button by what screen readers say for it.
33
+ */
34
+ testID?: string
18
35
  /** The current value. Update it from `onValueChange`. iOS's native stepper counts in whole numbers. */
19
36
  value: number
20
37
  }