@hzblj/kara-components 0.17.0 → 0.19.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 (218) 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/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/segmented-control/segmented-control.android.js +66 -12
  45. package/lib/module/atoms/segmented-control/segmented-control.android.js.map +1 -1
  46. package/lib/module/atoms/segmented-control/segmented-control.ios.js +19 -8
  47. package/lib/module/atoms/segmented-control/segmented-control.ios.js.map +1 -1
  48. package/lib/module/atoms/segmented-control/segments.js +16 -0
  49. package/lib/module/atoms/segmented-control/segments.js.map +1 -0
  50. package/lib/module/atoms/slider/slider.android.js +16 -2
  51. package/lib/module/atoms/slider/slider.android.js.map +1 -1
  52. package/lib/module/atoms/slider/slider.ios.js +22 -2
  53. package/lib/module/atoms/slider/slider.ios.js.map +1 -1
  54. package/lib/module/atoms/switch/switch.android.js +5 -1
  55. package/lib/module/atoms/switch/switch.android.js.map +1 -1
  56. package/lib/module/atoms/switch/switch.ios.js +3 -1
  57. package/lib/module/atoms/switch/switch.ios.js.map +1 -1
  58. package/lib/module/atoms/text/spans.js +2 -1
  59. package/lib/module/atoms/text/spans.js.map +1 -1
  60. package/lib/module/atoms/text/text.android.js +18 -6
  61. package/lib/module/atoms/text/text.android.js.map +1 -1
  62. package/lib/module/atoms/text/text.ios.js +6 -2
  63. package/lib/module/atoms/text/text.ios.js.map +1 -1
  64. package/lib/module/atoms/text-field/text-field-input.js +14 -0
  65. package/lib/module/atoms/text-field/text-field-input.js.map +1 -0
  66. package/lib/module/atoms/text-field/text-field.android.js +113 -6
  67. package/lib/module/atoms/text-field/text-field.android.js.map +1 -1
  68. package/lib/module/atoms/text-field/text-field.ios.js +75 -3
  69. package/lib/module/atoms/text-field/text-field.ios.js.map +1 -1
  70. package/lib/module/molecules/collapsible/collapsible.android.js +4 -3
  71. package/lib/module/molecules/collapsible/collapsible.android.js.map +1 -1
  72. package/lib/module/molecules/collapsible/collapsible.ios.js +3 -1
  73. package/lib/module/molecules/collapsible/collapsible.ios.js.map +1 -1
  74. package/lib/module/molecules/radio-group/radio-group.android.js +10 -2
  75. package/lib/module/molecules/radio-group/radio-group.android.js.map +1 -1
  76. package/lib/module/molecules/radio-group/radio-group.ios.js +6 -2
  77. package/lib/module/molecules/radio-group/radio-group.ios.js.map +1 -1
  78. package/lib/module/molecules/search-bar/search-bar.js +9 -0
  79. package/lib/module/molecules/search-bar/search-bar.js.map +1 -1
  80. package/lib/module/molecules/stepper/stepper.android.js +25 -13
  81. package/lib/module/molecules/stepper/stepper.android.js.map +1 -1
  82. package/lib/module/molecules/stepper/stepper.ios.js +12 -6
  83. package/lib/module/molecules/stepper/stepper.ios.js.map +1 -1
  84. package/lib/module/organisms/action-sheet/action-sheet.android.js +16 -8
  85. package/lib/module/organisms/action-sheet/action-sheet.android.js.map +1 -1
  86. package/lib/module/organisms/action-sheet/action-sheet.ios.js +16 -3
  87. package/lib/module/organisms/action-sheet/action-sheet.ios.js.map +1 -1
  88. package/lib/module/organisms/alert/alert.android.js +4 -3
  89. package/lib/module/organisms/alert/alert.android.js.map +1 -1
  90. package/lib/module/organisms/alert/alert.ios.js +3 -2
  91. package/lib/module/organisms/alert/alert.ios.js.map +1 -1
  92. package/lib/module/organisms/context-menu/context-menu.android.js +3 -1
  93. package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
  94. package/lib/module/organisms/context-menu/context-menu.ios.js +1 -0
  95. package/lib/module/organisms/context-menu/context-menu.ios.js.map +1 -1
  96. package/lib/module/organisms/fab-menu/fab-menu.android.js +3 -0
  97. package/lib/module/organisms/fab-menu/fab-menu.android.js.map +1 -1
  98. package/lib/module/organisms/fab-menu/fab-menu.ios.js +3 -0
  99. package/lib/module/organisms/fab-menu/fab-menu.ios.js.map +1 -1
  100. package/lib/module/organisms/menu/menu-parts.js +20 -0
  101. package/lib/module/organisms/menu/menu-parts.js.map +1 -0
  102. package/lib/module/organisms/menu/menu.android.js +51 -19
  103. package/lib/module/organisms/menu/menu.android.js.map +1 -1
  104. package/lib/module/organisms/menu/menu.ios.js +71 -8
  105. package/lib/module/organisms/menu/menu.ios.js.map +1 -1
  106. package/lib/module/organisms/sheet/sheet.js +2 -1
  107. package/lib/module/organisms/sheet/sheet.js.map +1 -1
  108. package/lib/typescript/atoms/badge/badge.types.d.ts +2 -0
  109. package/lib/typescript/atoms/button/button-tokens.d.ts +6 -2
  110. package/lib/typescript/atoms/button/button.types.d.ts +19 -5
  111. package/lib/typescript/atoms/checkbox/checkbox.types.d.ts +2 -0
  112. package/lib/typescript/atoms/color-picker/color-picker.types.d.ts +2 -0
  113. package/lib/typescript/atoms/date-picker/date-picker.ios.d.ts +5 -5
  114. package/lib/typescript/atoms/date-picker/date-picker.types.d.ts +30 -7
  115. package/lib/typescript/atoms/divider/divider.ios.d.ts +4 -0
  116. package/lib/typescript/atoms/divider/divider.types.d.ts +13 -2
  117. package/lib/typescript/atoms/fab/fab.types.d.ts +21 -4
  118. package/lib/typescript/atoms/icon-button/icon-button.types.d.ts +23 -4
  119. package/lib/typescript/atoms/list/list.types.d.ts +15 -1
  120. package/lib/typescript/atoms/progress/progress.types.d.ts +13 -2
  121. package/lib/typescript/atoms/segmented-control/segmented-control.ios.d.ts +3 -1
  122. package/lib/typescript/atoms/segmented-control/segmented-control.types.d.ts +26 -3
  123. package/lib/typescript/atoms/segmented-control/segments.d.ts +6 -0
  124. package/lib/typescript/atoms/slider/slider.ios.d.ts +4 -0
  125. package/lib/typescript/atoms/slider/slider.types.d.ts +7 -0
  126. package/lib/typescript/atoms/switch/switch.types.d.ts +2 -0
  127. package/lib/typescript/atoms/text/spans.d.ts +2 -0
  128. package/lib/typescript/atoms/text/text.types.d.ts +9 -1
  129. package/lib/typescript/atoms/text-field/text-field-input.d.ts +7 -0
  130. package/lib/typescript/atoms/text-field/text-field.types.d.ts +77 -4
  131. package/lib/typescript/molecules/collapsible/collapsible.types.d.ts +2 -0
  132. package/lib/typescript/molecules/radio-group/radio-group.types.d.ts +4 -0
  133. package/lib/typescript/molecules/search-bar/search-bar.types.d.ts +11 -1
  134. package/lib/typescript/molecules/stepper/stepper.ios.d.ts +2 -1
  135. package/lib/typescript/molecules/stepper/stepper.types.d.ts +17 -0
  136. package/lib/typescript/organisms/action-sheet/action-sheet.ios.d.ts +4 -0
  137. package/lib/typescript/organisms/action-sheet/action-sheet.types.d.ts +8 -3
  138. package/lib/typescript/organisms/alert/alert.types.d.ts +8 -3
  139. package/lib/typescript/organisms/menu/menu-parts.d.ts +14 -0
  140. package/lib/typescript/organisms/menu/menu.types.d.ts +35 -7
  141. package/lib/typescript/organisms/sheet/sheet.types.d.ts +7 -1
  142. package/package.json +4 -4
  143. package/src/atoms/badge/badge.android.tsx +9 -2
  144. package/src/atoms/badge/badge.ios.tsx +2 -2
  145. package/src/atoms/badge/badge.types.ts +2 -0
  146. package/src/atoms/button/button-tokens.ts +6 -3
  147. package/src/atoms/button/button.android.tsx +35 -11
  148. package/src/atoms/button/button.ios.tsx +72 -8
  149. package/src/atoms/button/button.types.ts +20 -5
  150. package/src/atoms/checkbox/checkbox.android.tsx +5 -2
  151. package/src/atoms/checkbox/checkbox.ios.tsx +2 -2
  152. package/src/atoms/checkbox/checkbox.types.ts +2 -0
  153. package/src/atoms/color-picker/color-picker.android.tsx +10 -2
  154. package/src/atoms/color-picker/color-picker.ios.tsx +24 -1
  155. package/src/atoms/color-picker/color-picker.types.ts +2 -0
  156. package/src/atoms/date-picker/date-picker.android.tsx +86 -16
  157. package/src/atoms/date-picker/date-picker.ios.tsx +20 -8
  158. package/src/atoms/date-picker/date-picker.types.ts +31 -7
  159. package/src/atoms/divider/divider.android.tsx +14 -3
  160. package/src/atoms/divider/divider.ios.tsx +4 -0
  161. package/src/atoms/divider/divider.types.ts +14 -2
  162. package/src/atoms/fab/fab.android.tsx +61 -11
  163. package/src/atoms/fab/fab.ios.tsx +48 -13
  164. package/src/atoms/fab/fab.types.ts +22 -4
  165. package/src/atoms/icon-button/icon-button.android.tsx +60 -10
  166. package/src/atoms/icon-button/icon-button.ios.tsx +77 -8
  167. package/src/atoms/icon-button/icon-button.types.ts +24 -4
  168. package/src/atoms/list/list.android.tsx +26 -4
  169. package/src/atoms/list/list.ios.tsx +20 -5
  170. package/src/atoms/list/list.types.ts +16 -1
  171. package/src/atoms/progress/progress.android.tsx +32 -6
  172. package/src/atoms/progress/progress.ios.tsx +11 -5
  173. package/src/atoms/progress/progress.types.ts +13 -2
  174. package/src/atoms/segmented-control/segmented-control.android.tsx +69 -10
  175. package/src/atoms/segmented-control/segmented-control.ios.tsx +24 -5
  176. package/src/atoms/segmented-control/segmented-control.types.ts +28 -3
  177. package/src/atoms/segmented-control/segments.ts +16 -0
  178. package/src/atoms/slider/slider.android.tsx +18 -2
  179. package/src/atoms/slider/slider.ios.tsx +38 -2
  180. package/src/atoms/slider/slider.types.ts +7 -0
  181. package/src/atoms/switch/switch.android.tsx +5 -2
  182. package/src/atoms/switch/switch.ios.tsx +2 -2
  183. package/src/atoms/switch/switch.types.ts +2 -0
  184. package/src/atoms/text/spans.ts +2 -1
  185. package/src/atoms/text/text.android.tsx +20 -7
  186. package/src/atoms/text/text.ios.tsx +7 -0
  187. package/src/atoms/text/text.types.ts +10 -1
  188. package/src/atoms/text-field/text-field-input.ts +17 -0
  189. package/src/atoms/text-field/text-field.android.tsx +132 -9
  190. package/src/atoms/text-field/text-field.ios.tsx +110 -6
  191. package/src/atoms/text-field/text-field.types.ts +98 -4
  192. package/src/molecules/collapsible/collapsible.android.tsx +10 -2
  193. package/src/molecules/collapsible/collapsible.ios.tsx +8 -2
  194. package/src/molecules/collapsible/collapsible.types.ts +2 -0
  195. package/src/molecules/radio-group/radio-group.android.tsx +18 -4
  196. package/src/molecules/radio-group/radio-group.ios.tsx +19 -5
  197. package/src/molecules/radio-group/radio-group.types.ts +4 -0
  198. package/src/molecules/search-bar/search-bar.tsx +9 -0
  199. package/src/molecules/search-bar/search-bar.types.ts +12 -1
  200. package/src/molecules/stepper/stepper.android.tsx +24 -4
  201. package/src/molecules/stepper/stepper.ios.tsx +23 -8
  202. package/src/molecules/stepper/stepper.types.ts +17 -0
  203. package/src/organisms/action-sheet/action-sheet.android.tsx +23 -11
  204. package/src/organisms/action-sheet/action-sheet.ios.tsx +20 -3
  205. package/src/organisms/action-sheet/action-sheet.types.ts +8 -3
  206. package/src/organisms/alert/alert.android.tsx +11 -8
  207. package/src/organisms/alert/alert.ios.tsx +2 -1
  208. package/src/organisms/alert/alert.types.ts +8 -3
  209. package/src/organisms/context-menu/context-menu.android.tsx +3 -2
  210. package/src/organisms/context-menu/context-menu.ios.tsx +1 -0
  211. package/src/organisms/fab-menu/fab-menu.android.tsx +3 -0
  212. package/src/organisms/fab-menu/fab-menu.ios.tsx +9 -1
  213. package/src/organisms/menu/menu-parts.ts +21 -0
  214. package/src/organisms/menu/menu.android.tsx +39 -10
  215. package/src/organisms/menu/menu.ios.tsx +55 -5
  216. package/src/organisms/menu/menu.types.ts +30 -7
  217. package/src/organisms/sheet/sheet.tsx +2 -1
  218. package/src/organisms/sheet/sheet.types.ts +7 -1
@@ -1,15 +1,19 @@
1
- import {Button as SwiftUIButton} from '@expo/ui/swift-ui'
1
+ import {Button as SwiftUIButton, Label as SwiftUILabel} from '@expo/ui/swift-ui'
2
2
  import {
3
3
  buttonStyle as buttonStyleModifier,
4
+ contentShape,
4
5
  controlSize as controlSizeModifier,
5
6
  disabled as disabledModifier,
6
7
  glassEffect as glassEffectModifier,
8
+ labelStyle,
9
+ shapes,
7
10
  tint as tintModifier,
8
11
  } from '@expo/ui/swift-ui/modifiers'
9
12
  import {useAccessibilityModifiers} from '@hzblj/kara-sx'
10
- import {type ColorToken, useAppTheme} from '@hzblj/kara-theme'
13
+ import {type ColorToken, type Theme, useAppTheme} from '@hzblj/kara-theme'
11
14
  import {useMemo} from 'react'
12
15
  import {useHostGuard} from '../../host-context'
16
+ import {Icon} from '../icon'
13
17
  import {Text} from '../text'
14
18
  import {VStack} from '../v-stack'
15
19
  import type {ButtonComponent, ButtonProps, ButtonStyleName, ButtonVariant} from './button.types'
@@ -19,6 +23,7 @@ const STYLE_BY_VARIANT = {
19
23
  accent: 'borderedProminent',
20
24
  destructive: 'borderedProminent',
21
25
  glass: 'glass',
26
+ plain: 'borderless',
22
27
  secondary: 'bordered',
23
28
  } as const satisfies Record<ButtonVariant, ButtonStyleName>
24
29
 
@@ -26,14 +31,56 @@ const TINT_BY_VARIANT = {
26
31
  accent: 'fill.accent.primary',
27
32
  destructive: 'fill.destructive.primary',
28
33
  glass: undefined,
34
+ plain: undefined,
29
35
  secondary: undefined,
30
36
  } as const satisfies Record<ButtonVariant, ColorToken | undefined>
31
37
 
38
+ /** Both, wherever the button sits: a toolbar or a menu would otherwise show the icon alone. */
39
+ const TITLE_AND_ICON = [labelStyle('titleAndIcon')]
40
+
41
+ /**
42
+ * `borderless` draws nothing around the label, so the label is the button: padding takes it to a 44pt tap target,
43
+ * and a rectangle content shape makes the whole of it (the padding, and the width `wide` gives it) take the tap,
44
+ * not only the glyphs.
45
+ */
46
+ const TEXT_BUTTON_TARGET = {modifiers: [contentShape(shapes.rectangle())]}
47
+
48
+ /**
49
+ * A prominent style (and tinted glass) is coloured by the tint, so a caller's own tint picks the label on it. A text
50
+ * style has nothing to fill, so there the tint is the label's own colour.
51
+ */
52
+ const labelColor = ({
53
+ disabled,
54
+ filled,
55
+ textOnly,
56
+ theme,
57
+ tint,
58
+ tokens,
59
+ }: {
60
+ disabled: boolean
61
+ filled: boolean
62
+ textOnly: boolean
63
+ theme: Theme
64
+ tint: ColorToken | undefined
65
+ tokens: {content: ColorToken}
66
+ }): ColorToken => {
67
+ if (!tint || disabled) {
68
+ return tokens.content
69
+ }
70
+
71
+ if (filled) {
72
+ return contentOnTint(tint, theme)
73
+ }
74
+
75
+ return textOnly ? tint : tokens.content
76
+ }
77
+
32
78
  export const Button: ButtonComponent = ({
33
79
  accessibilityHint,
34
80
  buttonStyle,
35
81
  controlSize = 'large',
36
82
  disabled = false,
83
+ icon,
37
84
  label,
38
85
  onPress,
39
86
  testID,
@@ -58,8 +105,10 @@ export const Button: ButtonComponent = ({
58
105
  */
59
106
  const glassTint = style === 'glass' && resolvedTint ? theme.color(resolvedTint) : undefined
60
107
  const filled = Boolean(glassTint) || style === 'borderedProminent' || style === 'glassProminent'
61
- // A prominent style — and tinted glass — is coloured by the tint, so a caller's own tint picks the label on it.
62
- const content = tint && filled && !disabled ? contentOnTint(tint, theme) : tokens.content
108
+ const textOnly = style === 'borderless' || style === 'plain'
109
+ // Only the `plain` variant's own text button is padded out; another variant restyled `plain` keeps its layout.
110
+ const textButton = variant === 'plain' && textOnly
111
+ const content = labelColor({disabled, filled, textOnly, theme, tint, tokens})
63
112
 
64
113
  const accessibilityModifiers = useAccessibilityModifiers({accessibilityHint})
65
114
 
@@ -85,12 +134,27 @@ export const Button: ButtonComponent = ({
85
134
  return list
86
135
  }, [accessibilityModifiers, controlSize, disabled, glassTint, resolvedTint, style, theme])
87
136
 
137
+ const text = (
138
+ <Text color={content} variant="body">
139
+ {label}
140
+ </Text>
141
+ )
142
+
88
143
  return (
89
144
  <SwiftUIButton modifiers={modifiers} onPress={onPress} testID={testID}>
90
- <VStack align="center" py={glassTint ? 's16' : undefined} wide={wide}>
91
- <Text color={content} variant="body">
92
- {label}
93
- </Text>
145
+ <VStack
146
+ align="center"
147
+ ios={textButton ? TEXT_BUTTON_TARGET : undefined}
148
+ py={glassTint ? 's16' : textButton ? 's12' : undefined}
149
+ wide={wide}
150
+ >
151
+ {icon ? (
152
+ <SwiftUILabel icon={<Icon color={content} name={icon} size="s20" />} modifiers={TITLE_AND_ICON}>
153
+ {text}
154
+ </SwiftUILabel>
155
+ ) : (
156
+ text
157
+ )}
94
158
  </VStack>
95
159
  </SwiftUIButton>
96
160
  )
@@ -1,7 +1,9 @@
1
1
  import type {ColorToken} from '@hzblj/kara-theme'
2
2
  import type {ReactNode} from 'react'
3
3
 
4
- export type ButtonVariant = 'accent' | 'destructive' | 'glass' | 'secondary'
4
+ import type {IconName} from '../icon'
5
+
6
+ export type ButtonVariant = 'accent' | 'destructive' | 'glass' | 'plain' | 'secondary'
5
7
 
6
8
  /**
7
9
  * SwiftUI `ButtonStyle`. iOS only: Compose has no equivalent, so Android ignores it and keeps
@@ -22,19 +24,28 @@ export type ButtonStyleName =
22
24
  */
23
25
  export type ButtonControlSize = 'extraLarge' | 'large' | 'mini' | 'regular' | 'small'
24
26
 
25
- /** A text button: SwiftUI `Button` on iOS, Material's filled button (outlined for `glass`) on Android. */
27
+ /**
28
+ * A text button: SwiftUI `Button` on iOS, Material's filled button on Android (outlined for `glass`, a `TextButton`
29
+ * for `plain`).
30
+ */
26
31
  export type ButtonProps = {
27
32
  /** What pressing it does, when the label alone does not say. See `AccessibilityProps`. */
28
33
  accessibilityHint?: string
29
34
  /**
30
35
  * Replaces the style the variant picks: `borderedProminent` for `accent` and `destructive`, `bordered` for
31
- * `secondary`, `glass` for `glass`. iOS only. Android ignores it.
36
+ * `secondary`, `glass` for `glass`, `borderless` for `plain`. iOS only. Android ignores it.
32
37
  */
33
38
  buttonStyle?: ButtonStyleName
34
39
  /** SwiftUI's control size. `large` by default. iOS only. Android ignores it. */
35
40
  controlSize?: ButtonControlSize
36
41
  /** Draws the button in its disabled colours and stops it responding to presses. */
37
42
  disabled?: boolean
43
+ /**
44
+ * One of the app's icons, before the label and in the label's colour. iOS sets the two as a SwiftUI `Label`; Android
45
+ * puts the icon in the button's content row, as Material's buttons with an icon do. Screen readers read the label
46
+ * only.
47
+ */
48
+ icon?: IconName
38
49
  /** The button's text. */
39
50
  label: string
40
51
  /** Called when the button is pressed. */
@@ -44,10 +55,14 @@ export type ButtonProps = {
44
55
  /**
45
56
  * Overrides the variant's own tint. A prominent style takes it as the fill and picks a contrasting
46
57
  * label. `glass` has to reach the material through `glassEffect` instead, so tinting one costs it
47
- * the stock `.glass` style (see the iOS implementation).
58
+ * the stock `.glass` style (see the iOS implementation). `plain` has no fill, so there it colours the label.
48
59
  */
49
60
  tint?: ColorToken
50
- /** The look. `accent` by default. Material has no glass, so on Android `glass` is an outlined button. */
61
+ /**
62
+ * The look. `accent` by default. Material has no glass, so on Android `glass` is an outlined button. `plain` is a
63
+ * text button in the accent, with no fill or border, for a lesser action beside a prominent one ("Skip", "Forgot
64
+ * password?"): SwiftUI's `borderless` style, Material's `TextButton`.
65
+ */
51
66
  variant?: ButtonVariant
52
67
  /** Stretches the button to the full width it is offered. On by default. */
53
68
  wide?: boolean
@@ -1,5 +1,5 @@
1
1
  import {Checkbox as ComposeCheckbox, 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 {spacing, useAppTheme} from '@hzblj/kara-theme'
4
4
  import {useMemo} from 'react'
5
5
 
@@ -14,17 +14,20 @@ const NOOP = () => undefined
14
14
  * gets a handler, so a disabled or read-only box keeps the touch target an enabled one has, and lines up with it;
15
15
  * `enabled` is what stops the input.
16
16
  */
17
- export const Checkbox: CheckboxComponent = ({disabled = false, label, onValueChange, value}: CheckboxProps) => {
17
+ export const Checkbox: CheckboxComponent = ({disabled = false, label, onValueChange, testID, value}: CheckboxProps) => {
18
18
  useHostGuard('Checkbox')
19
19
  const theme = useAppTheme()
20
20
  const rowModifiers = useMemo(
21
21
  () => (disabled || !onValueChange ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => onValueChange(!value))]),
22
22
  [disabled, onValueChange, value]
23
23
  )
24
+ // On the box, not the row: the box is its own toggleable node inside the row, and the one a test reads.
25
+ const controlModifiers = useMemo(() => (testID ? [testIdModifier(testID)] : undefined), [testID])
24
26
  const control = (
25
27
  <ComposeCheckbox
26
28
  colors={{checkedColor: theme.color('fill.accent.primary'), checkmarkColor: theme.color('content.on.accent')}}
27
29
  enabled={!disabled}
30
+ modifiers={controlModifiers}
28
31
  onCheckedChange={onValueChange ?? NOOP}
29
32
  value={value}
30
33
  />
@@ -6,7 +6,7 @@ import {useMemo} from 'react'
6
6
  import {useHostGuard} from '../../host-context'
7
7
  import type {CheckboxComponent, CheckboxProps} from './checkbox.types'
8
8
 
9
- export const Checkbox: CheckboxComponent = ({disabled = false, label, onValueChange, value}: CheckboxProps) => {
9
+ export const Checkbox: CheckboxComponent = ({disabled = false, label, onValueChange, testID, value}: CheckboxProps) => {
10
10
  useHostGuard('Checkbox')
11
11
  const theme = useAppTheme()
12
12
 
@@ -20,5 +20,5 @@ export const Checkbox: CheckboxComponent = ({disabled = false, label, onValueCha
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
  }
@@ -8,6 +8,8 @@ export type CheckboxProps = {
8
8
  label?: string
9
9
  /** Called with the new value when the user toggles it. */
10
10
  onValueChange?: (value: boolean) => void
11
+ /** Test identifier, forwarded to the native control (on Android the box itself, not the labelled row). */
12
+ testID?: string
11
13
  /** Whether it is checked. Controlled: update it from `onValueChange`. */
12
14
  value: boolean
13
15
  }
@@ -9,8 +9,10 @@ import type {ColorPickerComponent, ColorPickerProps} from './color-picker.types'
9
9
  /**
10
10
  * The row SwiftUI's `ColorPicker` lays out on iOS, the label leading and the well trailing, around kara's own Compose
11
11
  * well: Compose ships no colour picker, so the sheet behind it draws the iOS picker's grid, spectrum and sliders.
12
+ * `disabled` dims the label and the well, which then does not open its sheet.
12
13
  */
13
14
  export const ColorPicker: ColorPickerComponent = ({
15
+ disabled = false,
14
16
  label,
15
17
  onValueChange,
16
18
  supportsOpacity = false,
@@ -21,9 +23,15 @@ export const ColorPicker: ColorPickerComponent = ({
21
23
 
22
24
  return (
23
25
  <HStack align="center" spacing="s12" testID={testID} wide>
24
- {label ? <Text>{label}</Text> : null}
26
+ {label ? <Text color={disabled ? 'content.tertiary' : undefined}>{label}</Text> : null}
25
27
  <Spacer />
26
- <ColorWell onValueChange={onValueChange} supportsOpacity={supportsOpacity} title={label} value={value} />
28
+ <ColorWell
29
+ enabled={!disabled}
30
+ onValueChange={onValueChange}
31
+ supportsOpacity={supportsOpacity}
32
+ title={label}
33
+ value={value}
34
+ />
27
35
  </HStack>
28
36
  )
29
37
  }
@@ -1,9 +1,19 @@
1
1
  import {ColorPicker as SwiftUIColorPicker} from '@expo/ui/swift-ui'
2
+ import {disabled as disabledModifier, labelsHidden, opacity as opacityModifier} from '@expo/ui/swift-ui/modifiers'
3
+ import {opacity} from '@hzblj/kara-theme'
2
4
 
3
5
  import {useHostGuard} from '../../host-context'
6
+ import {HStack} from '../h-stack'
7
+ import {Spacer} from '../spacer'
8
+ import {Text} from '../text'
4
9
  import type {ColorPickerComponent, ColorPickerProps} from './color-picker.types'
5
10
 
11
+ // iOS 27 stops a disabled picker but draws its label and well at full strength: the label is then kara's own text,
12
+ // dimmed, and the well takes the opacity.
13
+ const DISABLED = [labelsHidden(), disabledModifier(true), opacityModifier(opacity.o40)]
14
+
6
15
  export const ColorPicker: ColorPickerComponent = ({
16
+ disabled = false,
7
17
  label,
8
18
  onValueChange,
9
19
  supportsOpacity = false,
@@ -12,13 +22,26 @@ export const ColorPicker: ColorPickerComponent = ({
12
22
  }: ColorPickerProps) => {
13
23
  useHostGuard('ColorPicker')
14
24
 
15
- return (
25
+ const picker = (
16
26
  <SwiftUIColorPicker
17
27
  label={label}
28
+ modifiers={disabled ? DISABLED : undefined}
18
29
  onSelectionChange={onValueChange}
19
30
  selection={value}
20
31
  supportsOpacity={supportsOpacity}
21
32
  testID={testID}
22
33
  />
23
34
  )
35
+
36
+ if (!disabled) {
37
+ return picker
38
+ }
39
+
40
+ return (
41
+ <HStack align="center" spacing="s12" wide>
42
+ {label ? <Text color="content.tertiary">{label}</Text> : null}
43
+ <Spacer />
44
+ {picker}
45
+ </HStack>
46
+ )
24
47
  }
@@ -6,6 +6,8 @@ import type {ReactNode} from 'react'
6
6
  * spectrum and RGB sliders.
7
7
  */
8
8
  export type ColorPickerProps = {
9
+ /** Stops the user picking a colour, and dims the label and the well. */
10
+ disabled?: boolean
9
11
  /** Text beside the colour well, and the Android sheet's title. */
10
12
  label?: string
11
13
  /** Called with the colour the user picks. */
@@ -1,7 +1,8 @@
1
1
  import {DateTimePicker} from '@expo/ui/community/datetime-picker'
2
- import {RNHostView} from '@expo/ui/jetpack-compose'
3
- import {useAppTheme} from '@hzblj/kara-theme'
4
- import {useCallback, useState} from 'react'
2
+ import {DateTimePicker as ComposeDateTimePicker, Host, RNHostView} from '@expo/ui/jetpack-compose'
3
+ import {alpha, testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
4
+ import {opacity, useAppTheme} from '@hzblj/kara-theme'
5
+ import {useCallback, useMemo, useState} from 'react'
5
6
 
6
7
  import {useInsideHost} from '../../host-context'
7
8
  import {Box} from '../box'
@@ -9,6 +10,8 @@ import {Pressable} from '../pressable'
9
10
  import {RnText} from '../rn-text'
10
11
  import type {DatePickerComponent, DatePickerMode, DatePickerProps} from './date-picker.types'
11
12
 
13
+ const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
14
+
12
15
  const formatted = (value: Date, mode: DatePickerMode): string => {
13
16
  if (mode === 'time') {
14
17
  return value.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'})
@@ -21,20 +24,68 @@ const formatted = (value: Date, mode: DatePickerMode): string => {
21
24
  : date
22
25
  }
23
26
 
27
+ /** Material's calendar counts in UTC days: `value`'s local day goes in as that day's UTC midnight. */
28
+ const utcDayOf = (value: Date): string =>
29
+ new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate())).toISOString()
30
+
31
+ /** The UTC day the calendar reports, back on `value`'s local time of day. */
32
+ const localDayOf = (utcDay: Date, value: Date): Date => {
33
+ const next = new Date(value)
34
+ next.setFullYear(utcDay.getUTCFullYear(), utcDay.getUTCMonth(), utcDay.getUTCDate())
35
+
36
+ return next
37
+ }
38
+
39
+ /**
40
+ * Material's calendar in place, for `graphical`: the month and its days, without the title and headline its dialog
41
+ * shows, in the accent. It also reports its day when it first shows and whenever `value` hands it a new one, so only a
42
+ * day that differs from `value` goes out. It has no disabled state, so a disabled one is dimmed and offers only the
43
+ * selected day.
44
+ */
45
+ const Calendar = ({disabled, max, min, onValueChange, testID, value}: DatePickerProps) => {
46
+ const theme = useAppTheme()
47
+
48
+ const onDateSelected = useCallback(
49
+ (date: Date) => {
50
+ const next = localDayOf(date, value)
51
+
52
+ if (!disabled && next.getTime() !== value.getTime()) {
53
+ onValueChange?.(next)
54
+ }
55
+ },
56
+ [disabled, onValueChange, value]
57
+ )
58
+
59
+ const modifiers = useMemo(
60
+ () => [...(disabled ? [alpha(opacity.o40)] : []), ...(testID ? [testIdModifier(testID)] : [])],
61
+ [disabled, testID]
62
+ )
63
+
64
+ return (
65
+ <ComposeDateTimePicker
66
+ color={theme.color('fill.accent.primary')}
67
+ displayedComponents="date"
68
+ initialDate={utcDayOf(value)}
69
+ modifiers={modifiers}
70
+ onDateSelected={onDateSelected}
71
+ selectableDates={disabled ? {end: value, start: value} : min || max ? {end: max, start: min} : undefined}
72
+ showHeadline={false}
73
+ showTitle={false}
74
+ showVariantToggle={false}
75
+ variant="picker"
76
+ />
77
+ )
78
+ }
79
+
24
80
  /**
25
- * The value as a field that opens Material's date picker dialog on a tap. Android's picker *is* a dialog — mounted, it
26
- * opens — so it is mounted only while open, and unmounted on confirm or dismiss, as the component asks its caller to.
81
+ * The value as a field that opens Material's date picker dialog on a tap. Android's picker *is* a dialog (mounted, it
82
+ * opens), so it is mounted only while open, and unmounted on confirm or dismiss, as the component asks its caller to.
27
83
  * Both are React Native views; inside a `Screen` or `Island` `RNHostView` carries them into that host, sized to the
28
- * field (see `useInsideHost`).
84
+ * field (see `useInsideHost`). With `datePickerStyle="graphical"` and a date to pick, it is Material's calendar in
85
+ * place instead: a Compose view, in a host of its own the width of its container, or straight in the enclosing one.
29
86
  */
30
- export const DatePicker: DatePickerComponent = ({
31
- max,
32
- min,
33
- mode = 'date',
34
- onValueChange,
35
- testID,
36
- value,
37
- }: DatePickerProps) => {
87
+ export const DatePicker: DatePickerComponent = (props: DatePickerProps) => {
88
+ const {datePickerStyle = 'compact', disabled = false, max, min, mode = 'date', onValueChange, testID, value} = props
38
89
  const theme = useAppTheme()
39
90
  const insideHost = useInsideHost()
40
91
  const [open, setOpen] = useState(false)
@@ -50,20 +101,39 @@ export const DatePicker: DatePickerComponent = ({
50
101
  [onValueChange]
51
102
  )
52
103
 
104
+ if (datePickerStyle === 'graphical' && mode !== 'time') {
105
+ const calendar = <Calendar {...props} disabled={disabled} />
106
+
107
+ return insideHost ? (
108
+ calendar
109
+ ) : (
110
+ <Host
111
+ colorScheme={theme.dark ? 'dark' : 'light'}
112
+ matchContents={MATCH_HEIGHT_ONLY}
113
+ seedColor={theme.materialSeed}
114
+ >
115
+ {calendar}
116
+ </Host>
117
+ )
118
+ }
119
+
53
120
  const field = (
54
121
  <>
55
122
  <Pressable
56
123
  alignSelf="flex-start"
57
124
  bg="fill.tertiary"
58
125
  borderRadius="medium"
126
+ disabled={disabled}
59
127
  onPress={show}
60
128
  px="s12"
61
129
  py="s8"
62
130
  testID={testID}
63
131
  >
64
- <RnText variant="body">{formatted(value, mode)}</RnText>
132
+ <RnText color={disabled ? 'content.tertiary' : 'content.primary'} variant="body">
133
+ {formatted(value, mode)}
134
+ </RnText>
65
135
  </Pressable>
66
- {open ? (
136
+ {open && !disabled ? (
67
137
  <DateTimePicker
68
138
  accentColor={theme.colorString('fill.accent.primary')}
69
139
  maximumDate={max}
@@ -1,5 +1,10 @@
1
1
  import {Host, DatePicker as SwiftUIDatePicker} from '@expo/ui/swift-ui'
2
- import {datePickerStyle, labelsHidden, tint} from '@expo/ui/swift-ui/modifiers'
2
+ import {
3
+ datePickerStyle as datePickerStyleModifier,
4
+ disabled as disabledModifier,
5
+ labelsHidden,
6
+ tint,
7
+ } from '@expo/ui/swift-ui/modifiers'
3
8
  import {useAppTheme} from '@hzblj/kara-theme'
4
9
  import {useMemo} from 'react'
5
10
 
@@ -13,13 +18,15 @@ const COMPONENTS = {
13
18
  } as const satisfies Record<DatePickerMode, ('date' | 'hourAndMinute')[]>
14
19
 
15
20
  /**
16
- * The system's compact date picker: the value in a pill, and a tap on it opens the calendar popover UIKit owns. Its own
17
- * host, sized to the pill on both axes — a host that took the width it was given would lay the pill out against the
18
- * far edge of it, or past its container's — so it drops into a row the way a `Text` does. The label is the caller's,
19
- * beside it; the calendar is in the accent. Inside a `Screen` or `Island` it renders into that host instead (see
20
- * `useInsideHost`).
21
+ * The system's date picker, in the accent. `compact` by default: the value in a pill, and a tap on it opens the
22
+ * calendar popover UIKit owns. `graphical` is that calendar in place, `wheel` the value on wheels. Its own host, sized
23
+ * to the picker on both axes (a host that took the width it was given would lay the pill out against the far edge of
24
+ * it, or past its container's), so it drops into a row the way a `Text` does. The label is the caller's, beside it.
25
+ * Inside a `Screen` or `Island` it renders into that host instead (see `useInsideHost`).
21
26
  */
22
27
  export const DatePicker: DatePickerComponent = ({
28
+ datePickerStyle = 'compact',
29
+ disabled = false,
23
30
  max,
24
31
  min,
25
32
  mode = 'date',
@@ -31,8 +38,13 @@ export const DatePicker: DatePickerComponent = ({
31
38
  const insideHost = useInsideHost()
32
39
 
33
40
  const modifiers = useMemo(
34
- () => [datePickerStyle('compact'), labelsHidden(), tint(theme.color('fill.accent.primary'))],
35
- [theme]
41
+ () => [
42
+ datePickerStyleModifier(datePickerStyle),
43
+ labelsHidden(),
44
+ tint(theme.color('fill.accent.primary')),
45
+ ...(disabled ? [disabledModifier(true)] : []),
46
+ ],
47
+ [datePickerStyle, disabled, theme]
36
48
  )
37
49
 
38
50
  const picker = (
@@ -2,24 +2,48 @@ import type {ReactNode} from 'react'
2
2
 
3
3
  export type DatePickerMode = 'date' | 'datetime' | 'time'
4
4
 
5
+ /** How SwiftUI shows the picker: a pill that opens the calendar, the calendar in place, or wheels. */
6
+ export type DatePickerStyleName = 'compact' | 'graphical' | 'wheel'
7
+
5
8
  /**
6
- * A date or time picker: SwiftUI's compact `DatePicker` on iOS, a field that opens Material's picker dialog on a tap
7
- * on Android. The React Native tier: it brings its own host and sizes itself to the value, so it sits in a row beside
8
- * its label like a `Text`. Inside a `Screen` or `Island` it joins that host instead, beside a native `Text` label.
9
+ * A date or time picker: SwiftUI's `DatePicker` on iOS (a pill by default, a calendar or wheels with
10
+ * `datePickerStyle`), a field that opens Material's picker dialog on a tap on Android, or Material's calendar in place
11
+ * with `datePickerStyle="graphical"`. The React Native tier: it brings its own host and sizes itself to the value, so it
12
+ * sits in a row beside its label like a `Text`. Inside a `Screen` or `Island` it joins that host instead, beside a
13
+ * native `Text` label.
9
14
  */
10
15
  export type DatePickerProps = {
16
+ /**
17
+ * SwiftUI's date picker style. `compact` by default: the value in a pill, which opens the calendar in a popover.
18
+ * `graphical` is the calendar in place, a month at a time, with the time under it in `datetime` mode: what a screen
19
+ * built around picking a day wants. `wheel` puts the value on wheels. Android shows Material's calendar in place for
20
+ * `graphical` in `date` and `datetime` modes (without its title and headline); otherwise it keeps its field and
21
+ * dialog, since Material has no wheel.
22
+ */
23
+ datePickerStyle?: DatePickerStyleName
24
+ /**
25
+ * Stops the user changing the date, and dims the picker. Material's calendar has no disabled state of its own, so a
26
+ * `graphical` picker on Android is dimmed and offers only the selected day.
27
+ */
28
+ disabled?: boolean
11
29
  /** The latest date that can be picked. Android ignores it in `time` mode. */
12
30
  max?: Date
13
31
  /** The earliest date that can be picked. Android ignores it in `time` mode. */
14
32
  min?: Date
15
33
  /**
16
- * What it picks. `date` by default. Android has no combined dialog, so there `datetime` picks the date and keeps
17
- * `value`'s time of day.
34
+ * What it picks. `date` by default. Android has no combined dialog or calendar, so there `datetime` picks the date
35
+ * and keeps `value`'s time of day.
18
36
  */
19
37
  mode?: DatePickerMode
20
- /** Called with the new date: on iOS as it changes, on Android when the dialog is confirmed. */
38
+ /**
39
+ * Called with the new date: on iOS as it changes, on Android when the dialog is confirmed or a day in the calendar
40
+ * is tapped.
41
+ */
21
42
  onValueChange?: (date: Date) => void
22
- /** Test identifier, on the native picker on iOS and on the tappable field on Android. */
43
+ /**
44
+ * Test identifier, on the native picker on iOS and on Android's calendar, and on the tappable field elsewhere on
45
+ * Android.
46
+ */
23
47
  testID?: string
24
48
  /** The selected date. Controlled: update it from `onValueChange`. */
25
49
  value: Date
@@ -1,12 +1,23 @@
1
- import {HorizontalDivider} from '@expo/ui/jetpack-compose'
1
+ import {HorizontalDivider, VerticalDivider} from '@expo/ui/jetpack-compose'
2
+ import {testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
2
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
+ import {useMemo} from 'react'
3
5
 
4
6
  import {useHostGuard} from '../../host-context'
5
7
  import type {DividerComponent, DividerProps} from './divider.types'
6
8
 
7
- export const Divider: DividerComponent = ({color = 'border.tertiary'}: DividerProps) => {
9
+ /**
10
+ * Compose's dividers. Unlike SwiftUI's, a Compose divider does not turn with its stack, so `axis` picks the one:
11
+ * `VerticalDivider` fills the height of the `Row` around it.
12
+ */
13
+ export const Divider: DividerComponent = ({axis = 'horizontal', color = 'border.tertiary', testID}: DividerProps) => {
8
14
  useHostGuard('Divider')
9
15
  const theme = useAppTheme()
16
+ const modifiers = useMemo(() => (testID ? [testIdModifier(testID)] : undefined), [testID])
10
17
 
11
- return <HorizontalDivider color={theme.color(color)} />
18
+ return axis === 'vertical' ? (
19
+ <VerticalDivider color={theme.color(color)} modifiers={modifiers} />
20
+ ) : (
21
+ <HorizontalDivider color={theme.color(color)} modifiers={modifiers} />
22
+ )
12
23
  }
@@ -6,6 +6,10 @@ import {useMemo} from 'react'
6
6
  import {useHostGuard} from '../../host-context'
7
7
  import type {DividerComponent, DividerProps} from './divider.types'
8
8
 
9
+ /**
10
+ * SwiftUI's divider, which turns with its stack: vertical in an `HStack`, horizontal anywhere else. So `axis`, which
11
+ * Android needs to pick its divider, has nothing to do here.
12
+ */
9
13
  export const Divider: DividerComponent = ({color = 'border.tertiary', testID}: DividerProps) => {
10
14
  useHostGuard('Divider')
11
15
  const theme = useAppTheme()
@@ -1,11 +1,23 @@
1
1
  import type {ColorToken} from '@hzblj/kara-theme'
2
2
  import type {ReactNode} from 'react'
3
3
 
4
- /** A horizontal hairline: SwiftUI `Divider` / Compose `HorizontalDivider`. */
4
+ /** Which way the line runs. */
5
+ export type DividerAxis = 'horizontal' | 'vertical'
6
+
7
+ /**
8
+ * A hairline between views: SwiftUI `Divider` / Compose `HorizontalDivider`, or `VerticalDivider` with
9
+ * `axis="vertical"`.
10
+ */
5
11
  export type DividerProps = {
12
+ /**
13
+ * Which way the line runs. `horizontal` by default: across a `VStack`. `vertical` stands it up between the children
14
+ * of an `HStack`, as tall as the stack, so give the stack a height (`h`). iOS ignores it: SwiftUI's divider turns by
15
+ * itself, vertical in an `HStack` and horizontal anywhere else. Set it anyway, so Android draws the same.
16
+ */
17
+ axis?: DividerAxis
6
18
  /** The line's colour. `border.tertiary` by default. */
7
19
  color?: ColorToken
8
- /** Test identifier, forwarded to the native view on iOS. Android ignores it. */
20
+ /** Test identifier, forwarded to the native view. */
9
21
  testID?: string
10
22
  }
11
23