@hzblj/kara-components 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +31 -3
  3. package/lib/module/atoms/checkbox/checkbox.android.js +7 -3
  4. package/lib/module/atoms/checkbox/checkbox.android.js.map +1 -1
  5. package/lib/module/atoms/chip/chip.js +1 -1
  6. package/lib/module/atoms/chip/chip.js.map +1 -1
  7. package/lib/module/atoms/date-picker/date-picker.ios.js +32 -11
  8. package/lib/module/atoms/date-picker/date-picker.ios.js.map +1 -1
  9. package/lib/module/atoms/index.js +0 -1
  10. package/lib/module/atoms/index.js.map +1 -1
  11. package/lib/module/atoms/pressable/pressable.js +11 -5
  12. package/lib/module/atoms/pressable/pressable.js.map +1 -1
  13. package/lib/module/atoms/rn-flat-list/rn-flat-list.js +10 -9
  14. package/lib/module/atoms/rn-flat-list/rn-flat-list.js.map +1 -1
  15. package/lib/module/atoms/rn-section-list/rn-section-list.js +5 -8
  16. package/lib/module/atoms/rn-section-list/rn-section-list.js.map +1 -1
  17. package/lib/module/atoms/segmented-control/index.js +1 -1
  18. package/lib/module/atoms/segmented-control/index.js.map +1 -1
  19. package/lib/module/atoms/segmented-control/segmented-control.android.js +74 -0
  20. package/lib/module/atoms/segmented-control/segmented-control.android.js.map +1 -0
  21. package/lib/module/atoms/segmented-control/segmented-control.ios.js +44 -0
  22. package/lib/module/atoms/segmented-control/segmented-control.ios.js.map +1 -0
  23. package/lib/module/atoms/slider/slider.android.js +26 -2
  24. package/lib/module/atoms/slider/slider.android.js.map +1 -1
  25. package/lib/module/atoms/switch/switch.android.js +14 -4
  26. package/lib/module/atoms/switch/switch.android.js.map +1 -1
  27. package/lib/module/haptics/haptics.js +56 -0
  28. package/lib/module/haptics/haptics.js.map +1 -0
  29. package/lib/module/haptics/index.js +5 -0
  30. package/lib/module/haptics/index.js.map +1 -0
  31. package/lib/module/haptics/use-haptic-feedback.js +33 -0
  32. package/lib/module/haptics/use-haptic-feedback.js.map +1 -0
  33. package/lib/module/index.js +1 -0
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/molecules/search-bar/search-bar.js +15 -2
  36. package/lib/module/molecules/search-bar/search-bar.js.map +1 -1
  37. package/lib/module/molecules/tooltip/tooltip.android.js +16 -5
  38. package/lib/module/molecules/tooltip/tooltip.android.js.map +1 -1
  39. package/lib/module/organisms/action-sheet/action-sheet.android.js +41 -20
  40. package/lib/module/organisms/action-sheet/action-sheet.android.js.map +1 -1
  41. package/lib/module/organisms/action-sheet/action-sheet.ios.js +24 -8
  42. package/lib/module/organisms/action-sheet/action-sheet.ios.js.map +1 -1
  43. package/lib/module/organisms/alert/alert.ios.js +15 -2
  44. package/lib/module/organisms/alert/alert.ios.js.map +1 -1
  45. package/lib/module/organisms/context-menu/context-menu.android.js +2 -2
  46. package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
  47. package/lib/module/organisms/form/form.android.js +22 -9
  48. package/lib/module/organisms/form/form.android.js.map +1 -1
  49. package/lib/module/organisms/swipe-actions/swipe-actions.android.js +125 -11
  50. package/lib/module/organisms/swipe-actions/swipe-actions.android.js.map +1 -1
  51. package/lib/module/organisms/swipe-actions/swipe-actions.ios.js +46 -18
  52. package/lib/module/organisms/swipe-actions/swipe-actions.ios.js.map +1 -1
  53. package/lib/module/templates/screen/screen.android.js +31 -10
  54. package/lib/module/templates/screen/screen.android.js.map +1 -1
  55. package/lib/typescript/atoms/avatar/avatar.types.d.ts +5 -0
  56. package/lib/typescript/atoms/badge/badge.types.d.ts +3 -0
  57. package/lib/typescript/atoms/button/button.types.d.ts +12 -0
  58. package/lib/typescript/atoms/card/card.types.d.ts +6 -0
  59. package/lib/typescript/atoms/checkbox/checkbox.types.d.ts +5 -0
  60. package/lib/typescript/atoms/chip/chip.types.d.ts +5 -0
  61. package/lib/typescript/atoms/color-picker/color-picker.types.d.ts +4 -0
  62. package/lib/typescript/atoms/date-picker/date-picker.ios.d.ts +6 -1
  63. package/lib/typescript/atoms/date-picker/date-picker.types.d.ts +14 -0
  64. package/lib/typescript/atoms/divider/divider.types.d.ts +3 -0
  65. package/lib/typescript/atoms/fab/fab.types.d.ts +7 -0
  66. package/lib/typescript/atoms/glass-view/glass-view.types.d.ts +1 -0
  67. package/lib/typescript/atoms/h-stack/h-stack.types.d.ts +6 -0
  68. package/lib/typescript/atoms/icon/icon.types.d.ts +8 -0
  69. package/lib/typescript/atoms/icon-button/icon-button.types.d.ts +5 -0
  70. package/lib/typescript/atoms/index.d.ts +0 -1
  71. package/lib/typescript/atoms/island/island.types.d.ts +4 -0
  72. package/lib/typescript/atoms/list/list.types.d.ts +2 -0
  73. package/lib/typescript/atoms/picker/picker.types.d.ts +12 -0
  74. package/lib/typescript/atoms/pressable/pressable.types.d.ts +11 -1
  75. package/lib/typescript/atoms/progress/progress.types.d.ts +6 -0
  76. package/lib/typescript/atoms/rn-flat-list/rn-flat-list.d.ts +5 -8
  77. package/lib/typescript/atoms/rn-flat-list/rn-flat-list.types.d.ts +3 -1
  78. package/lib/typescript/atoms/rn-scroll-view/rn-scroll-view.types.d.ts +4 -0
  79. package/lib/typescript/atoms/rn-section-list/rn-section-list.d.ts +2 -8
  80. package/lib/typescript/atoms/rn-section-list/rn-section-list.types.d.ts +3 -1
  81. package/lib/typescript/atoms/rn-text/rn-text.types.d.ts +4 -0
  82. package/lib/typescript/atoms/rn-view/rn-view.types.d.ts +2 -0
  83. package/lib/typescript/atoms/scroll-view/scroll-view.types.d.ts +4 -0
  84. package/lib/typescript/atoms/segmented-control/segmented-control.d.ts +1 -2
  85. package/lib/typescript/atoms/segmented-control/segmented-control.ios.d.ts +7 -0
  86. package/lib/typescript/atoms/segmented-control/segmented-control.types.d.ts +13 -0
  87. package/lib/typescript/atoms/skeleton/skeleton.types.d.ts +5 -0
  88. package/lib/typescript/atoms/slider/slider.types.d.ts +7 -0
  89. package/lib/typescript/atoms/spacer/spacer.types.d.ts +3 -0
  90. package/lib/typescript/atoms/surface/surface.types.d.ts +7 -0
  91. package/lib/typescript/atoms/switch/switch.types.d.ts +8 -0
  92. package/lib/typescript/atoms/text/text.types.d.ts +12 -0
  93. package/lib/typescript/atoms/text-field/text-field.types.d.ts +12 -0
  94. package/lib/typescript/atoms/v-stack/v-stack.types.d.ts +9 -0
  95. package/lib/typescript/atoms/z-stack/z-stack.types.d.ts +8 -0
  96. package/lib/typescript/haptics/haptics.d.ts +61 -0
  97. package/lib/typescript/haptics/index.d.ts +2 -0
  98. package/lib/typescript/haptics/use-haptic-feedback.d.ts +15 -0
  99. package/lib/typescript/index.d.ts +1 -0
  100. package/lib/typescript/molecules/collapsible/collapsible.types.d.ts +8 -0
  101. package/lib/typescript/molecules/empty-state/empty-state.types.d.ts +5 -0
  102. package/lib/typescript/molecules/label/label.types.d.ts +6 -0
  103. package/lib/typescript/molecules/labeled-content/labeled-content.types.d.ts +6 -0
  104. package/lib/typescript/molecules/link/link.types.d.ts +6 -0
  105. package/lib/typescript/molecules/list-item/list-item.types.d.ts +9 -0
  106. package/lib/typescript/molecules/navigation-bar/navigation-bar.types.d.ts +8 -0
  107. package/lib/typescript/molecules/pager/pager.types.d.ts +2 -0
  108. package/lib/typescript/molecules/radio-group/radio-group.types.d.ts +10 -0
  109. package/lib/typescript/molecules/rn-list-item/rn-list-item.types.d.ts +1 -1
  110. package/lib/typescript/molecules/rn-tooltip/rn-tooltip.types.d.ts +11 -0
  111. package/lib/typescript/molecules/scroll-edge-container/scroll-edge-container.types.d.ts +3 -0
  112. package/lib/typescript/molecules/search-bar/search-bar.types.d.ts +8 -0
  113. package/lib/typescript/molecules/stepper/stepper.types.d.ts +13 -0
  114. package/lib/typescript/molecules/tooltip/tooltip.types.d.ts +12 -0
  115. package/lib/typescript/organisms/action-sheet/action-sheet.types.d.ts +8 -0
  116. package/lib/typescript/organisms/alert/alert.types.d.ts +11 -0
  117. package/lib/typescript/organisms/context-menu/context-menu.types.d.ts +4 -0
  118. package/lib/typescript/organisms/fab-menu/fab-menu.types.d.ts +2 -0
  119. package/lib/typescript/organisms/form/form.types.d.ts +6 -0
  120. package/lib/typescript/organisms/menu/menu.types.d.ts +17 -0
  121. package/lib/typescript/organisms/sheet/sheet.types.d.ts +19 -0
  122. package/lib/typescript/organisms/snackbar/snackbar.types.d.ts +8 -0
  123. package/lib/typescript/organisms/swipe-actions/swipe-actions.types.d.ts +36 -13
  124. package/lib/typescript/templates/screen/screen.types.d.ts +9 -0
  125. package/package.json +5 -9
  126. package/src/atoms/avatar/avatar.types.ts +5 -0
  127. package/src/atoms/badge/badge.types.ts +3 -0
  128. package/src/atoms/button/button.types.ts +12 -0
  129. package/src/atoms/card/card.types.ts +6 -0
  130. package/src/atoms/checkbox/checkbox.android.tsx +7 -3
  131. package/src/atoms/checkbox/checkbox.types.ts +5 -0
  132. package/src/atoms/chip/chip.tsx +1 -1
  133. package/src/atoms/chip/chip.types.ts +5 -0
  134. package/src/atoms/color-picker/color-picker.types.ts +4 -0
  135. package/src/atoms/date-picker/date-picker.ios.tsx +33 -13
  136. package/src/atoms/date-picker/date-picker.types.ts +14 -0
  137. package/src/atoms/divider/divider.types.ts +3 -0
  138. package/src/atoms/fab/fab.types.ts +7 -0
  139. package/src/atoms/glass-view/glass-view.types.ts +1 -0
  140. package/src/atoms/h-stack/h-stack.types.ts +6 -0
  141. package/src/atoms/icon/icon.types.ts +8 -0
  142. package/src/atoms/icon-button/icon-button.types.ts +5 -0
  143. package/src/atoms/index.ts +0 -1
  144. package/src/atoms/island/island.types.ts +4 -0
  145. package/src/atoms/list/list.types.ts +2 -0
  146. package/src/atoms/picker/picker.types.ts +12 -0
  147. package/src/atoms/pressable/pressable.tsx +12 -5
  148. package/src/atoms/pressable/pressable.types.ts +12 -1
  149. package/src/atoms/progress/progress.types.ts +6 -0
  150. package/src/atoms/rn-flat-list/rn-flat-list.tsx +8 -8
  151. package/src/atoms/rn-flat-list/rn-flat-list.types.ts +3 -1
  152. package/src/atoms/rn-scroll-view/rn-scroll-view.types.ts +4 -0
  153. package/src/atoms/rn-section-list/rn-section-list.tsx +5 -8
  154. package/src/atoms/rn-section-list/rn-section-list.types.ts +3 -1
  155. package/src/atoms/rn-text/rn-text.types.ts +4 -0
  156. package/src/atoms/rn-view/rn-view.types.ts +2 -0
  157. package/src/atoms/scroll-view/scroll-view.types.ts +4 -0
  158. package/src/atoms/segmented-control/segmented-control.android.tsx +79 -0
  159. package/src/atoms/segmented-control/segmented-control.ios.tsx +47 -0
  160. package/src/atoms/segmented-control/segmented-control.types.ts +13 -0
  161. package/src/atoms/skeleton/skeleton.types.ts +5 -0
  162. package/src/atoms/slider/slider.android.tsx +33 -2
  163. package/src/atoms/slider/slider.types.ts +7 -0
  164. package/src/atoms/spacer/spacer.types.ts +3 -0
  165. package/src/atoms/surface/surface.types.ts +7 -0
  166. package/src/atoms/switch/switch.android.tsx +18 -5
  167. package/src/atoms/switch/switch.types.ts +8 -0
  168. package/src/atoms/text/text.types.ts +12 -0
  169. package/src/atoms/text-field/text-field.types.ts +12 -0
  170. package/src/atoms/v-stack/v-stack.types.ts +9 -0
  171. package/src/atoms/z-stack/z-stack.types.ts +8 -0
  172. package/src/haptics/haptics.ts +68 -0
  173. package/src/haptics/index.ts +2 -0
  174. package/src/haptics/use-haptic-feedback.ts +36 -0
  175. package/src/index.ts +1 -0
  176. package/src/molecules/collapsible/collapsible.types.ts +8 -0
  177. package/src/molecules/empty-state/empty-state.types.ts +5 -0
  178. package/src/molecules/label/label.types.ts +6 -0
  179. package/src/molecules/labeled-content/labeled-content.types.ts +6 -0
  180. package/src/molecules/link/link.types.ts +6 -0
  181. package/src/molecules/list-item/list-item.types.ts +9 -0
  182. package/src/molecules/navigation-bar/navigation-bar.types.ts +8 -0
  183. package/src/molecules/pager/pager.types.ts +2 -0
  184. package/src/molecules/radio-group/radio-group.types.ts +10 -0
  185. package/src/molecules/rn-list-item/rn-list-item.types.ts +1 -1
  186. package/src/molecules/rn-tooltip/rn-tooltip.types.ts +11 -0
  187. package/src/molecules/scroll-edge-container/scroll-edge-container.types.ts +3 -0
  188. package/src/molecules/search-bar/search-bar.tsx +9 -2
  189. package/src/molecules/search-bar/search-bar.types.ts +8 -0
  190. package/src/molecules/stepper/stepper.types.ts +13 -0
  191. package/src/molecules/tooltip/tooltip.android.tsx +17 -9
  192. package/src/molecules/tooltip/tooltip.types.ts +12 -0
  193. package/src/organisms/action-sheet/action-sheet.android.tsx +37 -19
  194. package/src/organisms/action-sheet/action-sheet.ios.tsx +22 -9
  195. package/src/organisms/action-sheet/action-sheet.types.ts +8 -0
  196. package/src/organisms/alert/alert.ios.tsx +11 -1
  197. package/src/organisms/alert/alert.types.ts +11 -0
  198. package/src/organisms/context-menu/context-menu.android.tsx +2 -2
  199. package/src/organisms/context-menu/context-menu.types.ts +4 -0
  200. package/src/organisms/fab-menu/fab-menu.types.ts +2 -0
  201. package/src/organisms/form/form.android.tsx +15 -7
  202. package/src/organisms/form/form.types.ts +6 -0
  203. package/src/organisms/menu/menu.types.ts +17 -0
  204. package/src/organisms/sheet/sheet.types.ts +19 -0
  205. package/src/organisms/snackbar/snackbar.types.ts +8 -0
  206. package/src/organisms/swipe-actions/swipe-actions.android.tsx +129 -9
  207. package/src/organisms/swipe-actions/swipe-actions.ios.tsx +45 -11
  208. package/src/organisms/swipe-actions/swipe-actions.types.ts +37 -14
  209. package/src/templates/screen/screen.android.tsx +33 -12
  210. package/src/templates/screen/screen.types.ts +9 -0
  211. package/lib/module/atoms/flash-list/flash-list.js +0 -31
  212. package/lib/module/atoms/flash-list/flash-list.js.map +0 -1
  213. package/lib/module/atoms/flash-list/flash-list.types.js +0 -4
  214. package/lib/module/atoms/flash-list/flash-list.types.js.map +0 -1
  215. package/lib/module/atoms/flash-list/index.js +0 -5
  216. package/lib/module/atoms/flash-list/index.js.map +0 -1
  217. package/lib/module/atoms/segmented-control/segmented-control.js +0 -28
  218. package/lib/module/atoms/segmented-control/segmented-control.js.map +0 -1
  219. package/lib/typescript/atoms/flash-list/flash-list.d.ts +0 -8
  220. package/lib/typescript/atoms/flash-list/flash-list.types.d.ts +0 -18
  221. package/lib/typescript/atoms/flash-list/index.d.ts +0 -2
  222. package/src/atoms/flash-list/flash-list.tsx +0 -28
  223. package/src/atoms/flash-list/flash-list.types.ts +0 -20
  224. package/src/atoms/flash-list/index.ts +0 -2
  225. package/src/atoms/segmented-control/segmented-control.tsx +0 -34
@@ -0,0 +1,79 @@
1
+ import {Host, SegmentedButton, SingleChoiceSegmentedButtonRow, Text} from '@expo/ui/jetpack-compose'
2
+ import {fillMaxWidth, testID as testIdModifier, weight} from '@expo/ui/jetpack-compose/modifiers'
3
+ import {useAppTheme} from '@hzblj/kara-theme'
4
+ import {useCallback, useMemo} from 'react'
5
+
6
+ import {haptics} from '../../haptics'
7
+ import type {SegmentedControlComponent, SegmentedControlProps} from './segmented-control.types'
8
+
9
+ const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
10
+
11
+ /** Every segment the same width, like UIKit's: Material's would size each to its label. */
12
+ const SEGMENT = [weight(1)]
13
+
14
+ /**
15
+ * Material's single-choice segmented buttons, the selected one filled with the accent like a checked `Switch`. Its own
16
+ * host, themed like an `Island`, so it drops into React Native layout the way the iOS control does. Material plays
17
+ * nothing on a new segment, so this plays `selection`.
18
+ */
19
+ export const SegmentedControl: SegmentedControlComponent = ({
20
+ disabled = false,
21
+ onChange,
22
+ selectedIndex,
23
+ testID,
24
+ values,
25
+ }: SegmentedControlProps) => {
26
+ const theme = useAppTheme()
27
+
28
+ const colors = useMemo(
29
+ () => ({
30
+ activeBorderColor: theme.color('fill.accent.primary'),
31
+ activeContainerColor: theme.color('fill.accent.primary'),
32
+ activeContentColor: theme.color('content.on.bg'),
33
+ disabledActiveBorderColor: theme.color('fill.accent.disabled'),
34
+ disabledActiveContainerColor: theme.color('fill.accent.disabled'),
35
+ disabledActiveContentColor: theme.color('btn.content.disabled'),
36
+ disabledInactiveBorderColor: theme.color('border.tertiary'),
37
+ disabledInactiveContentColor: theme.color('btn.content.disabled'),
38
+ inactiveBorderColor: theme.color('border.secondary.base'),
39
+ inactiveContentColor: theme.color('content.primary'),
40
+ }),
41
+ [theme]
42
+ )
43
+
44
+ const rowModifiers = useMemo(() => (testID ? [fillMaxWidth(), testIdModifier(testID)] : [fillMaxWidth()]), [testID])
45
+
46
+ // Material reports a tap on the segment that is already selected too; iOS does not, and neither does this.
47
+ const select = useCallback(
48
+ (index: number) => {
49
+ if (index === selectedIndex) {
50
+ return
51
+ }
52
+
53
+ haptics.selection()
54
+ onChange?.(index, values[index] ?? '')
55
+ },
56
+ [onChange, selectedIndex, values]
57
+ )
58
+
59
+ return (
60
+ <Host colorScheme={theme.dark ? 'dark' : 'light'} matchContents={MATCH_HEIGHT_ONLY} seedColor={theme.materialSeed}>
61
+ <SingleChoiceSegmentedButtonRow modifiers={rowModifiers}>
62
+ {values.map((label, index) => (
63
+ <SegmentedButton
64
+ colors={colors}
65
+ enabled={!disabled}
66
+ key={index}
67
+ modifiers={SEGMENT}
68
+ onClick={() => select(index)}
69
+ selected={index === selectedIndex}
70
+ >
71
+ <SegmentedButton.Label>
72
+ <Text style={theme.typography.subheadlineMedium}>{label}</Text>
73
+ </SegmentedButton.Label>
74
+ </SegmentedButton>
75
+ ))}
76
+ </SingleChoiceSegmentedButtonRow>
77
+ </Host>
78
+ )
79
+ }
@@ -0,0 +1,47 @@
1
+ import {SegmentedControl as NativeSegmentedControl} from '@hzblj/kara-native/swift-ui'
2
+ import {useAppTheme} from '@hzblj/kara-theme'
3
+ import {useCallback, useMemo} from 'react'
4
+
5
+ import type {SegmentedControlComponent, SegmentedControlProps} from './segmented-control.types'
6
+
7
+ /**
8
+ * kara's own `UISegmentedControl`, with the pill in the accent and white text on it — the system control tints only the
9
+ * pill, and SwiftUI's segmented picker not even that. A plain `UIView` in React Native layout: it needs no host, takes
10
+ * the width it is given and UIKit's height. UIKit plays the control's own feedback.
11
+ */
12
+ export const SegmentedControl: SegmentedControlComponent = ({
13
+ disabled = false,
14
+ onChange,
15
+ selectedIndex,
16
+ testID,
17
+ values,
18
+ }: SegmentedControlProps) => {
19
+ const theme = useAppTheme()
20
+ const face = theme.typography.footnoteMedium
21
+
22
+ // The native control speaks in values; a segment's is its index, so two labels may read the same.
23
+ const options = useMemo(() => values.map((label, index) => ({label, value: String(index)})), [values])
24
+
25
+ const onValueChange = useCallback(
26
+ (value: string) => {
27
+ const index = Number(value)
28
+ onChange?.(index, values[index] ?? '')
29
+ },
30
+ [onChange, values]
31
+ )
32
+
33
+ return (
34
+ <NativeSegmentedControl
35
+ activePillColor={theme.color('fill.accent.primary')}
36
+ activeTextColor={theme.color('content.on.bg')}
37
+ disabled={disabled}
38
+ fontFamily={face.fontFamily}
39
+ fontSize={face.fontSize}
40
+ inactiveTextColor={theme.color('content.primary')}
41
+ onValueChange={onValueChange}
42
+ options={options}
43
+ selectedValue={String(selectedIndex)}
44
+ testID={testID}
45
+ />
46
+ )
47
+ }
@@ -1,10 +1,23 @@
1
1
  import type {ReactNode} from 'react'
2
2
 
3
+ /**
4
+ * A row of mutually exclusive segments, the selected one in the accent — kara's own `UISegmentedControl` on iOS,
5
+ * Material's single-choice segmented buttons on Android. It needs no `Screen` or `Island`: iOS is a plain view, Android
6
+ * brings its own host. It takes the width it is given; the height is the platform's.
7
+ */
3
8
  export type SegmentedControlProps = {
9
+ /** Stops the user changing the segment, and dims the control. */
4
10
  disabled?: boolean
11
+ /**
12
+ * Called with the index and label of the segment the user picked — never for a tap on the one already selected.
13
+ * Android plays `selection`; iOS's control plays its own feedback.
14
+ */
5
15
  onChange?: (index: number, value: string) => void
16
+ /** The selected segment. Controlled: pass back what `onChange` reports. */
6
17
  selectedIndex: number
18
+ /** Test identifier, on the whole control — reach a segment by its label. */
7
19
  testID?: string
20
+ /** The segments' labels, in order. */
8
21
  values: string[]
9
22
  }
10
23
 
@@ -1,10 +1,15 @@
1
1
  import type {RadiiSize} from '@hzblj/kara-theme'
2
2
  import type {ReactNode} from 'react'
3
3
 
4
+ /** A loading placeholder that pulses in `fill.quaternary` — a React Native view, so it needs no host. */
4
5
  export type SkeletonProps = {
6
+ /** The corner radius. `small` by default. */
5
7
  borderRadius?: RadiiSize
8
+ /** In points. */
6
9
  height: number
10
+ /** Test identifier, forwarded to the view. */
7
11
  testID?: string
12
+ /** In points. Leave it unset and it fills the available width. */
8
13
  width?: number
9
14
  }
10
15
 
@@ -1,9 +1,16 @@
1
1
  import {Slider as ComposeSlider} from '@expo/ui/jetpack-compose'
2
2
  import {useAppTheme} from '@hzblj/kara-theme'
3
+ import {useCallback, useEffect, useRef} from 'react'
3
4
 
5
+ import {haptics} from '../../haptics'
4
6
  import {useHostGuard} from '../../host-context'
5
7
  import type {SliderComponent, SliderProps} from './slider.types'
6
8
 
9
+ /**
10
+ * Material's slider. `step` is the increment, as SwiftUI takes it; Compose counts the stops between the two ends
11
+ * 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
+ */
7
14
  export const Slider: SliderComponent = ({
8
15
  disabled = false,
9
16
  max = 1,
@@ -14,6 +21,30 @@ export const Slider: SliderComponent = ({
14
21
  }: SliderProps) => {
15
22
  useHostGuard('Slider')
16
23
  const theme = useAppTheme()
24
+ const steps = step ? Math.max(0, Math.round((max - min) / step) - 1) : 0
25
+
26
+ // The stop is derived from the value rather than trusting it to arrive snapped, and remembered between events:
27
+ // Compose reports faster than React re-renders the controlled value.
28
+ const stopOf = useCallback((at: number) => (step ? Math.round((at - min) / step) : 0), [min, step])
29
+ const stop = useRef(stopOf(value))
30
+
31
+ useEffect(() => {
32
+ stop.current = stopOf(value)
33
+ }, [stopOf, value])
34
+
35
+ const onChange = useCallback(
36
+ (next: number) => {
37
+ const reached = stopOf(next)
38
+
39
+ if (steps > 0 && reached !== stop.current) {
40
+ haptics.tick()
41
+ }
42
+
43
+ stop.current = reached
44
+ onValueChange?.(next)
45
+ },
46
+ [onValueChange, stopOf, steps]
47
+ )
17
48
 
18
49
  return (
19
50
  <ComposeSlider
@@ -24,8 +55,8 @@ export const Slider: SliderComponent = ({
24
55
  enabled={!disabled}
25
56
  max={max}
26
57
  min={min}
27
- onValueChange={onValueChange}
28
- steps={step}
58
+ onValueChange={onChange}
59
+ steps={steps}
29
60
  value={value}
30
61
  />
31
62
  )
@@ -1,11 +1,18 @@
1
1
  import type {ReactNode} from 'react'
2
2
 
3
+ /** SwiftUI `Slider` / Compose's Material `Slider`, tinted `fill.accent.primary`. */
3
4
  export type SliderProps = {
5
+ /** Stops the user moving the thumb, and dims the slider. */
4
6
  disabled?: boolean
7
+ /** The upper end. `1` by default. */
5
8
  max?: number
9
+ /** The lower end. `0` by default. */
6
10
  min?: number
11
+ /** Called with the new value as the thumb moves. */
7
12
  onValueChange?: (value: number) => void
13
+ /** The increment between stops; leave it unset for a continuous slider. On Android every stop plays a `tick`. */
8
14
  step?: number
15
+ /** Between `min` and `max`. Controlled: pass back what `onValueChange` reports. */
9
16
  value: number
10
17
  }
11
18
 
@@ -1,8 +1,11 @@
1
1
  import type {SpaceSize} from '@hzblj/kara-theme'
2
2
  import type {ReactNode} from 'react'
3
3
 
4
+ /** Takes up the free space in a stack — SwiftUI `Spacer` / Compose `Spacer` with `weight(1)`. */
4
5
  export type SpacerProps = {
6
+ /** The least room it takes. iOS holds it along the stack's axis; Android as a minimum height only. */
5
7
  min?: SpaceSize
8
+ /** iOS: forwarded to the native view. Android ignores it. */
6
9
  testID?: string
7
10
  }
8
11
 
@@ -1,9 +1,16 @@
1
1
  import type {ResponsiveSx} from '@hzblj/kara-sx'
2
2
  import type {ReactNode} from 'react'
3
3
 
4
+ /**
5
+ * A container padded `s16` and rounded `large` unless told otherwise — a SwiftUI `VStack` on iOS, Material's
6
+ * `Surface` around a `Column` on Android.
7
+ */
4
8
  export type SurfaceProps = ResponsiveSx & {
9
+ /** Stacked vertically, from the leading edge. */
5
10
  children?: ReactNode
11
+ /** iOS wraps the content in a `GlassEffectContainer`; Android raises it to a tonal elevation of `3`. */
6
12
  glass?: boolean
13
+ /** Test identifier, forwarded to the native view. */
7
14
  testID?: string
8
15
  }
9
16
 
@@ -1,21 +1,34 @@
1
1
  import {Switch as ComposeSwitch, Row, Text} from '@expo/ui/jetpack-compose'
2
2
  import {clickable, fillMaxWidth} from '@expo/ui/jetpack-compose/modifiers'
3
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
- import {useMemo} from 'react'
4
+ import {useCallback, useMemo} from 'react'
5
5
 
6
+ import {haptics} from '../../haptics'
6
7
  import {useHostGuard} from '../../host-context'
7
8
  import type {SwitchComponent, SwitchProps} from './switch.types'
8
9
 
10
+ /** Stands in for a missing handler: Material drops its 48dp touch target without one, and the switch moves. */
11
+ const NOOP = () => undefined
12
+
9
13
  /**
10
14
  * Material's switch, tinted with the accent like SwiftUI's toggle. With a label it is a full-width row — label at the
11
- * start, switch at the end — and the whole row toggles, the way a Material settings row does.
15
+ * start, switch at the end — and the whole row toggles, the way a Material settings row does. A flip plays
16
+ * `toggleOn`/`toggleOff` — Android 14's constants made for switches — since Compose's Material switch plays nothing
17
+ * of its own. iOS stays silent, as `UISwitch` does.
12
18
  */
13
19
  export const Switch: SwitchComponent = ({disabled = false, label, onValueChange, value}: SwitchProps) => {
14
20
  useHostGuard('Switch')
15
21
  const theme = useAppTheme()
22
+ const toggle = useCallback(
23
+ (next: boolean) => {
24
+ haptics.play(next ? 'toggleOn' : 'toggleOff')
25
+ onValueChange?.(next)
26
+ },
27
+ [onValueChange]
28
+ )
16
29
  const rowModifiers = useMemo(
17
- () => (disabled || !onValueChange ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => onValueChange(!value))]),
18
- [disabled, onValueChange, value]
30
+ () => (disabled || !onValueChange ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => toggle(!value))]),
31
+ [disabled, onValueChange, toggle, value]
19
32
  )
20
33
  const control = (
21
34
  <ComposeSwitch
@@ -25,7 +38,7 @@ export const Switch: SwitchComponent = ({disabled = false, label, onValueChange,
25
38
  checkedTrackColor: theme.color('fill.accent.primary'),
26
39
  }}
27
40
  enabled={!disabled}
28
- onCheckedChange={onValueChange}
41
+ onCheckedChange={onValueChange ? toggle : NOOP}
29
42
  value={value}
30
43
  />
31
44
  )
@@ -1,9 +1,17 @@
1
1
  import type {ReactNode} from 'react'
2
2
 
3
+ /** SwiftUI `Toggle` / Compose `Switch`, tinted `fill.accent.primary`. */
3
4
  export type SwitchProps = {
5
+ /** Stops the user flipping it, and dims it. */
4
6
  disabled?: boolean
7
+ /**
8
+ * Shown beside the switch. On Android it makes a full-width row — label at the start, switch at the end — that
9
+ * toggles wherever it is tapped.
10
+ */
5
11
  label?: string
12
+ /** Called with the new value when the user flips it. On Android a flip plays `toggleOn` / `toggleOff`. */
6
13
  onValueChange?: (value: boolean) => void
14
+ /** Whether it is on. Controlled: pass back what `onValueChange` reports. */
7
15
  value: boolean
8
16
  }
9
17
 
@@ -7,14 +7,26 @@ export type TextDecoration = 'none' | 'strikethrough' | 'underline'
7
7
 
8
8
  export type TextTransform = 'lowercase' | 'uppercase'
9
9
 
10
+ /** SwiftUI `Text` / Compose `Text` in a theme typography variant — a native atom; outside a host, use `RnText`. */
10
11
  export type TextProps = {
12
+ /**
13
+ * How its lines align. iOS reads `left` and `right` as leading and trailing, so they flip in a right-to-left
14
+ * layout; Android keeps them as they are.
15
+ */
11
16
  align?: TextAlign
17
+ /** The text. */
12
18
  children?: ReactNode
19
+ /** The text's colour. `content.primary` by default. */
13
20
  color?: ColorToken
21
+ /** An underline or a strikethrough. */
14
22
  decoration?: TextDecoration
23
+ /** The most lines it wraps to. iOS truncates the last with an ellipsis; Android clips it. */
15
24
  numberOfLines?: number
25
+ /** Test identifier, forwarded to the native view. */
16
26
  testID?: string
27
+ /** Upper- or lower-cases the text. Android applies it only when `children` is a plain string. */
17
28
  transform?: TextTransform
29
+ /** The typography variant. `body` by default. */
18
30
  variant?: TextVariant
19
31
  }
20
32
 
@@ -1,13 +1,25 @@
1
1
  import type {ReactNode} from 'react'
2
2
 
3
+ /**
4
+ * An uncontrolled text field with an optional label above it — SwiftUI `TextField` (`SecureField` when `secure`) /
5
+ * Compose `OutlinedTextField`.
6
+ */
3
7
  export type TextFieldProps = {
8
+ /** The text it starts with. Read once, on mount — the field is uncontrolled. */
4
9
  defaultValue?: string
10
+ /** Stops editing and dims the field. On iOS it outranks `error`. */
5
11
  disabled?: boolean
12
+ /** Marks the field invalid: a destructive outline and label. */
6
13
  error?: boolean
14
+ /** Shown above the field, in `labelMedium`. */
7
15
  label?: string
16
+ /** Wraps the text and grows the field to fit it. iOS ignores it when `secure`. */
8
17
  multiline?: boolean
18
+ /** Called with the whole text after every change. */
9
19
  onChangeText?: (text: string) => void
20
+ /** Masks the text, for a password. */
10
21
  secure?: boolean
22
+ /** iOS: forwarded to the native field. Android ignores it. */
11
23
  testID?: string
12
24
  }
13
25
 
@@ -4,11 +4,20 @@ import type {ReactNode} from 'react'
4
4
 
5
5
  export type VStackAlign = 'center' | 'end' | 'start'
6
6
 
7
+ /** Stacks its children top to bottom — SwiftUI `VStack` / Compose `Column`. */
7
8
  export type VStackProps = ResponsiveSx & {
9
+ /** The children's horizontal alignment. `start` by default. */
8
10
  align?: VStackAlign
11
+ /** Laid out top to bottom, `spacing` apart. */
9
12
  children?: ReactNode
13
+ /**
14
+ * Makes the whole stack pressable. iOS adds a tap gesture with no press feedback; Android a `clickable` whose ripple
15
+ * fills the stack's shape.
16
+ */
10
17
  onPress?: () => void
18
+ /** The space between children. */
11
19
  spacing?: SpaceSize
20
+ /** Test identifier, forwarded to the native view. */
12
21
  testID?: string
13
22
  }
14
23
 
@@ -3,10 +3,18 @@ import type {ReactNode} from 'react'
3
3
 
4
4
  export type ZStackAlign = 'bottom' | 'center' | 'top'
5
5
 
6
+ /** Layers its children on one another — SwiftUI `ZStack` / Compose `Box`. */
6
7
  export type ZStackProps = ResponsiveSx & {
8
+ /** Where the children sit vertically; they are always centred horizontally. `center` by default. */
7
9
  align?: ZStackAlign
10
+ /** Drawn in order, the last on top. */
8
11
  children?: ReactNode
12
+ /**
13
+ * Makes the whole stack pressable. iOS adds a tap gesture with no press feedback; Android a `clickable` whose ripple
14
+ * fills the stack's shape.
15
+ */
9
16
  onPress?: () => void
17
+ /** Test identifier, forwarded to the native view. */
10
18
  testID?: string
11
19
  }
12
20
 
@@ -0,0 +1,68 @@
1
+ import {HAPTICS, type Haptic, playHaptic, prepareHaptic} from '@hzblj/kara-native'
2
+
3
+ export type {Haptic}
4
+
5
+ export type HapticOptions = {
6
+ /** iOS: scales an impact — `soft`, `light`, `medium`, `rigid`, `heavy` — from 0 to 1. Everything else ignores it. */
7
+ intensity?: number
8
+ }
9
+
10
+ type HapticShorthands = {readonly [K in Haptic]: (options?: HapticOptions) => void}
11
+
12
+ let enabled = true
13
+
14
+ const play = (haptic: Haptic, options?: HapticOptions): void => {
15
+ if (enabled) {
16
+ playHaptic(haptic, options?.intensity)
17
+ }
18
+ }
19
+
20
+ const prepare = (haptic: Haptic): void => {
21
+ if (enabled) {
22
+ prepareHaptic(haptic)
23
+ }
24
+ }
25
+
26
+ const shorthands = Object.fromEntries(
27
+ HAPTICS.map(haptic => [haptic, (options?: HapticOptions) => play(haptic, options)])
28
+ ) as HapticShorthands
29
+
30
+ /**
31
+ * The platform's own haptic feedback, named for what happened: UIKit's feedback generators on iOS, the system's
32
+ * `HapticFeedbackConstants` on Android — never a raw vibration. Both follow the user's system haptics setting, and
33
+ * every call is synchronous, with nothing to await.
34
+ *
35
+ * ```ts
36
+ * haptics.success() // a name you know: one function per haptic
37
+ * haptics.play(haptic) // a name that arrives as data
38
+ * haptics.light({intensity: 0.6}) // impacts scale on iOS
39
+ * haptics.prepare('selection') // iOS: warm the Taptic Engine as a gesture begins
40
+ * haptics.setEnabled(userPreference) // silences every kara haptic, the components' own too
41
+ * ```
42
+ *
43
+ * - `selection` — a choice changed: a segment, a chip, a picker row.
44
+ * - `tick` — one step of a continuous gesture: a slider detent, a scrubbed value.
45
+ * - `toggleOn` / `toggleOff` — a switch flipped.
46
+ * - `success` / `warning` / `error` — how a task ended. Android has no warning of its own; it plays a rejection.
47
+ * - `longPress` / `dragStart` — a long press was recognised; an item lifted to be dragged.
48
+ * - `thresholdActivate` / `thresholdDeactivate` — a gesture crossed its commit point, or went back over it.
49
+ * - `keyPress` — a key of the app's own keypad.
50
+ * - `soft`, `light`, `medium`, `rigid`, `heavy` — a collision none of the names above describes.
51
+ *
52
+ * On Android the names that came with Android 14's constants fall back to the nearest older feedback, so every name
53
+ * plays on every version. Haptics are silent in a build without kara's native module.
54
+ */
55
+ export const haptics = {
56
+ ...shorthands,
57
+ /** Whether kara plays haptics — `true` until `setEnabled(false)`. */
58
+ isEnabled: (): boolean => enabled,
59
+ play,
60
+ prepare,
61
+ /**
62
+ * Switches every kara haptic on or off, the ones its components play included — for an app's own "Haptics" setting.
63
+ * Not persisted: call it at start-up with the stored preference. The system setting applies either way.
64
+ */
65
+ setEnabled: (value: boolean): void => {
66
+ enabled = value
67
+ },
68
+ }
@@ -0,0 +1,2 @@
1
+ export {type Haptic, type HapticOptions, haptics} from './haptics'
2
+ export {type HapticResolver, useHapticFeedback} from './use-haptic-feedback'
@@ -0,0 +1,36 @@
1
+ import {useEffect, useRef} from 'react'
2
+
3
+ import {type Haptic, haptics} from './haptics'
4
+
5
+ /** Picks the haptic for one change of a trigger — or none, to stay silent for that change. */
6
+ export type HapticResolver<T> = (previous: T, next: T) => Haptic | false | null | undefined
7
+
8
+ /**
9
+ * Plays a haptic whenever `trigger` changes — never on mount — like SwiftUI's `sensoryFeedback(_:trigger:)`. Give it
10
+ * a haptic, or a function of the previous and next value that picks one per change. Changes are compared with
11
+ * `Object.is`, so a trigger is a primitive or a stable reference.
12
+ *
13
+ * ```ts
14
+ * useHapticFeedback(tab, 'selection')
15
+ * useHapticFeedback(quantity, (before, after) => (after > before ? 'tick' : 'soft'))
16
+ * useHapticFeedback(payment.status, (_, status) => status === 'paid' ? 'success' : status === 'failed' && 'error')
17
+ * ```
18
+ */
19
+ export const useHapticFeedback = <T>(trigger: T, feedback: Haptic | HapticResolver<T>): void => {
20
+ const previous = useRef(trigger)
21
+
22
+ useEffect(() => {
23
+ const before = previous.current
24
+
25
+ if (Object.is(before, trigger)) {
26
+ return
27
+ }
28
+
29
+ previous.current = trigger
30
+ const haptic = typeof feedback === 'function' ? feedback(before, trigger) : feedback
31
+
32
+ if (haptic) {
33
+ haptics.play(haptic)
34
+ }
35
+ }, [feedback, trigger])
36
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './atoms'
2
+ export * from './haptics'
2
3
  export * from './molecules'
3
4
  export * from './organisms'
4
5
  export * from './templates'
@@ -1,9 +1,17 @@
1
1
  import type {ReactNode} from 'react'
2
2
 
3
+ /**
4
+ * A row that shows and hides its content: SwiftUI's `DisclosureGroup` on iOS; on Android a pressable row with a
5
+ * chevron over an animated reveal. A native atom — it renders inside `<Screen>` or `<Island>`.
6
+ */
3
7
  export type CollapsibleProps = {
8
+ /** Shown while `expanded`. */
4
9
  children: ReactNode
10
+ /** Whether the content is shown. Update it from `onExpandedChange`. */
5
11
  expanded: boolean
12
+ /** The row's text. */
6
13
  label: string
14
+ /** The user pressed the row; it passes the state the row asks for. */
7
15
  onExpandedChange: (expanded: boolean) => void
8
16
  }
9
17
 
@@ -6,10 +6,15 @@ import type {IconName} from '../../atoms/icon'
6
6
  * `action` takes a `Button` so the state can offer a way out.
7
7
  */
8
8
  export type EmptyStateProps = {
9
+ /** Under the text — a `Button`, say. */
9
10
  action?: ReactNode
11
+ /** A second line under the title, in the secondary colour. */
10
12
  description?: string
13
+ /** One of the app's icons, large and muted above the title. */
11
14
  icon?: IconName
15
+ /** Test identifier, forwarded to the native view. */
12
16
  testID?: string
17
+ /** The headline. */
13
18
  title: string
14
19
  }
15
20
 
@@ -8,11 +8,17 @@ import type {IconName} from '../../atoms/icon'
8
8
  * header. For a full tappable row with a subtitle and chevron, use `ListItem`.
9
9
  */
10
10
  export type LabelProps = {
11
+ /** Colours the icon and the title alike. `content.primary` by default. */
11
12
  color?: ColorToken
13
+ /** One of the app's icons, before the title. */
12
14
  icon: IconName
15
+ /** The icon's size. `s20` by default. */
13
16
  iconSize?: SpaceSize
17
+ /** Test identifier, forwarded to the native view. */
14
18
  testID?: string
19
+ /** The text beside the icon. */
15
20
  title: string
21
+ /** The title's text style. `body` by default. */
16
22
  variant?: TextVariant
17
23
  }
18
24
 
@@ -7,13 +7,19 @@ import type {IconName} from '../../atoms/icon'
7
7
  * Pass `children` instead of `value` when the trailing side is a control rather than text.
8
8
  */
9
9
  export type LabeledContentProps = {
10
+ /** The trailing side when it is a control, in place of `value`. */
10
11
  children?: ReactNode
12
+ /** One of the app's icons, before the label, in the secondary colour. */
11
13
  icon?: IconName
14
+ /** The leading text. */
12
15
  label: string
16
+ /** Makes the row pressable and adds a chevron on the trailing edge. */
13
17
  onPress?: () => void
14
18
  /** Horizontal padding inside the row — inside its press area too, so Android's ripple reaches the card's edges. */
15
19
  px?: SpaceSize
20
+ /** Test identifier, forwarded to the native view. */
16
21
  testID?: string
22
+ /** The trailing text, in the secondary colour. Ignored when `children` is given. */
17
23
  value?: string
18
24
  }
19
25
 
@@ -5,11 +5,17 @@ import type {IconName} from '../../atoms/icon'
5
5
 
6
6
  /** Opens an external URL in the platform browser or the app that claims the scheme. */
7
7
  export type LinkProps = {
8
+ /** Colours the label and the icon. `content.accent` by default. */
8
9
  color?: ColorToken
10
+ /** One of the app's icons, before the label. */
9
11
  icon?: IconName
12
+ /** The link's text. */
10
13
  label: string
14
+ /** Test identifier, forwarded to the native view. */
11
15
  testID?: string
16
+ /** Opened through React Native's `Linking` on press. A URL nothing can open fails silently. */
12
17
  url: string
18
+ /** The label's text style. `body` by default. */
13
19
  variant?: TextVariant
14
20
  }
15
21