@hzblj/kara-components 0.7.0 → 0.8.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 (117) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +5 -5
  3. package/lib/module/atoms/activity-indicator/activity-indicator.android.js +42 -0
  4. package/lib/module/atoms/activity-indicator/activity-indicator.android.js.map +1 -0
  5. package/lib/module/atoms/activity-indicator/activity-indicator.ios.js +19 -0
  6. package/lib/module/atoms/activity-indicator/activity-indicator.ios.js.map +1 -0
  7. package/lib/module/atoms/activity-indicator/activity-indicator.types.js +4 -0
  8. package/lib/module/atoms/activity-indicator/activity-indicator.types.js.map +1 -0
  9. package/lib/module/atoms/activity-indicator/index.js +2 -1
  10. package/lib/module/atoms/activity-indicator/index.js.map +1 -1
  11. package/lib/module/atoms/avatar/avatar.js +2 -4
  12. package/lib/module/atoms/avatar/avatar.js.map +1 -1
  13. package/lib/module/atoms/blur-view/blur-view.android.js +100 -0
  14. package/lib/module/atoms/blur-view/blur-view.android.js.map +1 -0
  15. package/lib/module/atoms/blur-view/blur-view.ios.js +43 -0
  16. package/lib/module/atoms/blur-view/blur-view.ios.js.map +1 -0
  17. package/lib/module/atoms/blur-view/blur-view.types.js +4 -0
  18. package/lib/module/atoms/blur-view/blur-view.types.js.map +1 -0
  19. package/lib/module/atoms/blur-view/index.js +5 -0
  20. package/lib/module/atoms/blur-view/index.js.map +1 -0
  21. package/lib/module/atoms/glass-view/glass-view.android.js +36 -5
  22. package/lib/module/atoms/glass-view/glass-view.android.js.map +1 -1
  23. package/lib/module/atoms/image/image.js +13 -6
  24. package/lib/module/atoms/image/image.js.map +1 -1
  25. package/lib/module/atoms/image/image.types.js +4 -0
  26. package/lib/module/atoms/image/image.types.js.map +1 -0
  27. package/lib/module/atoms/image/index.js +1 -0
  28. package/lib/module/atoms/image/index.js.map +1 -1
  29. package/lib/module/atoms/index.js +1 -0
  30. package/lib/module/atoms/index.js.map +1 -1
  31. package/lib/module/atoms/island/island.android.js +6 -2
  32. package/lib/module/atoms/island/island.android.js.map +1 -1
  33. package/lib/module/atoms/island/island.ios.js +5 -2
  34. package/lib/module/atoms/island/island.ios.js.map +1 -1
  35. package/lib/module/atoms/pressable/pressable.js +38 -6
  36. package/lib/module/atoms/pressable/pressable.js.map +1 -1
  37. package/lib/module/atoms/rn-text/rn-text.js +5 -2
  38. package/lib/module/atoms/rn-text/rn-text.js.map +1 -1
  39. package/lib/module/atoms/skeleton/skeleton.js +5 -5
  40. package/lib/module/atoms/skeleton/skeleton.js.map +1 -1
  41. package/lib/module/atoms/text/text.android.js +5 -2
  42. package/lib/module/atoms/text/text.android.js.map +1 -1
  43. package/lib/module/atoms/text/text.ios.js +14 -3
  44. package/lib/module/atoms/text/text.ios.js.map +1 -1
  45. package/lib/module/molecules/collapsible/collapsible.android.js +37 -7
  46. package/lib/module/molecules/collapsible/collapsible.android.js.map +1 -1
  47. package/lib/module/molecules/navigation-bar/navigation-bar.android.js +2 -8
  48. package/lib/module/molecules/navigation-bar/navigation-bar.android.js.map +1 -1
  49. package/lib/module/molecules/pager/pager.android.js +5 -4
  50. package/lib/module/molecules/pager/pager.android.js.map +1 -1
  51. package/lib/module/molecules/scroll-edge-container/scroll-edge-container.android.js +114 -11
  52. package/lib/module/molecules/scroll-edge-container/scroll-edge-container.android.js.map +1 -1
  53. package/lib/module/molecules/stepper/stepper.ios.js +45 -8
  54. package/lib/module/molecules/stepper/stepper.ios.js.map +1 -1
  55. package/lib/module/ripple-clip.js +9 -1
  56. package/lib/module/ripple-clip.js.map +1 -1
  57. package/lib/typescript/atoms/activity-indicator/activity-indicator.d.ts +1 -3
  58. package/lib/typescript/atoms/activity-indicator/activity-indicator.ios.d.ts +3 -0
  59. package/lib/typescript/atoms/activity-indicator/activity-indicator.types.d.ts +17 -0
  60. package/lib/typescript/atoms/activity-indicator/index.d.ts +1 -0
  61. package/lib/typescript/atoms/blur-view/blur-view.d.ts +1 -0
  62. package/lib/typescript/atoms/blur-view/blur-view.ios.d.ts +3 -0
  63. package/lib/typescript/atoms/blur-view/blur-view.types.d.ts +31 -0
  64. package/lib/typescript/atoms/blur-view/index.d.ts +2 -0
  65. package/lib/typescript/atoms/box/a-box.d.ts +1 -1
  66. package/lib/typescript/atoms/glass-view/glass-view.types.d.ts +12 -5
  67. package/lib/typescript/atoms/image/image.d.ts +12 -4
  68. package/lib/typescript/atoms/image/image.types.d.ts +4 -0
  69. package/lib/typescript/atoms/image/index.d.ts +1 -0
  70. package/lib/typescript/atoms/index.d.ts +1 -0
  71. package/lib/typescript/atoms/island/island.types.d.ts +8 -4
  72. package/lib/typescript/atoms/pressable/pressable.types.d.ts +5 -3
  73. package/lib/typescript/atoms/rn-text/rn-text.types.d.ts +7 -2
  74. package/lib/typescript/atoms/skeleton/skeleton.types.d.ts +8 -4
  75. package/lib/typescript/atoms/text/text.types.d.ts +7 -2
  76. package/lib/typescript/molecules/scroll-edge-container/scroll-edge-container.types.d.ts +6 -4
  77. package/lib/typescript/molecules/stepper/stepper.ios.d.ts +5 -0
  78. package/lib/typescript/molecules/stepper/stepper.types.d.ts +1 -4
  79. package/package.json +5 -5
  80. package/src/atoms/activity-indicator/activity-indicator.android.tsx +32 -0
  81. package/src/atoms/activity-indicator/activity-indicator.ios.tsx +15 -0
  82. package/src/atoms/activity-indicator/activity-indicator.types.ts +20 -0
  83. package/src/atoms/activity-indicator/index.ts +1 -0
  84. package/src/atoms/avatar/avatar.tsx +1 -1
  85. package/src/atoms/blur-view/blur-view.android.tsx +68 -0
  86. package/src/atoms/blur-view/blur-view.ios.tsx +37 -0
  87. package/src/atoms/blur-view/blur-view.types.ts +34 -0
  88. package/src/atoms/blur-view/index.ts +2 -0
  89. package/src/atoms/glass-view/glass-view.android.tsx +41 -3
  90. package/src/atoms/glass-view/glass-view.types.ts +12 -5
  91. package/src/atoms/image/image.tsx +11 -12
  92. package/src/atoms/image/image.types.ts +6 -0
  93. package/src/atoms/image/index.ts +1 -0
  94. package/src/atoms/index.ts +1 -0
  95. package/src/atoms/island/island.android.tsx +10 -2
  96. package/src/atoms/island/island.ios.tsx +4 -2
  97. package/src/atoms/island/island.types.ts +8 -4
  98. package/src/atoms/pressable/pressable.tsx +46 -6
  99. package/src/atoms/pressable/pressable.types.ts +5 -3
  100. package/src/atoms/rn-text/rn-text.tsx +6 -3
  101. package/src/atoms/rn-text/rn-text.types.ts +15 -9
  102. package/src/atoms/skeleton/skeleton.tsx +4 -4
  103. package/src/atoms/skeleton/skeleton.types.ts +8 -4
  104. package/src/atoms/text/text.android.tsx +7 -1
  105. package/src/atoms/text/text.ios.tsx +14 -3
  106. package/src/atoms/text/text.types.ts +7 -2
  107. package/src/molecules/collapsible/collapsible.android.tsx +47 -7
  108. package/src/molecules/navigation-bar/navigation-bar.android.tsx +1 -6
  109. package/src/molecules/pager/pager.android.tsx +4 -1
  110. package/src/molecules/scroll-edge-container/scroll-edge-container.android.tsx +103 -10
  111. package/src/molecules/scroll-edge-container/scroll-edge-container.types.ts +6 -4
  112. package/src/molecules/stepper/stepper.ios.tsx +37 -1
  113. package/src/molecules/stepper/stepper.types.ts +1 -4
  114. package/src/ripple-clip.ts +10 -1
  115. package/lib/module/atoms/activity-indicator/activity-indicator.js +0 -14
  116. package/lib/module/atoms/activity-indicator/activity-indicator.js.map +0 -1
  117. package/src/atoms/activity-indicator/activity-indicator.tsx +0 -9
@@ -0,0 +1,37 @@
1
+ import {BlurView as NativeBlurView} from '@hzblj/kara-native/swift-ui'
2
+ import {useSx} from '@hzblj/kara-sx'
3
+ import {useAppTheme} from '@hzblj/kara-theme'
4
+ import {useMemo} from 'react'
5
+ import {StyleSheet} from 'react-native'
6
+
7
+ import type {BlurViewComponent, BlurViewProps} from './blur-view.types'
8
+
9
+ /** `borderRadius` is the material's shape, so it goes to the effect view — the one that clips — not the layout box. */
10
+ export const BlurView: BlurViewComponent = ({
11
+ appearance,
12
+ children,
13
+ material = 'regular',
14
+ testID,
15
+ vibrancy,
16
+ ...sx
17
+ }: BlurViewProps) => {
18
+ const theme = useAppTheme()
19
+ const resolved = useSx(sx)
20
+ const {cornerRadius, style} = useMemo(() => {
21
+ const {borderRadius, ...rest} = StyleSheet.flatten(resolved) ?? {}
22
+ return {cornerRadius: typeof borderRadius === 'number' ? borderRadius : 0, style: rest}
23
+ }, [resolved])
24
+
25
+ return (
26
+ <NativeBlurView
27
+ colorScheme={appearance ?? (theme.dark ? 'dark' : 'light')}
28
+ cornerRadius={cornerRadius}
29
+ material={material}
30
+ style={style}
31
+ testID={testID}
32
+ vibrancy={vibrancy}
33
+ >
34
+ {children}
35
+ </NativeBlurView>
36
+ )
37
+ }
@@ -0,0 +1,34 @@
1
+ import type {BlurMaterial, BlurVibrancy} from '@hzblj/kara-native/swift-ui'
2
+ import type {ResponsiveViewSx} from '@hzblj/kara-sx'
3
+ import type {ReactNode} from 'react'
4
+
5
+ export type {BlurMaterial, BlurVibrancy} from '@hzblj/kara-native/swift-ui'
6
+
7
+ /**
8
+ * One of the system's materials behind React Native content — what sheets, bars, menus and HUDs are made of, in
9
+ * SwiftUI's five thicknesses. iOS: UIKit's `system*Material` blur, with optional vibrancy for the content. Android:
10
+ * what lies behind, blurred and saturated (Android 12+), under a Material tonal surface as thick as the material —
11
+ * `surface` at `ultraThin` up to `surfaceContainerHighest` at `ultraThick`; below Android 12 the surface alone. The
12
+ * React Native tier: it needs no host, so it suits a list cell. Shape it with `borderRadius`; it clips its children.
13
+ */
14
+ export type BlurViewProps = ResponsiveViewSx & {
15
+ /**
16
+ * Pins the material to light or dark whatever the app's appearance — a dark HUD over a light screen. Unset, it follows
17
+ * the app theme. Android draws the opposite appearance with Material's inverse surface.
18
+ */
19
+ appearance?: 'dark' | 'light'
20
+ /** React Native content, over the material. */
21
+ children?: ReactNode
22
+ /** How much of what lies behind shows through, from `ultraThin` (most) to `ultraThick` (least). `regular` by default. */
23
+ material?: BlurMaterial
24
+ /** Test identifier, forwarded to the view. */
25
+ testID?: string
26
+ /**
27
+ * iOS: draws the children vibrant at this level, the way iOS draws its own labels and fills on a material — their
28
+ * colour becomes the material's vibrant one and their alpha the mask. Android has no vibrancy: the children keep
29
+ * their colours there.
30
+ */
31
+ vibrancy?: BlurVibrancy
32
+ }
33
+
34
+ export type BlurViewComponent = (props: BlurViewProps) => ReactNode
@@ -0,0 +1,2 @@
1
+ export * from './blur-view'
2
+ export * from './blur-view.types'
@@ -1,5 +1,43 @@
1
- import {GlassFallback} from './glass-fallback'
1
+ import {BackdropBlurView} from '@hzblj/kara-native/jetpack-compose'
2
+ import {useSx} from '@hzblj/kara-sx'
3
+ import {radii, useAppTheme, withOpacity} from '@hzblj/kara-theme'
4
+
2
5
  import type {GlassViewComponent, GlassViewProps} from './glass-view.types'
3
6
 
4
- /** Android has no Liquid Glass, so this is always the fallback capsule. */
5
- export const GlassView: GlassViewComponent = (props: GlassViewProps) => <GlassFallback {...props} />
7
+ /** dp. Strong enough that a photo behind reads as colour and light, not as detail. */
8
+ const BLUR_RADIUS = 16
9
+
10
+ /**
11
+ * Frosted glass, Android's way: what lies behind the capsule, blurred (Android 12+), under a tonal Material surface
12
+ * and a light hairline — the blurred surfaces Android 16 gives its own system UI, not a copy of iOS's lens. Without a
13
+ * tint it is the surface colour, translucent, so the content keeps its usual colours on it; a tint fills it with that
14
+ * colour instead. Below Android 12, with nothing to blur, the same colours are drawn near-opaque. Android has no swell,
15
+ * so `interactive` changes nothing here — the `Pressable` around it gives the press its feedback.
16
+ */
17
+ export const GlassView: GlassViewComponent = ({
18
+ children,
19
+ interactive: _interactive,
20
+ testID,
21
+ tint,
22
+ ...sx
23
+ }: GlassViewProps) => {
24
+ const theme = useAppTheme()
25
+ const style = useSx(sx)
26
+ const base = String(theme.color(tint ?? 'surface.primary'))
27
+
28
+ return (
29
+ <BackdropBlurView
30
+ blurRadius={BLUR_RADIUS}
31
+ cornerRadius={radii.full}
32
+ fallbackTint={withOpacity(base, 'o95')}
33
+ glassTint={withOpacity(base, tint ? 'o85' : 'o50')}
34
+ hairlineColor={withOpacity(String(theme.color('content.on.bg')), theme.dark ? 'o20' : 'o60')}
35
+ hairlineWidth={1}
36
+ saturation={1.6}
37
+ style={style}
38
+ testID={testID}
39
+ >
40
+ {children}
41
+ </BackdropBlurView>
42
+ )
43
+ }
@@ -3,18 +3,25 @@ import type {ColorToken} from '@hzblj/kara-theme'
3
3
  import type {ReactNode} from 'react'
4
4
 
5
5
  /**
6
- * iOS 26 Liquid Glass around a React Native subtree, shaped as a capsule — the React Native tier, so it needs no host
7
- * and costs next to nothing in a list cell. Where there is no Liquid Glass (Android, and iOS before 26) it draws a
8
- * capsule of `tint` instead, or of the glass buttons' translucent fill (`btn.bg.glass.base`) when there is none.
6
+ * Glass around a React Native subtree, shaped as a capsule — the React Native tier, so it needs no host. iOS 26:
7
+ * Liquid Glass. Android: frosted glass — what lies behind, blurred on Android 12+, under a translucent tonal surface
8
+ * (or `tint`) and a hairline. iOS before 26 has no Liquid Glass and draws a capsule of `tint` instead, or of the glass
9
+ * buttons' translucent fill (`btn.bg.glass.base`) when there is none.
10
+ *
11
+ * On Android each glass re-records what lies behind it before every frame: a few buttons cost nothing, but keep it out
12
+ * of the cells of a long list — a plain `tint` reads as well there.
9
13
  */
10
14
  export type GlassViewProps = ResponsiveViewSx & {
11
15
  /** React Native views. Give them `pointerEvents="none"` when `interactive`, so the press reaches the `Pressable`. */
12
16
  children?: ReactNode
13
- /** Swells toward the finger on touch, carrying the children with it. Wrap the view in a `Pressable` for the press. */
17
+ /**
18
+ * iOS 26: swells toward the finger on touch, carrying the children with it. Android has no swell: wrap the view in a
19
+ * `Pressable` with `borderRadius="full"`, whose ripple then keeps to the capsule. The press is always the `Pressable`'s.
20
+ */
14
21
  interactive?: boolean
15
22
  /** Test identifier, forwarded to the view. */
16
23
  testID?: string
17
- /** Tints the material. Unset leaves it clear. */
24
+ /** Tints the material. Unset leaves it clear on iOS, and the translucent surface colour on Android. */
18
25
  tint?: ColorToken
19
26
  }
20
27
 
@@ -1,15 +1,14 @@
1
+ import {sx} from '@hzblj/kara-sx'
1
2
  import {duration} from '@hzblj/kara-theme'
2
- import {Image as ExpoImage, ImageProps} from 'expo-image'
3
- import {FC} from 'react'
3
+ import {Image as ExpoImage, type ImageProps as ExpoImageProps} from 'expo-image'
4
+ import type {Ref} from 'react'
4
5
 
5
- const Image: FC<ImageProps> = props => (
6
- <ExpoImage
7
- transition={{
8
- duration: duration.d100,
9
- timing: 'linear',
10
- }}
11
- {...props}
12
- />
13
- )
6
+ const FADE = {duration: duration.d100, timing: 'linear'} as const
14
7
 
15
- export {Image}
8
+ const FadingImage = (props: ExpoImageProps & {ref?: Ref<ExpoImage>}) => <ExpoImage transition={FADE} {...props} />
9
+
10
+ /**
11
+ * `expo-image` with the `sx` props, the way `Box` is a `View` with them, and the design system's 100 ms linear fade
12
+ * already applied, so a loaded image never pops in. An explicit `transition` wins, and so does an explicit `style`.
13
+ */
14
+ export const Image = sx(FadingImage)
@@ -0,0 +1,6 @@
1
+ import type {ComponentProps} from 'react'
2
+
3
+ import type {Image} from './image'
4
+
5
+ /** Derived from the component itself, so it cannot drift from what `sx(Image)` actually accepts. */
6
+ export type ImageProps = ComponentProps<typeof Image>
@@ -1 +1,2 @@
1
1
  export * from './image'
2
+ export * from './image.types'
@@ -1,6 +1,7 @@
1
1
  export * from './activity-indicator'
2
2
  export * from './avatar'
3
3
  export * from './badge'
4
+ export * from './blur-view'
4
5
  export * from './box'
5
6
  export * from './button'
6
7
  export * from './card'
@@ -1,4 +1,5 @@
1
1
  import {Host} from '@expo/ui/jetpack-compose'
2
+ import {useSx} from '@hzblj/kara-sx'
2
3
  import {useAppTheme} from '@hzblj/kara-theme'
3
4
 
4
5
  import {HostBoundary} from '../../host-context'
@@ -8,15 +9,22 @@ const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
8
9
 
9
10
  const MATCH_NEITHER = {horizontal: false, vertical: false}
10
11
 
11
- export const Island: IslandComponent = ({children, matchContents = true, style}: IslandProps) => {
12
+ export const Island: IslandComponent = ({
13
+ children,
14
+ matchContents = true,
15
+ style,
16
+ testID: _testID,
17
+ ...sx
18
+ }: IslandProps) => {
12
19
  const theme = useAppTheme()
20
+ const resolved = useSx(sx)
13
21
 
14
22
  return (
15
23
  <Host
16
24
  colorScheme={theme.dark ? 'dark' : 'light'}
17
25
  matchContents={matchContents ? MATCH_HEIGHT_ONLY : MATCH_NEITHER}
18
26
  seedColor={theme.materialSeed}
19
- style={style}
27
+ style={style ? [resolved, style] : resolved}
20
28
  >
21
29
  <HostBoundary>{children}</HostBoundary>
22
30
  </Host>
@@ -1,5 +1,6 @@
1
1
  import {Host} from '@expo/ui/swift-ui'
2
2
  import {dynamicTypeSize} from '@expo/ui/swift-ui/modifiers'
3
+ import {useSx} from '@hzblj/kara-sx'
3
4
  import {MAX_DYNAMIC_TYPE_SIZE, useAppTheme} from '@hzblj/kara-theme'
4
5
 
5
6
  import {HostBoundary} from '../../host-context'
@@ -11,8 +12,9 @@ const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
11
12
 
12
13
  const MATCH_NEITHER = {horizontal: false, vertical: false}
13
14
 
14
- export const Island: IslandComponent = ({children, matchContents = true, style, testID}: IslandProps) => {
15
+ export const Island: IslandComponent = ({children, matchContents = true, style, testID, ...sx}: IslandProps) => {
15
16
  const theme = useAppTheme()
17
+ const resolved = useSx(sx)
16
18
 
17
19
  return (
18
20
  <Host
@@ -20,7 +22,7 @@ export const Island: IslandComponent = ({children, matchContents = true, style,
20
22
  matchContents={matchContents ? MATCH_HEIGHT_ONLY : MATCH_NEITHER}
21
23
  modifiers={DYNAMIC_TYPE_CAP}
22
24
  seedColor={theme.materialSeed}
23
- style={style}
25
+ style={style ? [resolved, style] : resolved}
24
26
  testID={testID}
25
27
  >
26
28
  <HostBoundary>{children}</HostBoundary>
@@ -1,8 +1,12 @@
1
+ import type {ResponsiveViewSx} from '@hzblj/kara-sx'
1
2
  import type {ReactNode} from 'react'
2
3
  import type {StyleProp, ViewStyle} from 'react-native'
3
4
 
4
- /** A host for native atoms in a React Native layout — the SwiftUI / Compose `Host` an `@expo/ui` tree mounts into. */
5
- export type IslandProps = {
5
+ /**
6
+ * A host for native atoms in a React Native layout — the SwiftUI / Compose `Host` an `@expo/ui` tree mounts into. It
7
+ * sits in that layout like a `Box`, and takes `Box`'s `sx` props for it: `h`, `flex`, margins.
8
+ */
9
+ export type IslandProps = ResponsiveViewSx & {
6
10
  /** The native views it hosts. */
7
11
  children?: ReactNode
8
12
  /**
@@ -11,13 +15,13 @@ export type IslandProps = {
11
15
  * width maps to `fixedSize(horizontal: true)`, so the native side ignores the proposed width and
12
16
  * resolves a `wide` stack's ideal width as unbounded.
13
17
  *
14
- * Turn it off — and give the island an explicit size via `style` — when the subtree contains an
18
+ * Turn it off — and give the island an explicit height, `h` — when the subtree contains an
15
19
  * `RnView`: that host drives its SwiftUI frame from the child's Yoga-laid-out bounds while this one
16
20
  * writes its measured size back into the Yoga style, and the two together spin a layout loop that
17
21
  * hangs the main thread as soon as the views lay out on screen.
18
22
  */
19
23
  matchContents?: boolean
20
- /** A React Native style for the host view — its size and placement. It needs a height when `matchContents` is off. */
24
+ /** A React Native style for the host view, merged over the `sx` props — for what they cannot say. */
21
25
  style?: StyleProp<ViewStyle>
22
26
  /** Test identifier, forwarded to the host view on iOS — Android ignores it. */
23
27
  testID?: string
@@ -1,9 +1,11 @@
1
1
  import {useSplitSx} from '@hzblj/kara-sx'
2
- import {useCallback} from 'react'
3
- import {Pressable as RNPressable} from 'react-native'
2
+ import {useAppTheme, withOpacity} from '@hzblj/kara-theme'
3
+ import {useCallback, useMemo} from 'react'
4
+ import {Platform, Pressable as RNPressable, View, type ViewStyle} from 'react-native'
4
5
  import Animated, {useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated'
5
6
 
6
7
  import {haptics} from '../../haptics'
8
+ import {splitForRipple} from '../../ripple-clip'
7
9
  import type {PressableComponent, PressableProps} from './pressable.types'
8
10
 
9
11
  const SPRING = {damping: 20, stiffness: 400}
@@ -22,20 +24,39 @@ export const Pressable: PressableComponent = ({
22
24
  const scale = useSharedValue(1)
23
25
  const feedback = haptic === true ? 'light' : haptic || undefined
24
26
 
27
+ const theme = useAppTheme()
28
+ const isAndroid = Platform.OS === 'android'
29
+
25
30
  const rStyle = useAnimatedStyle(() => ({transform: [{scale: scale.value}]}), [])
26
31
 
32
+ // Android's press is Material's: a ripple over the content — the pressed state layer, `onSurface` at 10 % — clipped to
33
+ // the shape by an outer view (`splitForRipple`), and nothing moves. iOS springs down.
34
+ const ripple = useMemo(
35
+ () =>
36
+ isAndroid ? {color: withOpacity(String(theme.color('content.primary')), 'o10'), foreground: true} : undefined,
37
+ [isAndroid, theme]
38
+ )
39
+ const androidStyle = useMemo(
40
+ () => (isAndroid ? splitForRipple(resolved as ViewStyle) : undefined),
41
+ [isAndroid, resolved]
42
+ )
43
+
27
44
  // The finger is down: the Taptic Engine has until the release to wake, so the haptic lands with the press.
28
45
  const onPressIn = useCallback(() => {
29
- scale.value = withSpring(0.96, SPRING)
46
+ if (!isAndroid) {
47
+ scale.value = withSpring(0.96, SPRING)
48
+ }
30
49
 
31
50
  if (feedback) {
32
51
  haptics.prepare(feedback)
33
52
  }
34
- }, [feedback, scale])
53
+ }, [feedback, isAndroid, scale])
35
54
 
36
55
  const onPressOut = useCallback(() => {
37
- scale.value = withSpring(1, SPRING)
38
- }, [scale])
56
+ if (!isAndroid) {
57
+ scale.value = withSpring(1, SPRING)
58
+ }
59
+ }, [isAndroid, scale])
39
60
 
40
61
  const onPressWithHaptic = useCallback(() => {
41
62
  if (feedback) {
@@ -45,6 +66,25 @@ export const Pressable: PressableComponent = ({
45
66
  onPress?.()
46
67
  }, [feedback, onPress])
47
68
 
69
+ if (androidStyle) {
70
+ return (
71
+ <View style={androidStyle.outer}>
72
+ <RNPressable
73
+ android_ripple={ripple}
74
+ disabled={disabled}
75
+ hitSlop={8}
76
+ onPress={onPressWithHaptic}
77
+ onPressIn={onPressIn}
78
+ style={androidStyle.inner}
79
+ testID={testID}
80
+ {...rest}
81
+ >
82
+ {children}
83
+ </RNPressable>
84
+ </View>
85
+ )
86
+ }
87
+
48
88
  return (
49
89
  <AnimatedPressable
50
90
  disabled={disabled}
@@ -4,13 +4,15 @@ import type {ReactNode} from 'react'
4
4
  import type {Haptic} from '../../haptics'
5
5
 
6
6
  /**
7
- * React Native's `Pressable`, styled through `sx` like a `Box`: it springs down to 96% while pressed and takes
8
- * touches 8 points past its edges. The React Native tier — it needs no host.
7
+ * React Native's `Pressable`, styled through `sx` like a `Box`, taking touches 8 points past its edges. The press is
8
+ * each platform's own: iOS springs it down to 96%; Android draws Material's ripple over the content, clipped to the
9
+ * pressable's shape — give it the `borderRadius` of what it wraps (`full` around a `GlassView`). The React Native
10
+ * tier — it needs no host.
9
11
  */
10
12
  export type PressableProps = ResponsiveViewSx & {
11
13
  /** The pressable content. */
12
14
  children?: ReactNode
13
- /** Ignores presses — no `onPress`, no haptic, no spring. */
15
+ /** Ignores presses — no `onPress`, no haptic, no spring or ripple. */
14
16
  disabled?: boolean
15
17
  /** Played on press. `true` is a `light` impact; a name picks the haptic, e.g. `selection` for a choice. */
16
18
  haptic?: boolean | Haptic
@@ -1,5 +1,6 @@
1
+ import {useSplitSx} from '@hzblj/kara-sx'
1
2
  import {MAX_FONT_SCALE, useAppTheme} from '@hzblj/kara-theme'
2
- import {Text as RNText, useWindowDimensions} from 'react-native'
3
+ import {Text as RNText, type TextStyle, useWindowDimensions} from 'react-native'
3
4
 
4
5
  import type {RnTextComponent, RnTextProps} from './rn-text.types'
5
6
 
@@ -10,6 +11,8 @@ export const RnText: RnTextComponent = ({
10
11
  ...props
11
12
  }: RnTextProps) => {
12
13
  const theme = useAppTheme()
14
+ // A view style, which a text accepts whole — only React Native's types disagree, on `userSelect`'s width.
15
+ const [sx, rest] = useSplitSx(props) as [TextStyle, Record<string, unknown>]
13
16
  const {fontScale} = useWindowDimensions()
14
17
  const style = theme.typography[variant]
15
18
 
@@ -26,8 +29,8 @@ export const RnText: RnTextComponent = ({
26
29
  return (
27
30
  <RNText
28
31
  maxFontSizeMultiplier={MAX_FONT_SCALE}
29
- style={[style, {color: theme.color(color), lineHeight, textAlign: align}]}
30
- {...props}
32
+ style={[style, sx, {color: theme.color(color), lineHeight, textAlign: align}]}
33
+ {...rest}
31
34
  />
32
35
  )
33
36
  }
@@ -1,15 +1,21 @@
1
+ import type {ResponsiveViewSx} from '@hzblj/kara-sx'
1
2
  import type {ColorToken, TextVariant} from '@hzblj/kara-theme'
2
3
  import type {ReactNode} from 'react'
3
4
  import type {TextProps as RNTextProps} from 'react-native'
4
5
 
5
- /** React Native's `Text` in a theme typography variant — the React Native tier, for text outside a native host. */
6
- export type RnTextProps = Omit<RNTextProps, 'style'> & {
7
- /** Horizontal alignment, as React Native's `textAlign`. */
8
- align?: 'center' | 'left' | 'right'
9
- /** The text's colour. `content.primary` by default. */
10
- color?: ColorToken
11
- /** The typography variant. `body` by default. */
12
- variant?: TextVariant
13
- }
6
+ /**
7
+ * React Native's `Text` in a theme typography variant — the React Native tier, for text outside a native host. It takes
8
+ * `Box`'s `sx` surface for its own box — margins, flex, padding, a background — all but `align`, which here aligns the
9
+ * lines.
10
+ */
11
+ export type RnTextProps = Omit<RNTextProps, 'style'> &
12
+ Omit<ResponsiveViewSx, 'align'> & {
13
+ /** Horizontal alignment, as React Native's `textAlign`. */
14
+ align?: 'center' | 'left' | 'right'
15
+ /** The text's colour. `content.primary` by default. */
16
+ color?: ColorToken
17
+ /** The typography variant. `body` by default. */
18
+ variant?: TextVariant
19
+ }
14
20
 
15
21
  export type RnTextComponent = (props: RnTextProps) => ReactNode
@@ -5,7 +5,7 @@ import {useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-na
5
5
  import {ABox} from '../box'
6
6
  import type {SkeletonComponent, SkeletonProps} from './skeleton.types'
7
7
 
8
- export const Skeleton: SkeletonComponent = ({borderRadius = 'small', height, testID, width}: SkeletonProps) => {
8
+ export const Skeleton: SkeletonComponent = ({borderRadius = 'small', h, testID, w}: SkeletonProps) => {
9
9
  const pulse = useSharedValue<number>(opacity.o40)
10
10
 
11
11
  useEffect(() => {
@@ -18,11 +18,11 @@ export const Skeleton: SkeletonComponent = ({borderRadius = 'small', height, tes
18
18
  <ABox
19
19
  bg="fill.quaternary"
20
20
  borderRadius={borderRadius}
21
- h={height}
21
+ h={h}
22
22
  style={rStyle}
23
23
  testID={testID}
24
- w={width}
25
- wide={width === undefined}
24
+ w={w}
25
+ wide={w === undefined}
26
26
  />
27
27
  )
28
28
  }
@@ -1,16 +1,20 @@
1
+ import type {SxDimension} from '@hzblj/kara-sx'
1
2
  import type {RadiiSize} from '@hzblj/kara-theme'
2
3
  import type {ReactNode} from 'react'
3
4
 
4
- /** A loading placeholder that pulses in `fill.quaternary` — a React Native view, so it needs no host. */
5
+ /**
6
+ * A loading placeholder that pulses in `fill.quaternary` — a React Native view, so it needs no host. Sized with the
7
+ * `sx` names: `w`, `h`, `borderRadius`.
8
+ */
5
9
  export type SkeletonProps = {
6
10
  /** The corner radius. `small` by default. */
7
11
  borderRadius?: RadiiSize
8
12
  /** In points. */
9
- height: number
13
+ h: number
10
14
  /** Test identifier, forwarded to the view. */
11
15
  testID?: string
12
- /** In points. Leave it unset and it fills the available width. */
13
- width?: number
16
+ /** In points, or a percentage of the row (`60%` for a line that ends early). Unset, it fills the row. */
17
+ w?: SxDimension
14
18
  }
15
19
 
16
20
  export type SkeletonComponent = (props: SkeletonProps) => ReactNode
@@ -1,5 +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 {useSxModifiers} from '@hzblj/kara-sx'
3
4
  import {useAppTheme} from '@hzblj/kara-theme'
4
5
  import {useMemo} from 'react'
5
6
 
@@ -32,11 +33,16 @@ export const Text: TextComponent = ({
32
33
  testID,
33
34
  transform,
34
35
  variant = 'body',
36
+ ...sx
35
37
  }: TextProps) => {
36
38
  useHostGuard('Text')
37
39
  const theme = useAppTheme()
38
40
  const style = theme.typography[variant]
39
- const modifiers = useMemo(() => (testID ? [testIdModifier(testID)] : undefined), [testID])
41
+ const sxModifiers = useSxModifiers(sx)
42
+ const modifiers = useMemo(
43
+ () => (testID ? [...sxModifiers, testIdModifier(testID)] : sxModifiers),
44
+ [sxModifiers, testID]
45
+ )
40
46
 
41
47
  return (
42
48
  <ComposeText
@@ -10,11 +10,12 @@ import {
10
10
  underline,
11
11
  } from '@expo/ui/swift-ui/modifiers'
12
12
  import {scaledFont} from '@hzblj/kara-native/swift-ui'
13
+ import {type FrameAlignment, useSxModifiers} from '@hzblj/kara-sx'
13
14
  import {useAppTheme} from '@hzblj/kara-theme'
14
15
  import {useMemo} from 'react'
15
16
 
16
17
  import {useHostGuard} from '../../host-context'
17
- import type {TextComponent, TextProps} from './text.types'
18
+ import type {TextAlign, TextComponent, TextProps} from './text.types'
18
19
 
19
20
  const ALIGNMENT = {
20
21
  center: 'center',
@@ -22,6 +23,13 @@ const ALIGNMENT = {
22
23
  right: 'trailing',
23
24
  } as const
24
25
 
26
+ /** Where a frame larger than the text — `wide`, `w`, `h` — puts it: on the side its lines align to, centred vertically. */
27
+ const FRAME_ALIGNMENT = {
28
+ center: 'center',
29
+ left: 'leading',
30
+ right: 'trailing',
31
+ } as const satisfies Record<TextAlign, FrameAlignment>
32
+
25
33
  export const Text: TextComponent = ({
26
34
  align,
27
35
  children,
@@ -31,10 +39,13 @@ export const Text: TextComponent = ({
31
39
  testID,
32
40
  transform,
33
41
  variant = 'body',
42
+ ...sx
34
43
  }: TextProps) => {
35
44
  useHostGuard('Text')
36
45
  const theme = useAppTheme()
37
46
  const style = theme.typography[variant]
47
+ // After the text's own modifiers: the font and colour belong to the glyphs, the padding and frame to the box around.
48
+ const sxModifiers = useSxModifiers(sx, FRAME_ALIGNMENT[align ?? 'left'])
38
49
 
39
50
  const modifiers = useMemo(() => {
40
51
  const list: ModifierConfig[] = [
@@ -65,8 +76,8 @@ export const Text: TextComponent = ({
65
76
  list.push(lineLimit(numberOfLines))
66
77
  }
67
78
 
68
- return list
69
- }, [align, color, decoration, numberOfLines, style, theme, transform])
79
+ return [...list, ...sxModifiers]
80
+ }, [align, color, decoration, numberOfLines, style, sxModifiers, theme, transform])
70
81
 
71
82
  return (
72
83
  <SwiftUIText modifiers={modifiers} testID={testID}>
@@ -1,3 +1,4 @@
1
+ import type {ResponsiveSx} from '@hzblj/kara-sx'
1
2
  import type {ColorToken, TextVariant} from '@hzblj/kara-theme'
2
3
  import type {ReactNode} from 'react'
3
4
 
@@ -7,8 +8,12 @@ export type TextDecoration = 'none' | 'strikethrough' | 'underline'
7
8
 
8
9
  export type TextTransform = 'lowercase' | 'uppercase'
9
10
 
10
- /** SwiftUI `Text` / Compose `Text` in a theme typography variant — a native atom; outside a host, use `RnText`. */
11
- export type TextProps = {
11
+ /**
12
+ * SwiftUI `Text` / Compose `Text` in a theme typography variant — a native atom; outside a host, use `RnText`. It takes
13
+ * the native `sx` surface too: padding, a frame (`wide` lines it up by `align`), a background, and the `ios` bucket's
14
+ * `contentTransition`, `animation` and `monospacedDigit` for a number that changes in place.
15
+ */
16
+ export type TextProps = ResponsiveSx & {
12
17
  /**
13
18
  * How its lines align. iOS reads `left` and `right` as leading and trailing, so they flip in a right-to-left
14
19
  * layout; Android keeps them as they are.