@hzblj/kara-components 0.17.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 (205) hide show
  1. package/CHANGELOG.md +49 -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/progress/progress.android.js +21 -7
  41. package/lib/module/atoms/progress/progress.android.js.map +1 -1
  42. package/lib/module/atoms/progress/progress.ios.js +5 -1
  43. package/lib/module/atoms/progress/progress.ios.js.map +1 -1
  44. package/lib/module/atoms/slider/slider.android.js +16 -2
  45. package/lib/module/atoms/slider/slider.android.js.map +1 -1
  46. package/lib/module/atoms/slider/slider.ios.js +22 -2
  47. package/lib/module/atoms/slider/slider.ios.js.map +1 -1
  48. package/lib/module/atoms/switch/switch.android.js +5 -1
  49. package/lib/module/atoms/switch/switch.android.js.map +1 -1
  50. package/lib/module/atoms/switch/switch.ios.js +3 -1
  51. package/lib/module/atoms/switch/switch.ios.js.map +1 -1
  52. package/lib/module/atoms/text/spans.js +2 -1
  53. package/lib/module/atoms/text/spans.js.map +1 -1
  54. package/lib/module/atoms/text/text.android.js +18 -6
  55. package/lib/module/atoms/text/text.android.js.map +1 -1
  56. package/lib/module/atoms/text/text.ios.js +6 -2
  57. package/lib/module/atoms/text/text.ios.js.map +1 -1
  58. package/lib/module/atoms/text-field/text-field-input.js +14 -0
  59. package/lib/module/atoms/text-field/text-field-input.js.map +1 -0
  60. package/lib/module/atoms/text-field/text-field.android.js +113 -6
  61. package/lib/module/atoms/text-field/text-field.android.js.map +1 -1
  62. package/lib/module/atoms/text-field/text-field.ios.js +75 -3
  63. package/lib/module/atoms/text-field/text-field.ios.js.map +1 -1
  64. package/lib/module/molecules/collapsible/collapsible.android.js +4 -3
  65. package/lib/module/molecules/collapsible/collapsible.android.js.map +1 -1
  66. package/lib/module/molecules/collapsible/collapsible.ios.js +3 -1
  67. package/lib/module/molecules/collapsible/collapsible.ios.js.map +1 -1
  68. package/lib/module/molecules/radio-group/radio-group.android.js +10 -2
  69. package/lib/module/molecules/radio-group/radio-group.android.js.map +1 -1
  70. package/lib/module/molecules/radio-group/radio-group.ios.js +6 -2
  71. package/lib/module/molecules/radio-group/radio-group.ios.js.map +1 -1
  72. package/lib/module/molecules/search-bar/search-bar.js +9 -0
  73. package/lib/module/molecules/search-bar/search-bar.js.map +1 -1
  74. package/lib/module/molecules/stepper/stepper.android.js +25 -13
  75. package/lib/module/molecules/stepper/stepper.android.js.map +1 -1
  76. package/lib/module/molecules/stepper/stepper.ios.js +12 -6
  77. package/lib/module/molecules/stepper/stepper.ios.js.map +1 -1
  78. package/lib/module/organisms/action-sheet/action-sheet.android.js +16 -8
  79. package/lib/module/organisms/action-sheet/action-sheet.android.js.map +1 -1
  80. package/lib/module/organisms/action-sheet/action-sheet.ios.js +16 -3
  81. package/lib/module/organisms/action-sheet/action-sheet.ios.js.map +1 -1
  82. package/lib/module/organisms/alert/alert.android.js +4 -3
  83. package/lib/module/organisms/alert/alert.android.js.map +1 -1
  84. package/lib/module/organisms/alert/alert.ios.js +3 -2
  85. package/lib/module/organisms/alert/alert.ios.js.map +1 -1
  86. package/lib/module/organisms/context-menu/context-menu.android.js +3 -1
  87. package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
  88. package/lib/module/organisms/context-menu/context-menu.ios.js +1 -0
  89. package/lib/module/organisms/context-menu/context-menu.ios.js.map +1 -1
  90. package/lib/module/organisms/fab-menu/fab-menu.android.js +3 -0
  91. package/lib/module/organisms/fab-menu/fab-menu.android.js.map +1 -1
  92. package/lib/module/organisms/fab-menu/fab-menu.ios.js +3 -0
  93. package/lib/module/organisms/fab-menu/fab-menu.ios.js.map +1 -1
  94. package/lib/module/organisms/menu/menu-parts.js +20 -0
  95. package/lib/module/organisms/menu/menu-parts.js.map +1 -0
  96. package/lib/module/organisms/menu/menu.android.js +51 -19
  97. package/lib/module/organisms/menu/menu.android.js.map +1 -1
  98. package/lib/module/organisms/menu/menu.ios.js +71 -8
  99. package/lib/module/organisms/menu/menu.ios.js.map +1 -1
  100. package/lib/module/organisms/sheet/sheet.js +2 -1
  101. package/lib/module/organisms/sheet/sheet.js.map +1 -1
  102. package/lib/typescript/atoms/badge/badge.types.d.ts +2 -0
  103. package/lib/typescript/atoms/button/button-tokens.d.ts +6 -2
  104. package/lib/typescript/atoms/button/button.types.d.ts +19 -5
  105. package/lib/typescript/atoms/checkbox/checkbox.types.d.ts +2 -0
  106. package/lib/typescript/atoms/color-picker/color-picker.types.d.ts +2 -0
  107. package/lib/typescript/atoms/date-picker/date-picker.ios.d.ts +5 -5
  108. package/lib/typescript/atoms/date-picker/date-picker.types.d.ts +30 -7
  109. package/lib/typescript/atoms/divider/divider.ios.d.ts +4 -0
  110. package/lib/typescript/atoms/divider/divider.types.d.ts +13 -2
  111. package/lib/typescript/atoms/fab/fab.types.d.ts +21 -4
  112. package/lib/typescript/atoms/icon-button/icon-button.types.d.ts +23 -4
  113. package/lib/typescript/atoms/list/list.types.d.ts +15 -1
  114. package/lib/typescript/atoms/progress/progress.types.d.ts +13 -2
  115. package/lib/typescript/atoms/slider/slider.ios.d.ts +4 -0
  116. package/lib/typescript/atoms/slider/slider.types.d.ts +7 -0
  117. package/lib/typescript/atoms/switch/switch.types.d.ts +2 -0
  118. package/lib/typescript/atoms/text/spans.d.ts +2 -0
  119. package/lib/typescript/atoms/text/text.types.d.ts +9 -1
  120. package/lib/typescript/atoms/text-field/text-field-input.d.ts +7 -0
  121. package/lib/typescript/atoms/text-field/text-field.types.d.ts +77 -4
  122. package/lib/typescript/molecules/collapsible/collapsible.types.d.ts +2 -0
  123. package/lib/typescript/molecules/radio-group/radio-group.types.d.ts +4 -0
  124. package/lib/typescript/molecules/search-bar/search-bar.types.d.ts +11 -1
  125. package/lib/typescript/molecules/stepper/stepper.ios.d.ts +2 -1
  126. package/lib/typescript/molecules/stepper/stepper.types.d.ts +17 -0
  127. package/lib/typescript/organisms/action-sheet/action-sheet.ios.d.ts +4 -0
  128. package/lib/typescript/organisms/action-sheet/action-sheet.types.d.ts +8 -3
  129. package/lib/typescript/organisms/alert/alert.types.d.ts +8 -3
  130. package/lib/typescript/organisms/menu/menu-parts.d.ts +14 -0
  131. package/lib/typescript/organisms/menu/menu.types.d.ts +35 -7
  132. package/lib/typescript/organisms/sheet/sheet.types.d.ts +7 -1
  133. package/package.json +4 -4
  134. package/src/atoms/badge/badge.android.tsx +9 -2
  135. package/src/atoms/badge/badge.ios.tsx +2 -2
  136. package/src/atoms/badge/badge.types.ts +2 -0
  137. package/src/atoms/button/button-tokens.ts +6 -3
  138. package/src/atoms/button/button.android.tsx +35 -11
  139. package/src/atoms/button/button.ios.tsx +72 -8
  140. package/src/atoms/button/button.types.ts +20 -5
  141. package/src/atoms/checkbox/checkbox.android.tsx +5 -2
  142. package/src/atoms/checkbox/checkbox.ios.tsx +2 -2
  143. package/src/atoms/checkbox/checkbox.types.ts +2 -0
  144. package/src/atoms/color-picker/color-picker.android.tsx +10 -2
  145. package/src/atoms/color-picker/color-picker.ios.tsx +24 -1
  146. package/src/atoms/color-picker/color-picker.types.ts +2 -0
  147. package/src/atoms/date-picker/date-picker.android.tsx +86 -16
  148. package/src/atoms/date-picker/date-picker.ios.tsx +20 -8
  149. package/src/atoms/date-picker/date-picker.types.ts +31 -7
  150. package/src/atoms/divider/divider.android.tsx +14 -3
  151. package/src/atoms/divider/divider.ios.tsx +4 -0
  152. package/src/atoms/divider/divider.types.ts +14 -2
  153. package/src/atoms/fab/fab.android.tsx +61 -11
  154. package/src/atoms/fab/fab.ios.tsx +48 -13
  155. package/src/atoms/fab/fab.types.ts +22 -4
  156. package/src/atoms/icon-button/icon-button.android.tsx +60 -10
  157. package/src/atoms/icon-button/icon-button.ios.tsx +77 -8
  158. package/src/atoms/icon-button/icon-button.types.ts +24 -4
  159. package/src/atoms/list/list.android.tsx +26 -4
  160. package/src/atoms/list/list.ios.tsx +20 -5
  161. package/src/atoms/list/list.types.ts +16 -1
  162. package/src/atoms/progress/progress.android.tsx +32 -6
  163. package/src/atoms/progress/progress.ios.tsx +11 -5
  164. package/src/atoms/progress/progress.types.ts +13 -2
  165. package/src/atoms/slider/slider.android.tsx +18 -2
  166. package/src/atoms/slider/slider.ios.tsx +38 -2
  167. package/src/atoms/slider/slider.types.ts +7 -0
  168. package/src/atoms/switch/switch.android.tsx +5 -2
  169. package/src/atoms/switch/switch.ios.tsx +2 -2
  170. package/src/atoms/switch/switch.types.ts +2 -0
  171. package/src/atoms/text/spans.ts +2 -1
  172. package/src/atoms/text/text.android.tsx +20 -7
  173. package/src/atoms/text/text.ios.tsx +7 -0
  174. package/src/atoms/text/text.types.ts +10 -1
  175. package/src/atoms/text-field/text-field-input.ts +17 -0
  176. package/src/atoms/text-field/text-field.android.tsx +132 -9
  177. package/src/atoms/text-field/text-field.ios.tsx +110 -6
  178. package/src/atoms/text-field/text-field.types.ts +98 -4
  179. package/src/molecules/collapsible/collapsible.android.tsx +10 -2
  180. package/src/molecules/collapsible/collapsible.ios.tsx +8 -2
  181. package/src/molecules/collapsible/collapsible.types.ts +2 -0
  182. package/src/molecules/radio-group/radio-group.android.tsx +18 -4
  183. package/src/molecules/radio-group/radio-group.ios.tsx +19 -5
  184. package/src/molecules/radio-group/radio-group.types.ts +4 -0
  185. package/src/molecules/search-bar/search-bar.tsx +9 -0
  186. package/src/molecules/search-bar/search-bar.types.ts +12 -1
  187. package/src/molecules/stepper/stepper.android.tsx +24 -4
  188. package/src/molecules/stepper/stepper.ios.tsx +23 -8
  189. package/src/molecules/stepper/stepper.types.ts +17 -0
  190. package/src/organisms/action-sheet/action-sheet.android.tsx +23 -11
  191. package/src/organisms/action-sheet/action-sheet.ios.tsx +20 -3
  192. package/src/organisms/action-sheet/action-sheet.types.ts +8 -3
  193. package/src/organisms/alert/alert.android.tsx +11 -8
  194. package/src/organisms/alert/alert.ios.tsx +2 -1
  195. package/src/organisms/alert/alert.types.ts +8 -3
  196. package/src/organisms/context-menu/context-menu.android.tsx +3 -2
  197. package/src/organisms/context-menu/context-menu.ios.tsx +1 -0
  198. package/src/organisms/fab-menu/fab-menu.android.tsx +3 -0
  199. package/src/organisms/fab-menu/fab-menu.ios.tsx +9 -1
  200. package/src/organisms/menu/menu-parts.ts +21 -0
  201. package/src/organisms/menu/menu.android.tsx +39 -10
  202. package/src/organisms/menu/menu.ios.tsx +55 -5
  203. package/src/organisms/menu/menu.types.ts +30 -7
  204. package/src/organisms/sheet/sheet.tsx +2 -1
  205. package/src/organisms/sheet/sheet.types.ts +7 -1
@@ -1,25 +1,140 @@
1
- import {Column, Text as ComposeText, OutlinedTextField, useNativeState} from '@expo/ui/jetpack-compose'
1
+ import {
2
+ Column,
3
+ Text as ComposeText,
4
+ type TextFieldRef as ComposeTextFieldRef,
5
+ OutlinedTextField,
6
+ type TextFieldKeyboardActions,
7
+ type TextFieldKeyboardOptions,
8
+ type TextFieldKeyboardType,
9
+ useNativeState,
10
+ } from '@expo/ui/jetpack-compose'
11
+ import {semantics, testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
2
12
  import {useSxModifiers} from '@hzblj/kara-sx'
3
13
  import {useAppTheme} from '@hzblj/kara-theme'
14
+ import {useImperativeHandle, useMemo, useRef} from 'react'
4
15
  import {Glyph} from '../../glyph'
5
16
  import {useHostGuard} from '../../host-context'
6
- import type {TextFieldComponent, TextFieldProps} from './text-field.types'
17
+ import type {TextFieldComponent, TextFieldContentType, TextFieldKeyboard, TextFieldProps} from './text-field.types'
18
+ import {autoCorrectFor, capitalizationFor, ignoreDetachedView} from './text-field-input'
19
+
20
+ const KEYBOARD = {
21
+ decimal: 'decimal',
22
+ default: 'text',
23
+ email: 'email',
24
+ number: 'number',
25
+ phone: 'phone',
26
+ url: 'uri',
27
+ } as const satisfies Record<TextFieldKeyboard, TextFieldKeyboardType>
28
+
29
+ /**
30
+ * A `secure` field asks for the password keyboards: they suggest nothing and learn nothing, and they are what Android's
31
+ * autofill takes for a password field.
32
+ */
33
+ const SECURE_KEYBOARD = {
34
+ ...KEYBOARD,
35
+ default: 'password',
36
+ number: 'numberPassword',
37
+ } as const satisfies Record<TextFieldKeyboard, TextFieldKeyboardType>
38
+
39
+ /** Compose's autofill `ContentType`s, by the hint names `@expo/ui`'s `semantics` modifier maps to them. */
40
+ const AUTOFILL_HINT = {
41
+ addressCity: 'postal-address-locality',
42
+ countryName: 'country',
43
+ creditCardNumber: 'cc-number',
44
+ emailAddress: 'email',
45
+ familyName: 'family-name',
46
+ fullStreetAddress: 'street-address',
47
+ givenName: 'given-name',
48
+ name: 'name',
49
+ newPassword: 'new-password',
50
+ oneTimeCode: 'one-time-code',
51
+ password: 'password',
52
+ postalCode: 'postal-code',
53
+ streetAddressLine1: 'address-line1',
54
+ streetAddressLine2: 'address-line2',
55
+ telephoneNumber: 'tel',
56
+ username: 'username',
57
+ } as const satisfies Record<TextFieldContentType, string>
7
58
 
8
59
  export const TextField: TextFieldComponent = ({
60
+ autoCapitalize,
61
+ autoCorrect,
62
+ autoFocus = false,
63
+ contentType,
9
64
  defaultValue = '',
10
65
  disabled = false,
11
66
  error = false,
67
+ keyboard = 'default',
12
68
  label,
69
+ maxLength,
13
70
  multiline = false,
14
71
  onChangeText,
72
+ onFocusChange,
73
+ onSubmit,
74
+ placeholder,
75
+ ref,
15
76
  secure = false,
77
+ submitLabel,
78
+ testID,
16
79
  }: TextFieldProps) => {
17
80
  useHostGuard('TextField')
18
81
  const theme = useAppTheme()
19
82
  const value = useNativeState(defaultValue)
20
- const modifiers = useSxModifiers({wide: true})
83
+ const field = useRef<ComposeTextFieldRef>(null)
84
+ const sxModifiers = useSxModifiers({wide: true})
21
85
  const invalid = Boolean(error)
22
86
  const message = typeof error === 'string' && error ? error : undefined
87
+ const face = {fontFamily: theme.typography.body.fontFamily, fontWeight: theme.typography.body.fontWeight}
88
+ const capitalization = capitalizationFor(keyboard, autoCapitalize) ?? 'none'
89
+ const corrects = !secure && autoCorrectFor(keyboard, autoCorrect)
90
+ const singleLine = !multiline
91
+ const submits = singleLine && Boolean(onSubmit)
92
+
93
+ useImperativeHandle(
94
+ ref,
95
+ () => ({
96
+ blur: () => field.current?.blur().catch(ignoreDetachedView),
97
+ // The field reports no text set from code, so the change is reported here.
98
+ clear: () => {
99
+ field.current?.clear().catch(ignoreDetachedView)
100
+ onChangeText?.('')
101
+ },
102
+ focus: () => field.current?.focus().catch(ignoreDetachedView),
103
+ }),
104
+ [onChangeText]
105
+ )
106
+
107
+ const modifiers = useMemo(
108
+ () => [
109
+ ...sxModifiers,
110
+ ...(testID ? [testIdModifier(testID)] : []),
111
+ ...(contentType ? [semantics({contentType: AUTOFILL_HINT[contentType]})] : []),
112
+ ],
113
+ [contentType, sxModifiers, testID]
114
+ )
115
+
116
+ /**
117
+ * A multiline field keeps the default action, a return key that starts a new line. A single-line one asked for
118
+ * `onSubmit` without a `submitLabel` gets `done`: Compose only runs the keyboard action it was given.
119
+ */
120
+ const keyboardOptions = useMemo<TextFieldKeyboardOptions>(
121
+ () => ({
122
+ autoCorrectEnabled: corrects,
123
+ capitalization,
124
+ imeAction: singleLine ? (submitLabel ?? (submits ? 'done' : 'default')) : 'default',
125
+ keyboardType: (secure ? SECURE_KEYBOARD : KEYBOARD)[keyboard],
126
+ }),
127
+ [capitalization, corrects, keyboard, secure, singleLine, submitLabel, submits]
128
+ )
129
+
130
+ // Only the action matching `imeAction` ever fires, so each one is the submit.
131
+ const keyboardActions = useMemo<TextFieldKeyboardActions | undefined>(
132
+ () =>
133
+ submits && onSubmit
134
+ ? {onDone: onSubmit, onGo: onSubmit, onNext: onSubmit, onSearch: onSubmit, onSend: onSubmit}
135
+ : undefined,
136
+ [onSubmit, submits]
137
+ )
23
138
 
24
139
  return (
25
140
  <Column verticalArrangement={{spacedBy: 4}}>
@@ -32,19 +147,27 @@ export const TextField: TextFieldComponent = ({
32
147
  </ComposeText>
33
148
  ) : null}
34
149
  <OutlinedTextField
150
+ autoFocus={autoFocus}
35
151
  enabled={!disabled}
36
152
  isError={invalid}
153
+ keyboardActions={keyboardActions}
154
+ keyboardOptions={keyboardOptions}
155
+ maxLength={maxLength}
37
156
  modifiers={modifiers}
157
+ onFocusChanged={onFocusChange}
38
158
  onValueChange={onChangeText}
39
- singleLine={!multiline}
40
- textStyle={{
41
- color: theme.colorString('content.primary'),
42
- fontFamily: theme.typography.body.fontFamily,
43
- fontWeight: theme.typography.body.fontWeight,
44
- }}
159
+ ref={field}
160
+ singleLine={singleLine}
161
+ textStyle={{color: theme.colorString('content.primary'), ...face}}
45
162
  value={value}
46
163
  visualTransformation={secure ? 'password' : 'none'}
47
164
  >
165
+ {placeholder ? (
166
+ // No colour of its own: Material's placeholder colour comes down to it, dimmed with the field.
167
+ <OutlinedTextField.Placeholder>
168
+ <ComposeText style={face}>{placeholder}</ComposeText>
169
+ </OutlinedTextField.Placeholder>
170
+ ) : null}
48
171
  {invalid ? (
49
172
  <OutlinedTextField.TrailingIcon>
50
173
  <Glyph color="content.destructive" name="error" />
@@ -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
  />