@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,7 +1,8 @@
1
1
  import {Slider as ComposeSlider} from '@expo/ui/jetpack-compose'
2
+ import {testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
2
3
  import {haptics} from '@hzblj/expo-rich-haptics'
3
4
  import {useAppTheme} from '@hzblj/kara-theme'
4
- import {useCallback, useEffect, useRef} from 'react'
5
+ import {useCallback, useEffect, useMemo, useRef} from 'react'
5
6
 
6
7
  import {useHostGuard} from '../../host-context'
7
8
  import type {SliderComponent, SliderProps} from './slider.types'
@@ -9,14 +10,17 @@ import type {SliderComponent, SliderProps} from './slider.types'
9
10
  /**
10
11
  * Material's slider. `step` is the increment, as SwiftUI takes it; Compose counts the stops between the two ends
11
12
  * instead, so it is converted. A stepped slider ticks at every stop the thumb reaches — Compose's Material slider
12
- * plays nothing of its own, where iOS 26's slider already does.
13
+ * plays nothing of its own, where iOS 26's slider already does. `onValueChangeEnd` is Material's
14
+ * `onValueChangeFinished`, which reports no value: it gets the last one the drag reported.
13
15
  */
14
16
  export const Slider: SliderComponent = ({
15
17
  disabled = false,
16
18
  max = 1,
17
19
  min = 0,
18
20
  onValueChange,
21
+ onValueChangeEnd,
19
22
  step,
23
+ testID,
20
24
  value,
21
25
  }: SliderProps) => {
22
26
  useHostGuard('Slider')
@@ -27,6 +31,7 @@ export const Slider: SliderComponent = ({
27
31
  // Compose reports faster than React re-renders the controlled value.
28
32
  const stopOf = useCallback((at: number) => (step ? Math.round((at - min) / step) : 0), [min, step])
29
33
  const stop = useRef(stopOf(value))
34
+ const dragged = useRef<number | undefined>(undefined)
30
35
 
31
36
  useEffect(() => {
32
37
  stop.current = stopOf(value)
@@ -41,11 +46,20 @@ export const Slider: SliderComponent = ({
41
46
  }
42
47
 
43
48
  stop.current = reached
49
+ dragged.current = next
44
50
  onValueChange?.(next)
45
51
  },
46
52
  [onValueChange, stopOf, steps]
47
53
  )
48
54
 
55
+ const onFinished = useCallback(() => {
56
+ const final = dragged.current ?? value
57
+ dragged.current = undefined
58
+ onValueChangeEnd?.(final)
59
+ }, [onValueChangeEnd, value])
60
+
61
+ const modifiers = useMemo(() => (testID ? [testIdModifier(testID)] : undefined), [testID])
62
+
49
63
  return (
50
64
  <ComposeSlider
51
65
  colors={{
@@ -55,7 +69,9 @@ export const Slider: SliderComponent = ({
55
69
  enabled={!disabled}
56
70
  max={max}
57
71
  min={min}
72
+ modifiers={modifiers}
58
73
  onValueChange={onChange}
74
+ onValueChangeFinished={onValueChangeEnd ? onFinished : undefined}
59
75
  steps={steps}
60
76
  value={value}
61
77
  />
@@ -1,21 +1,28 @@
1
1
  import {Slider as SwiftUISlider} from '@expo/ui/swift-ui'
2
2
  import {disabled as disabledModifier, tint} from '@expo/ui/swift-ui/modifiers'
3
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
- import {useMemo} from 'react'
4
+ import {useCallback, useMemo, useRef} from 'react'
5
5
 
6
6
  import {useHostGuard} from '../../host-context'
7
7
  import type {SliderComponent, SliderProps} from './slider.types'
8
8
 
9
+ /**
10
+ * SwiftUI's slider. `onValueChangeEnd` is its `onEditingChanged(false)`, with the last value the drag reported: the
11
+ * controlled `value` can still be a render behind when the finger lifts.
12
+ */
9
13
  export const Slider: SliderComponent = ({
10
14
  disabled = false,
11
15
  max = 1,
12
16
  min = 0,
13
17
  onValueChange,
18
+ onValueChangeEnd,
14
19
  step,
20
+ testID,
15
21
  value,
16
22
  }: SliderProps) => {
17
23
  useHostGuard('Slider')
18
24
  const theme = useAppTheme()
25
+ const dragged = useRef<number | undefined>(undefined)
19
26
 
20
27
  const modifiers = useMemo(() => {
21
28
  const list = [tint(theme.color('fill.accent.primary'))]
@@ -27,7 +34,36 @@ export const Slider: SliderComponent = ({
27
34
  return list
28
35
  }, [disabled, theme])
29
36
 
37
+ const onChange = useCallback(
38
+ (next: number) => {
39
+ dragged.current = next
40
+ onValueChange?.(next)
41
+ },
42
+ [onValueChange]
43
+ )
44
+
45
+ const onEditingChanged = useCallback(
46
+ (editing: boolean) => {
47
+ const final = dragged.current ?? value
48
+ dragged.current = undefined
49
+
50
+ if (!editing) {
51
+ onValueChangeEnd?.(final)
52
+ }
53
+ },
54
+ [onValueChangeEnd, value]
55
+ )
56
+
30
57
  return (
31
- <SwiftUISlider max={max} min={min} modifiers={modifiers} onValueChange={onValueChange} step={step} value={value} />
58
+ <SwiftUISlider
59
+ max={max}
60
+ min={min}
61
+ modifiers={modifiers}
62
+ onEditingChanged={onValueChangeEnd ? onEditingChanged : undefined}
63
+ onValueChange={onChange}
64
+ step={step}
65
+ testID={testID}
66
+ value={value}
67
+ />
32
68
  )
33
69
  }
@@ -10,8 +10,15 @@ export type SliderProps = {
10
10
  min?: number
11
11
  /** Called with the new value as the thumb moves. */
12
12
  onValueChange?: (value: number) => void
13
+ /**
14
+ * Called once with the final value when the user lets go of the slider: where a seek or a volume commits, rather
15
+ * than at every step of the drag.
16
+ */
17
+ onValueChangeEnd?: (value: number) => void
13
18
  /** The increment between stops; leave it unset for a continuous slider. On Android every stop plays a `tick`. */
14
19
  step?: number
20
+ /** Test identifier, forwarded to the native view. */
21
+ testID?: string
15
22
  /** Between `min` and `max`. Controlled: pass back what `onValueChange` reports. */
16
23
  value: number
17
24
  }
@@ -1,5 +1,5 @@
1
1
  import {Switch as ComposeSwitch, Row, Text} from '@expo/ui/jetpack-compose'
2
- import {clickable, fillMaxWidth} from '@expo/ui/jetpack-compose/modifiers'
2
+ import {clickable, fillMaxWidth, testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
3
3
  import {haptics} from '@hzblj/expo-rich-haptics'
4
4
  import {useAppTheme} from '@hzblj/kara-theme'
5
5
  import {useCallback, useMemo} from 'react'
@@ -16,7 +16,7 @@ const NOOP = () => undefined
16
16
  * `toggleOn`/`toggleOff` — Android 14's constants made for switches — since Compose's Material switch plays nothing
17
17
  * of its own. iOS stays silent, as `UISwitch` does.
18
18
  */
19
- export const Switch: SwitchComponent = ({disabled = false, label, onValueChange, value}: SwitchProps) => {
19
+ export const Switch: SwitchComponent = ({disabled = false, label, onValueChange, testID, value}: SwitchProps) => {
20
20
  useHostGuard('Switch')
21
21
  const theme = useAppTheme()
22
22
  const toggle = useCallback(
@@ -30,6 +30,8 @@ export const Switch: SwitchComponent = ({disabled = false, label, onValueChange,
30
30
  () => (disabled || !onValueChange ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => toggle(!value))]),
31
31
  [disabled, onValueChange, toggle, value]
32
32
  )
33
+ // On the switch, not the row: the switch is its own toggleable node inside the row, and the one a test reads.
34
+ const controlModifiers = useMemo(() => (testID ? [testIdModifier(testID)] : undefined), [testID])
33
35
  const control = (
34
36
  <ComposeSwitch
35
37
  colors={{
@@ -38,6 +40,7 @@ export const Switch: SwitchComponent = ({disabled = false, label, onValueChange,
38
40
  checkedTrackColor: theme.color('fill.accent.primary'),
39
41
  }}
40
42
  enabled={!disabled}
43
+ modifiers={controlModifiers}
41
44
  onCheckedChange={onValueChange ? toggle : NOOP}
42
45
  value={value}
43
46
  />
@@ -6,7 +6,7 @@ import {useMemo} from 'react'
6
6
  import {useHostGuard} from '../../host-context'
7
7
  import type {SwitchComponent, SwitchProps} from './switch.types'
8
8
 
9
- export const Switch: SwitchComponent = ({disabled = false, label, onValueChange, value}: SwitchProps) => {
9
+ export const Switch: SwitchComponent = ({disabled = false, label, onValueChange, testID, value}: SwitchProps) => {
10
10
  useHostGuard('Switch')
11
11
  const theme = useAppTheme()
12
12
 
@@ -20,5 +20,5 @@ export const Switch: SwitchComponent = ({disabled = false, label, onValueChange,
20
20
  return list
21
21
  }, [disabled, theme])
22
22
 
23
- return <Toggle isOn={value} label={label} modifiers={modifiers} onIsOnChange={onValueChange} />
23
+ return <Toggle isOn={value} label={label} modifiers={modifiers} onIsOnChange={onValueChange} testID={testID} />
24
24
  }
@@ -11,6 +11,8 @@ export type SwitchProps = {
11
11
  label?: string
12
12
  /** Called with the new value when the user flips it. On Android a flip plays `toggleOn` / `toggleOff`. */
13
13
  onValueChange?: (value: boolean) => void
14
+ /** Test identifier, forwarded to the native switch (on Android the switch itself, not the labelled row). */
15
+ testID?: string
14
16
  /** Whether it is on. Controlled: pass back what `onValueChange` reports. */
15
17
  value: boolean
16
18
  }
@@ -4,7 +4,8 @@ import {Children, isValidElement, type ReactNode} from 'react'
4
4
  /** A run of the text: a string child, or a nested `Text` with a colour of its own. */
5
5
  export type TextSpan = {color?: ColorToken; text: string}
6
6
 
7
- const textOf = (children: ReactNode): string =>
7
+ /** The string and number children, joined: the text of a `Text` without spans, however JSX split it. */
8
+ export const textOf = (children: ReactNode): string =>
8
9
  Children.toArray(children)
9
10
  .map(child => (typeof child === 'string' || typeof child === 'number' ? String(child) : ''))
10
11
  .join('')
@@ -1,6 +1,6 @@
1
1
  import {Text as ComposeText} from '@expo/ui/jetpack-compose'
2
2
  import {testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
3
- import {AnimatedText, type TextAnimation} from '@hzblj/kara-native/jetpack-compose'
3
+ import {AnimatedText, type AnimatedTextOverflow, type TextAnimation} from '@hzblj/kara-native/jetpack-compose'
4
4
  import {
5
5
  animateConfig,
6
6
  type SxAnimateConfig,
@@ -12,8 +12,8 @@ import {duration, easing, springPhysics, useAppTheme} from '@hzblj/kara-theme'
12
12
  import {useMemo} from 'react'
13
13
 
14
14
  import {useHostGuard} from '../../host-context'
15
- import {spansOf} from './spans'
16
- import type {TextComponent, TextDecoration, TextProps, TextTransform} from './text.types'
15
+ import {spansOf, textOf} from './spans'
16
+ import type {TextComponent, TextDecoration, TextProps, TextTransform, TextTruncation} from './text.types'
17
17
 
18
18
  const DECORATION = {
19
19
  none: 'none',
@@ -21,6 +21,12 @@ const DECORATION = {
21
21
  underline: 'underline',
22
22
  } as const
23
23
 
24
+ const OVERFLOW = {
25
+ head: 'startEllipsis',
26
+ middle: 'middleEllipsis',
27
+ tail: 'ellipsis',
28
+ } as const satisfies Record<TextTruncation, AnimatedTextOverflow>
29
+
24
30
  const applyTransform = (children: TextProps['children'], transform: TextTransform | undefined) => {
25
31
  if (!transform || typeof children !== 'string') {
26
32
  return children
@@ -68,6 +74,7 @@ export const Text: TextComponent = ({
68
74
  numberOfLines,
69
75
  testID,
70
76
  transform,
77
+ truncation = 'tail',
71
78
  variant = 'body',
72
79
  ...props
73
80
  }: TextProps) => {
@@ -83,11 +90,15 @@ export const Text: TextComponent = ({
83
90
  )
84
91
  const spans = useMemo(() => spansOf(children, Text), [children])
85
92
  const animate = sx.animate === false ? undefined : sx.animate
93
+ // Compose ellipsizes at the start or in the middle of a single line only, and clips a longer text: that ends at the
94
+ // tail instead.
95
+ const overflow = numberOfLines === 1 ? OVERFLOW[truncation] : 'ellipsis'
86
96
 
87
- // `@expo/ui`'s text takes spans but animates none, and sets no font features: with `animate`, kara's own Compose text
88
- // eases each span's colour, and with `monospacedDigit` asks the face for its tabular figures.
89
- if (animate !== undefined || monospacedDigit) {
90
- const runs = spans ?? [{text: typeof children === 'string' || typeof children === 'number' ? String(children) : ''}]
97
+ // `@expo/ui`'s text takes spans but animates none, sets no font features and ellipsizes only at the end: with
98
+ // `animate`, kara's own Compose text eases each span's colour, with `monospacedDigit` asks the face for its tabular
99
+ // figures, and puts the ellipsis at the start or in the middle of a line.
100
+ if (animate !== undefined || monospacedDigit || overflow !== 'ellipsis') {
101
+ const runs = spans ?? [{text: textOf(children)}]
91
102
 
92
103
  return (
93
104
  <AnimatedText
@@ -106,6 +117,7 @@ export const Text: TextComponent = ({
106
117
  textAlign={align}
107
118
  textColor={theme.colorString(color)}
108
119
  textDecoration={decorationOf(decoration)}
120
+ textOverflow={overflow}
109
121
  />
110
122
  )
111
123
  }
@@ -115,6 +127,7 @@ export const Text: TextComponent = ({
115
127
  color={theme.colorString(color)}
116
128
  maxLines={numberOfLines}
117
129
  modifiers={modifiers}
130
+ overflow="ellipsis"
118
131
  style={{
119
132
  fontFamily: style.fontFamily,
120
133
  fontSize: style.fontSize,
@@ -8,6 +8,7 @@ import {
8
8
  multilineTextAlignment,
9
9
  strikethrough,
10
10
  textCase,
11
+ truncationMode,
11
12
  underline,
12
13
  } from '@expo/ui/swift-ui/modifiers'
13
14
  import {scaledFont} from '@hzblj/kara-native/swift-ui'
@@ -48,6 +49,7 @@ export const Text: TextComponent = ({
48
49
  numberOfLines,
49
50
  testID,
50
51
  transform,
52
+ truncation,
51
53
  variant = 'body',
52
54
  ...props
53
55
  }: TextProps) => {
@@ -99,6 +101,10 @@ export const Text: TextComponent = ({
99
101
  list.push(lineLimit(numberOfLines))
100
102
  }
101
103
 
104
+ if (truncation) {
105
+ list.push(truncationMode(truncation))
106
+ }
107
+
102
108
  return [...list, ...transition, ...sxModifiers, ...accessibilityModifiers]
103
109
  }, [
104
110
  accessibilityModifiers,
@@ -112,6 +118,7 @@ export const Text: TextComponent = ({
112
118
  theme,
113
119
  transform,
114
120
  transition,
121
+ truncation,
115
122
  ])
116
123
 
117
124
  return (
@@ -8,6 +8,9 @@ export type TextDecoration = 'none' | 'strikethrough' | 'underline'
8
8
 
9
9
  export type TextTransform = 'lowercase' | 'uppercase'
10
10
 
11
+ /** Where the ellipsis goes in a text cut short: its start, its middle or its end. */
12
+ export type TextTruncation = 'head' | 'middle' | 'tail'
13
+
11
14
  /**
12
15
  * SwiftUI `Text` / Compose `Text` in a theme typography variant. A native atom: outside a host, use `RnText`. It takes
13
16
  * the native `sx` surface too: padding, a frame (`wide` lines it up by `align`), a background, and the `ios` bucket's
@@ -34,7 +37,7 @@ export type TextProps = ResponsiveSx &
34
37
  color?: ColorToken
35
38
  /** An underline or a strikethrough. */
36
39
  decoration?: TextDecoration
37
- /** The most lines it wraps to. iOS truncates the last with an ellipsis; Android clips it. */
40
+ /** The most lines it wraps to. A text cut short ends with an ellipsis, where `truncation` puts it. */
38
41
  numberOfLines?: number
39
42
  /**
40
43
  * Sets it in the theme's fixed-width face, at the variant's size and weight: the app's `fonts.mono`, else Menlo on
@@ -51,6 +54,12 @@ export type TextProps = ResponsiveSx &
51
54
  testID?: string
52
55
  /** Upper- or lower-cases the text. Android applies it only when `children` is a plain string. */
53
56
  transform?: TextTransform
57
+ /**
58
+ * Where the ellipsis goes in a text cut short. `tail` by default: the end of the last line. `head` keeps the end of
59
+ * the text and `middle` both of its ends, for a file name, a path or an address. Android puts it at the head or in
60
+ * the middle of a single line only (`numberOfLines={1}`), as Compose does; a longer text there ends at the tail.
61
+ */
62
+ truncation?: TextTruncation
54
63
  /** The typography variant. `body` by default. */
55
64
  variant?: TextVariant
56
65
  }
@@ -0,0 +1,17 @@
1
+ import type {TextFieldAutoCapitalize, TextFieldKeyboard} from './text-field.types'
2
+
3
+ /** Keyboards for an address, where a capital or a correction the user did not type would only break it. */
4
+ const ADDRESS_KEYBOARDS: ReadonlySet<TextFieldKeyboard> = new Set(['email', 'url'])
5
+
6
+ /** The capitalisation asked for, else `none` for an address; `undefined` leaves the platform's own. */
7
+ export const capitalizationFor = (
8
+ keyboard: TextFieldKeyboard,
9
+ autoCapitalize: TextFieldAutoCapitalize | undefined
10
+ ): TextFieldAutoCapitalize | undefined => autoCapitalize ?? (ADDRESS_KEYBOARDS.has(keyboard) ? 'none' : undefined)
11
+
12
+ /** Autocorrection as asked for, else on for everything but an address. */
13
+ export const autoCorrectFor = (keyboard: TextFieldKeyboard, autoCorrect: boolean | undefined): boolean =>
14
+ autoCorrect ?? !ADDRESS_KEYBOARDS.has(keyboard)
15
+
16
+ /** A no-op for a ref call that lands after the native view has gone. */
17
+ export const ignoreDetachedView = () => undefined
@@ -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" />