@hzblj/kara-components 0.7.0 → 0.9.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 (135) hide show
  1. package/CHANGELOG.md +59 -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/progress/progress.ios.js +10 -4
  38. package/lib/module/atoms/progress/progress.ios.js.map +1 -1
  39. package/lib/module/atoms/rn-text/rn-text.js +5 -2
  40. package/lib/module/atoms/rn-text/rn-text.js.map +1 -1
  41. package/lib/module/atoms/skeleton/skeleton.js +5 -5
  42. package/lib/module/atoms/skeleton/skeleton.js.map +1 -1
  43. package/lib/module/atoms/surface/surface.ios.js +3 -0
  44. package/lib/module/atoms/surface/surface.ios.js.map +1 -1
  45. package/lib/module/atoms/text/text.android.js +5 -2
  46. package/lib/module/atoms/text/text.android.js.map +1 -1
  47. package/lib/module/atoms/text/text.ios.js +14 -3
  48. package/lib/module/atoms/text/text.ios.js.map +1 -1
  49. package/lib/module/atoms/text-field/text-field.android.js +19 -4
  50. package/lib/module/atoms/text-field/text-field.android.js.map +1 -1
  51. package/lib/module/atoms/text-field/text-field.ios.js +7 -2
  52. package/lib/module/atoms/text-field/text-field.ios.js.map +1 -1
  53. package/lib/module/molecules/collapsible/collapsible.android.js +37 -7
  54. package/lib/module/molecules/collapsible/collapsible.android.js.map +1 -1
  55. package/lib/module/molecules/navigation-bar/navigation-bar.android.js +2 -8
  56. package/lib/module/molecules/navigation-bar/navigation-bar.android.js.map +1 -1
  57. package/lib/module/molecules/pager/pager.android.js +5 -4
  58. package/lib/module/molecules/pager/pager.android.js.map +1 -1
  59. package/lib/module/molecules/scroll-edge-container/scroll-edge-container.android.js +114 -11
  60. package/lib/module/molecules/scroll-edge-container/scroll-edge-container.android.js.map +1 -1
  61. package/lib/module/molecules/stepper/stepper.ios.js +45 -8
  62. package/lib/module/molecules/stepper/stepper.ios.js.map +1 -1
  63. package/lib/module/ripple-clip.js +9 -1
  64. package/lib/module/ripple-clip.js.map +1 -1
  65. package/lib/typescript/atoms/activity-indicator/activity-indicator.d.ts +1 -3
  66. package/lib/typescript/atoms/activity-indicator/activity-indicator.ios.d.ts +3 -0
  67. package/lib/typescript/atoms/activity-indicator/activity-indicator.types.d.ts +17 -0
  68. package/lib/typescript/atoms/activity-indicator/index.d.ts +1 -0
  69. package/lib/typescript/atoms/blur-view/blur-view.d.ts +1 -0
  70. package/lib/typescript/atoms/blur-view/blur-view.ios.d.ts +3 -0
  71. package/lib/typescript/atoms/blur-view/blur-view.types.d.ts +31 -0
  72. package/lib/typescript/atoms/blur-view/index.d.ts +2 -0
  73. package/lib/typescript/atoms/box/a-box.d.ts +1 -1
  74. package/lib/typescript/atoms/glass-view/glass-view.types.d.ts +12 -5
  75. package/lib/typescript/atoms/image/image.d.ts +12 -4
  76. package/lib/typescript/atoms/image/image.types.d.ts +4 -0
  77. package/lib/typescript/atoms/image/index.d.ts +1 -0
  78. package/lib/typescript/atoms/index.d.ts +1 -0
  79. package/lib/typescript/atoms/island/island.types.d.ts +8 -4
  80. package/lib/typescript/atoms/pressable/pressable.types.d.ts +5 -3
  81. package/lib/typescript/atoms/progress/progress.types.d.ts +2 -2
  82. package/lib/typescript/atoms/rn-text/rn-text.types.d.ts +7 -2
  83. package/lib/typescript/atoms/skeleton/skeleton.types.d.ts +8 -4
  84. package/lib/typescript/atoms/surface/surface.types.d.ts +2 -2
  85. package/lib/typescript/atoms/text/text.types.d.ts +7 -2
  86. package/lib/typescript/atoms/text-field/text-field.types.d.ts +6 -2
  87. package/lib/typescript/molecules/scroll-edge-container/scroll-edge-container.types.d.ts +6 -4
  88. package/lib/typescript/molecules/stepper/stepper.ios.d.ts +5 -0
  89. package/lib/typescript/molecules/stepper/stepper.types.d.ts +1 -4
  90. package/package.json +5 -5
  91. package/src/atoms/activity-indicator/activity-indicator.android.tsx +32 -0
  92. package/src/atoms/activity-indicator/activity-indicator.ios.tsx +15 -0
  93. package/src/atoms/activity-indicator/activity-indicator.types.ts +20 -0
  94. package/src/atoms/activity-indicator/index.ts +1 -0
  95. package/src/atoms/avatar/avatar.tsx +1 -1
  96. package/src/atoms/blur-view/blur-view.android.tsx +68 -0
  97. package/src/atoms/blur-view/blur-view.ios.tsx +37 -0
  98. package/src/atoms/blur-view/blur-view.types.ts +34 -0
  99. package/src/atoms/blur-view/index.ts +2 -0
  100. package/src/atoms/glass-view/glass-view.android.tsx +41 -3
  101. package/src/atoms/glass-view/glass-view.types.ts +12 -5
  102. package/src/atoms/image/image.tsx +11 -12
  103. package/src/atoms/image/image.types.ts +6 -0
  104. package/src/atoms/image/index.ts +1 -0
  105. package/src/atoms/index.ts +1 -0
  106. package/src/atoms/island/island.android.tsx +10 -2
  107. package/src/atoms/island/island.ios.tsx +4 -2
  108. package/src/atoms/island/island.types.ts +8 -4
  109. package/src/atoms/pressable/pressable.tsx +46 -6
  110. package/src/atoms/pressable/pressable.types.ts +5 -3
  111. package/src/atoms/progress/progress.ios.tsx +14 -5
  112. package/src/atoms/progress/progress.types.ts +2 -2
  113. package/src/atoms/rn-text/rn-text.tsx +6 -3
  114. package/src/atoms/rn-text/rn-text.types.ts +15 -9
  115. package/src/atoms/skeleton/skeleton.tsx +4 -4
  116. package/src/atoms/skeleton/skeleton.types.ts +8 -4
  117. package/src/atoms/surface/surface.ios.tsx +2 -1
  118. package/src/atoms/surface/surface.types.ts +2 -2
  119. package/src/atoms/text/text.android.tsx +7 -1
  120. package/src/atoms/text/text.ios.tsx +14 -3
  121. package/src/atoms/text/text.types.ts +7 -2
  122. package/src/atoms/text-field/text-field.android.tsx +19 -3
  123. package/src/atoms/text-field/text-field.ios.tsx +7 -1
  124. package/src/atoms/text-field/text-field.types.ts +6 -2
  125. package/src/molecules/collapsible/collapsible.android.tsx +47 -7
  126. package/src/molecules/navigation-bar/navigation-bar.android.tsx +1 -6
  127. package/src/molecules/pager/pager.android.tsx +4 -1
  128. package/src/molecules/scroll-edge-container/scroll-edge-container.android.tsx +103 -10
  129. package/src/molecules/scroll-edge-container/scroll-edge-container.types.ts +6 -4
  130. package/src/molecules/stepper/stepper.ios.tsx +37 -1
  131. package/src/molecules/stepper/stepper.types.ts +1 -4
  132. package/src/ripple-clip.ts +10 -1
  133. package/lib/module/atoms/activity-indicator/activity-indicator.js +0 -14
  134. package/lib/module/atoms/activity-indicator/activity-indicator.js.map +0 -1
  135. package/src/atoms/activity-indicator/activity-indicator.tsx +0 -9
@@ -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,5 @@
1
- import {ProgressView} from '@expo/ui/swift-ui'
2
- import {progressViewStyle, tint} from '@expo/ui/swift-ui/modifiers'
1
+ import {Gauge, ProgressView} from '@expo/ui/swift-ui'
2
+ import {gaugeStyle, progressViewStyle, tint} from '@expo/ui/swift-ui/modifiers'
3
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
4
  import {useMemo} from 'react'
5
5
 
@@ -10,10 +10,19 @@ export const Progress: ProgressComponent = ({circular = false, value}: ProgressP
10
10
  useHostGuard('Progress')
11
11
  const theme = useAppTheme()
12
12
 
13
+ const ring = circular && value !== undefined
13
14
  const modifiers = useMemo(
14
- () => [progressViewStyle(circular ? 'circular' : 'linear'), tint(theme.color('fill.accent.primary'))],
15
- [circular, theme]
15
+ () => [
16
+ ring ? gaugeStyle('circularCapacity') : progressViewStyle(circular ? 'circular' : 'linear'),
17
+ tint(theme.color('fill.accent.primary')),
18
+ ],
19
+ [circular, ring, theme]
16
20
  )
17
21
 
18
- return <ProgressView modifiers={modifiers} value={value ?? null} />
22
+ // SwiftUI's circular `ProgressView` ignores its value on iOS and spins; a ring that fills is a capacity `Gauge`.
23
+ return ring ? (
24
+ <Gauge modifiers={modifiers} value={value} />
25
+ ) : (
26
+ <ProgressView modifiers={modifiers} value={value ?? null} />
27
+ )
19
28
  }
@@ -1,8 +1,8 @@
1
1
  import type {ReactNode} from 'react'
2
2
 
3
3
  /**
4
- * A progress bar or ring, tinted `fill.accent.primary` — SwiftUI `ProgressView` / Compose's
5
- * `LinearProgressIndicator` and `CircularProgressIndicator`.
4
+ * A progress bar or ring, tinted `fill.accent.primary` — SwiftUI `ProgressView` (a capacity `Gauge` for a ring with a
5
+ * value, which `ProgressView` cannot draw on iOS) / Compose's `LinearProgressIndicator` and `CircularProgressIndicator`.
6
6
  */
7
7
  export type ProgressProps = {
8
8
  /** A circular indicator instead of a bar. Off by default. */
@@ -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
@@ -6,7 +6,8 @@ import type {SurfaceComponent, SurfaceProps} from './surface.types'
6
6
  export const Surface: SurfaceComponent = ({children, glass = false, testID, ...sx}: SurfaceProps) => {
7
7
  useHostGuard('Surface')
8
8
 
9
- const modifiers = useSxModifiers({borderRadius: 'large', p: 's16', ...sx})
9
+ // `surface.primary` unless told otherwise, as Material's `Surface` is on Android — without it a plain one drew nothing.
10
+ const modifiers = useSxModifiers({bg: 'surface.primary', borderRadius: 'large', p: 's16', ...sx})
10
11
  const content = (
11
12
  <VStack alignment="leading" modifiers={modifiers} testID={testID}>
12
13
  {children}
@@ -2,8 +2,8 @@ import type {ResponsiveSx} from '@hzblj/kara-sx'
2
2
  import type {ReactNode} from 'react'
3
3
 
4
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.
5
+ * A container on `surface.primary`, padded `s16` and rounded `large` unless told otherwise — a SwiftUI `VStack` on
6
+ * iOS, Material's `Surface` around a `Column` on Android.
7
7
  */
8
8
  export type SurfaceProps = ResponsiveSx & {
9
9
  /** Stacked vertically, from the leading edge. */
@@ -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.
@@ -1,6 +1,7 @@
1
1
  import {Column, Text as ComposeText, OutlinedTextField, useNativeState} from '@expo/ui/jetpack-compose'
2
2
  import {useSxModifiers} from '@hzblj/kara-sx'
3
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
+ import {Glyph} from '../../glyph'
4
5
  import {useHostGuard} from '../../host-context'
5
6
  import type {TextFieldComponent, TextFieldProps} from './text-field.types'
6
7
 
@@ -17,12 +18,14 @@ export const TextField: TextFieldComponent = ({
17
18
  const theme = useAppTheme()
18
19
  const value = useNativeState(defaultValue)
19
20
  const modifiers = useSxModifiers({wide: true})
21
+ const invalid = Boolean(error)
22
+ const message = typeof error === 'string' && error ? error : undefined
20
23
 
21
24
  return (
22
25
  <Column verticalArrangement={{spacedBy: 4}}>
23
26
  {label ? (
24
27
  <ComposeText
25
- color={theme.colorString(error ? 'content.destructive' : 'content.secondary')}
28
+ color={theme.colorString(invalid ? 'content.destructive' : 'content.secondary')}
26
29
  style={theme.typography.labelMedium}
27
30
  >
28
31
  {label}
@@ -30,7 +33,7 @@ export const TextField: TextFieldComponent = ({
30
33
  ) : null}
31
34
  <OutlinedTextField
32
35
  enabled={!disabled}
33
- isError={error}
36
+ isError={invalid}
34
37
  modifiers={modifiers}
35
38
  onValueChange={onChangeText}
36
39
  singleLine={!multiline}
@@ -41,7 +44,20 @@ export const TextField: TextFieldComponent = ({
41
44
  }}
42
45
  value={value}
43
46
  visualTransformation={secure ? 'password' : 'none'}
44
- />
47
+ >
48
+ {invalid ? (
49
+ <OutlinedTextField.TrailingIcon>
50
+ <Glyph color="content.destructive" name="error" />
51
+ </OutlinedTextField.TrailingIcon>
52
+ ) : null}
53
+ {message ? (
54
+ <OutlinedTextField.SupportingText>
55
+ <ComposeText color={theme.colorString('content.destructive')} style={theme.typography.footnote}>
56
+ {message}
57
+ </ComposeText>
58
+ </OutlinedTextField.SupportingText>
59
+ ) : null}
60
+ </OutlinedTextField>
45
61
  </Column>
46
62
  )
47
63
  }
@@ -45,7 +45,8 @@ export const TextField: TextFieldComponent = ({
45
45
  }: TextFieldProps) => {
46
46
  useHostGuard('TextField')
47
47
  const text = useNativeState(defaultValue)
48
- const tone = TONE[toneOf(disabled, error)]
48
+ const tone = TONE[toneOf(disabled, Boolean(error))]
49
+ const message = tone === TONE.error && typeof error === 'string' && error ? error : undefined
49
50
 
50
51
  const sxModifiers = useSxModifiers({
51
52
  bg: tone.bg,
@@ -85,6 +86,11 @@ export const TextField: TextFieldComponent = ({
85
86
  text={text}
86
87
  />
87
88
  )}
89
+ {message ? (
90
+ <Text color="content.destructive" variant="footnote">
91
+ {message}
92
+ </Text>
93
+ ) : null}
88
94
  </VStack>
89
95
  )
90
96
  }
@@ -9,8 +9,12 @@ export type TextFieldProps = {
9
9
  defaultValue?: string
10
10
  /** Stops editing and dims the field. On iOS it outranks `error`. */
11
11
  disabled?: boolean
12
- /** Marks the field invalid: a destructive outline and label. */
13
- error?: boolean
12
+ /**
13
+ * Marks the field invalid: a destructive outline and label. A message is shown under the field as well — on Android
14
+ * as Material's supporting text, beside its error icon — so the error never rests on colour alone; Material's error
15
+ * colour is a pale red in dark mode. `true` marks it without a message.
16
+ */
17
+ error?: boolean | string
14
18
  /** Shown above the field, in `labelMedium`. */
15
19
  label?: string
16
20
  /** Wraps the text and grows the field to fit it. iOS ignores it when `secure`. */
@@ -1,25 +1,65 @@
1
- import {AnimatedVisibility, Column, Text as ComposeText, Row} from '@expo/ui/jetpack-compose'
2
- import {clickable} from '@expo/ui/jetpack-compose/modifiers'
3
- import {useAppTheme} from '@hzblj/kara-theme'
1
+ import {
2
+ AnimatedVisibility,
3
+ Box,
4
+ Column,
5
+ Text as ComposeText,
6
+ EnterTransition,
7
+ ExitTransition,
8
+ Row,
9
+ } from '@expo/ui/jetpack-compose'
10
+ import {
11
+ animated,
12
+ clickable,
13
+ defaultMinSize,
14
+ fillMaxWidth,
15
+ graphicsLayer,
16
+ tween,
17
+ } from '@expo/ui/jetpack-compose/modifiers'
18
+ import {duration, useAppTheme} from '@hzblj/kara-theme'
4
19
  import {useMemo} from 'react'
5
20
  import {Glyph} from '../../glyph'
6
21
  import {useHostGuard} from '../../host-context'
7
22
  import type {CollapsibleComponent, CollapsibleProps} from './collapsible.types'
8
23
 
24
+ /** Material's standard easing, at the length a disclosure takes. */
25
+ const TURN = tween({durationMillis: duration.d200, easing: 'fastOutSlowIn'})
26
+
27
+ /** Down from the row, fading in — Compose's default grows from a corner, sideways as well as down. */
28
+ const ENTER = EnterTransition.expandVertically().plus(EnterTransition.fadeIn())
29
+ const EXIT = ExitTransition.shrinkVertically().plus(ExitTransition.fadeOut())
30
+
31
+ const WIDE = [fillMaxWidth()]
32
+
33
+ /** Material's least touch target. */
34
+ const ROW_HEIGHT = 48
35
+
36
+ /**
37
+ * A pressable row and an animated reveal: the content expands down from the row, and the chevron turns a quarter
38
+ * toward it, as a Material disclosure does — it used to jump between two glyphs. The row spans its parent, the chevron
39
+ * at the far end, and is never shorter than a touch target.
40
+ */
9
41
  export const Collapsible: CollapsibleComponent = ({children, expanded, label, onExpandedChange}: CollapsibleProps) => {
10
42
  useHostGuard('Collapsible')
11
43
  const theme = useAppTheme()
12
- const modifiers = useMemo(() => [clickable(() => onExpandedChange(!expanded))], [expanded, onExpandedChange])
44
+ const modifiers = useMemo(
45
+ () => [fillMaxWidth(), defaultMinSize({minHeight: ROW_HEIGHT}), clickable(() => onExpandedChange(!expanded))],
46
+ [expanded, onExpandedChange]
47
+ )
48
+ const turn = useMemo(() => [graphicsLayer({rotationZ: animated(expanded ? 90 : 0, TURN)})], [expanded])
13
49
 
14
50
  return (
15
- <Column>
51
+ <Column modifiers={WIDE}>
16
52
  <Row horizontalArrangement="spaceBetween" modifiers={modifiers} verticalAlignment="center">
17
53
  <ComposeText color={theme.colorString('content.primary')} style={theme.typography.body}>
18
54
  {label}
19
55
  </ComposeText>
20
- <Glyph name={expanded ? 'chevronDown' : 'chevronRight'} size="s20" />
56
+ <Box modifiers={turn}>
57
+ <Glyph name="chevronRight" size="s20" />
58
+ </Box>
21
59
  </Row>
22
- <AnimatedVisibility visible={expanded}>{children}</AnimatedVisibility>
60
+ <AnimatedVisibility enterTransition={ENTER} exitTransition={EXIT} visible={expanded}>
61
+ {children}
62
+ </AnimatedVisibility>
23
63
  </Column>
24
64
  )
25
65
  }
@@ -2,17 +2,12 @@ import {iconResourceName} from '@hzblj/kara-native'
2
2
  import {TopAppBar, type TopAppBarItem} from '@hzblj/kara-native/jetpack-compose'
3
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
4
  import {useMemo} from 'react'
5
- import {StyleSheet} from 'react-native'
6
5
  import {Island} from '../../atoms/island'
7
6
  import type {NavigationBarComponent, NavigationBarItem, NavigationBarProps} from './navigation-bar.types'
8
7
 
9
8
  /** Material's small top app bar. */
10
9
  const BAR_HEIGHT = 64
11
10
 
12
- const styles = StyleSheet.create({
13
- bar: {height: BAR_HEIGHT, width: '100%'},
14
- })
15
-
16
11
  const DEFAULT_LABELS = {back: 'Back', close: 'Close'} as const
17
12
 
18
13
  const toNativeItem = ({onPress, testID, ...item}: NavigationBarItem): TopAppBarItem =>
@@ -38,7 +33,7 @@ export const NavigationBar: NavigationBarComponent = ({
38
33
  const nativeTrailingItems = useMemo(() => trailingItems?.map(toNativeItem), [trailingItems])
39
34
 
40
35
  return (
41
- <Island matchContents={false} style={styles.bar} testID={testID}>
36
+ <Island h={BAR_HEIGHT} matchContents={false} testID={testID} w="100%">
42
37
  <TopAppBar
43
38
  fontFamily={font.fontFamily}
44
39
  fontSize={font.fontSize}
@@ -4,15 +4,17 @@ import {spacing} from '@hzblj/kara-theme'
4
4
  import {Children, useCallback, useEffect, useMemo, useRef, useState} from 'react'
5
5
 
6
6
  import {HStack} from '../../atoms/h-stack'
7
+ import {Spacer} from '../../atoms/spacer'
7
8
  import {VStack} from '../../atoms/v-stack'
8
9
  import {useHostGuard} from '../../host-context'
9
10
  import type {PagerComponent, PagerProps} from './pager.types'
10
11
 
11
12
  const DOT_SIZE = 6
12
13
 
13
- /** Compose ships no page indicator, so the dots are composed out of atoms. */
14
+ /** Compose ships no page indicator, so the dots are composed out of atoms — centred between two spacers. */
14
15
  const PagerDots = ({count, current}: {count: number; current: number}) => (
15
16
  <HStack align="center" py="s12" spacing="s6" wide>
17
+ <Spacer />
16
18
  {Array.from({length: count}, (_, index) => (
17
19
  <VStack
18
20
  bg={index === current ? 'content.primary' : 'fill.tertiary'}
@@ -22,6 +24,7 @@ const PagerDots = ({count, current}: {count: number; current: number}) => (
22
24
  w={DOT_SIZE}
23
25
  />
24
26
  ))}
27
+ <Spacer />
25
28
  </HStack>
26
29
  )
27
30
 
@@ -1,9 +1,49 @@
1
- import {Box} from '../../atoms/box'
1
+ import {duration, opacity} from '@hzblj/kara-theme'
2
+ import {cloneElement, type ReactNode, useCallback, useRef} from 'react'
3
+ import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'
4
+ import {type SharedValue, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'
5
+
6
+ import {ABox, Box} from '../../atoms/box'
2
7
  import type {ScrollEdgeContainerComponent, ScrollEdgeContainerProps} from './scroll-edge-container.types'
3
8
 
9
+ /** Less than a pixel scrolled is not scrolled — a fling settles a fraction away from the edge. */
10
+ const EDGE_SLOP = 0.5
11
+
12
+ const FILL = {duration: duration.d200}
13
+
14
+ type Bar = {children: ReactNode; filled: SharedValue<number>}
15
+
16
+ /** A bar, and behind it the container colour it fills with — faded in and out, not switched. */
17
+ const EdgeBar = ({children, filled}: Bar) => {
18
+ const fill = useAnimatedStyle(() => ({opacity: filled.value}), [])
19
+
20
+ if (!children) {
21
+ return null
22
+ }
23
+
24
+ return (
25
+ <Box>
26
+ <ABox
27
+ bg="surface.secondary"
28
+ bottom={0}
29
+ left={0}
30
+ pointerEvents="none"
31
+ position="absolute"
32
+ right={0}
33
+ style={fill}
34
+ top={0}
35
+ />
36
+ {children}
37
+ </Box>
38
+ )
39
+ }
40
+
4
41
  /**
5
- * Android has no scroll edge effect, and a React Native scroll view there has no `contentInset` to slide content under
6
- * a bar with — so the bars stack above and below the scroll view instead of over it. Give them a background.
42
+ * Material's take on the scroll edge: a React Native scroll view has no `contentInset` to slide content under a bar on
43
+ * Android, so the bars stack above and below it, and do what a Material top app bar does on scroll — transparent at
44
+ * rest, filled with `surface.secondary` (the scrolled container colour, `surfaceContainer`) once content has scrolled
45
+ * under it. The bottom bar mirrors it: filled while there is more content below. The scroll view keeps its own
46
+ * `onScroll`, `onLayout` and `onContentSizeChange`.
7
47
  */
8
48
  export const ScrollEdgeContainer: ScrollEdgeContainerComponent = ({
9
49
  automaticInsets: _automaticInsets,
@@ -13,10 +53,63 @@ export const ScrollEdgeContainer: ScrollEdgeContainerComponent = ({
13
53
  testID,
14
54
  topBar,
15
55
  ...sx
16
- }: ScrollEdgeContainerProps) => (
17
- <Box {...sx} testID={testID}>
18
- {topBar}
19
- <Box flex={1}>{children}</Box>
20
- {bottomBar}
21
- </Box>
22
- )
56
+ }: ScrollEdgeContainerProps) => {
57
+ const topFilled = useSharedValue<number>(opacity.o0)
58
+ const bottomFilled = useSharedValue<number>(opacity.o0)
59
+ const metrics = useRef({content: 0, offset: 0, viewport: 0})
60
+ const state = useRef({bottom: false, top: false})
61
+ const {flex, onContentSizeChange, onLayout, onScroll, scrollEventThrottle} = children.props as {
62
+ flex?: number
63
+ onContentSizeChange?: (width: number, height: number) => void
64
+ onLayout?: (event: LayoutChangeEvent) => void
65
+ onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
66
+ scrollEventThrottle?: number
67
+ }
68
+
69
+ // Only a change of state animates; a scroll event restating the same one would restart the fade.
70
+ const update = useCallback(() => {
71
+ const {content, offset, viewport} = metrics.current
72
+ const top = offset > EDGE_SLOP
73
+ const bottom = viewport > 0 && offset + viewport < content - EDGE_SLOP
74
+
75
+ if (top !== state.current.top) {
76
+ state.current.top = top
77
+ topFilled.value = withTiming(top ? opacity.o100 : opacity.o0, FILL)
78
+ }
79
+
80
+ if (bottom !== state.current.bottom) {
81
+ state.current.bottom = bottom
82
+ bottomFilled.value = withTiming(bottom ? opacity.o100 : opacity.o0, FILL)
83
+ }
84
+ }, [bottomFilled, topFilled])
85
+
86
+ // `flex` so the scroll view takes the room between the bars — left to its content, it grew to the content's height,
87
+ // ran under the bottom bar and had nothing to scroll.
88
+ const scrollable = cloneElement(children, {
89
+ flex: flex ?? 1,
90
+ onContentSizeChange: (width: number, height: number) => {
91
+ metrics.current.content = height
92
+ update()
93
+ onContentSizeChange?.(width, height)
94
+ },
95
+ onLayout: (event: LayoutChangeEvent) => {
96
+ metrics.current.viewport = event.nativeEvent.layout.height
97
+ update()
98
+ onLayout?.(event)
99
+ },
100
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => {
101
+ metrics.current.offset = event.nativeEvent.contentOffset.y
102
+ update()
103
+ onScroll?.(event)
104
+ },
105
+ scrollEventThrottle: scrollEventThrottle ?? 16,
106
+ } as Partial<typeof children.props>)
107
+
108
+ return (
109
+ <Box {...sx} testID={testID}>
110
+ <EdgeBar filled={topFilled}>{topBar}</EdgeBar>
111
+ <Box flex={1}>{scrollable}</Box>
112
+ <EdgeBar filled={bottomFilled}>{bottomBar}</EdgeBar>
113
+ </Box>
114
+ )
115
+ }