@hzblj/kara-components 0.13.2 → 0.14.1

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 (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/lib/module/atoms/activity-indicator/activity-indicator.android.js +14 -7
  3. package/lib/module/atoms/activity-indicator/activity-indicator.android.js.map +1 -1
  4. package/lib/module/atoms/activity-indicator/activity-indicator.ios.js +12 -2
  5. package/lib/module/atoms/activity-indicator/activity-indicator.ios.js.map +1 -1
  6. package/lib/module/atoms/date-picker/date-picker.android.js +13 -1
  7. package/lib/module/atoms/date-picker/date-picker.android.js.map +1 -1
  8. package/lib/module/atoms/date-picker/date-picker.ios.js +19 -12
  9. package/lib/module/atoms/date-picker/date-picker.ios.js.map +1 -1
  10. package/lib/module/atoms/picker/picker.android.js +48 -41
  11. package/lib/module/atoms/picker/picker.android.js.map +1 -1
  12. package/lib/module/atoms/picker/picker.ios.js +25 -2
  13. package/lib/module/atoms/picker/picker.ios.js.map +1 -1
  14. package/lib/module/atoms/segmented-control/segmented-control.android.js +25 -18
  15. package/lib/module/atoms/segmented-control/segmented-control.android.js.map +1 -1
  16. package/lib/module/atoms/segmented-control/segmented-control.ios.js +15 -2
  17. package/lib/module/atoms/segmented-control/segmented-control.ios.js.map +1 -1
  18. package/lib/module/atoms/text/spans.js +44 -0
  19. package/lib/module/atoms/text/spans.js.map +1 -0
  20. package/lib/module/atoms/text/text.android.js +68 -3
  21. package/lib/module/atoms/text/text.android.js.map +1 -1
  22. package/lib/module/atoms/text/text.ios.js +15 -4
  23. package/lib/module/atoms/text/text.ios.js.map +1 -1
  24. package/lib/module/host-context.js +8 -0
  25. package/lib/module/host-context.js.map +1 -1
  26. package/lib/typescript/atoms/activity-indicator/activity-indicator.ios.d.ts +4 -1
  27. package/lib/typescript/atoms/activity-indicator/activity-indicator.types.d.ts +2 -1
  28. package/lib/typescript/atoms/date-picker/date-picker.ios.d.ts +2 -1
  29. package/lib/typescript/atoms/date-picker/date-picker.types.d.ts +1 -1
  30. package/lib/typescript/atoms/picker/picker.ios.d.ts +5 -0
  31. package/lib/typescript/atoms/picker/picker.types.d.ts +6 -2
  32. package/lib/typescript/atoms/segmented-control/segmented-control.ios.d.ts +2 -1
  33. package/lib/typescript/atoms/segmented-control/segmented-control.types.d.ts +2 -1
  34. package/lib/typescript/atoms/text/spans.d.ts +14 -0
  35. package/lib/typescript/atoms/text/text.types.d.ts +8 -1
  36. package/lib/typescript/host-context.d.ts +7 -0
  37. package/package.json +4 -4
  38. package/src/atoms/activity-indicator/activity-indicator.android.tsx +18 -5
  39. package/src/atoms/activity-indicator/activity-indicator.ios.tsx +10 -2
  40. package/src/atoms/activity-indicator/activity-indicator.types.ts +2 -1
  41. package/src/atoms/date-picker/date-picker.android.tsx +15 -1
  42. package/src/atoms/date-picker/date-picker.ios.tsx +20 -9
  43. package/src/atoms/date-picker/date-picker.types.ts +1 -1
  44. package/src/atoms/picker/picker.android.tsx +53 -44
  45. package/src/atoms/picker/picker.ios.tsx +28 -7
  46. package/src/atoms/picker/picker.types.ts +6 -2
  47. package/src/atoms/segmented-control/segmented-control.android.tsx +29 -18
  48. package/src/atoms/segmented-control/segmented-control.ios.tsx +10 -2
  49. package/src/atoms/segmented-control/segmented-control.types.ts +2 -1
  50. package/src/atoms/text/spans.ts +45 -0
  51. package/src/atoms/text/text.android.tsx +69 -3
  52. package/src/atoms/text/text.ios.tsx +36 -4
  53. package/src/atoms/text/text.types.ts +8 -1
  54. package/src/host-context.tsx +8 -0
@@ -1,7 +1,10 @@
1
1
  import {DateTimePicker} from '@expo/ui/community/datetime-picker'
2
+ import {RNHostView} from '@expo/ui/jetpack-compose'
2
3
  import {useAppTheme} from '@hzblj/kara-theme'
3
4
  import {useCallback, useState} from 'react'
4
5
 
6
+ import {useInsideHost} from '../../host-context'
7
+ import {Box} from '../box'
5
8
  import {Pressable} from '../pressable'
6
9
  import {RnText} from '../rn-text'
7
10
  import type {DatePickerComponent, DatePickerMode, DatePickerProps} from './date-picker.types'
@@ -21,6 +24,8 @@ const formatted = (value: Date, mode: DatePickerMode): string => {
21
24
  /**
22
25
  * The value as a field that opens Material's date picker dialog on a tap. Android's picker *is* a dialog — mounted, it
23
26
  * opens — so it is mounted only while open, and unmounted on confirm or dismiss, as the component asks its caller to.
27
+ * Both are React Native views; inside a `Screen` or `Island` `RNHostView` carries them into that host, sized to the
28
+ * field (see `useInsideHost`).
24
29
  */
25
30
  export const DatePicker: DatePickerComponent = ({
26
31
  max,
@@ -31,6 +36,7 @@ export const DatePicker: DatePickerComponent = ({
31
36
  value,
32
37
  }: DatePickerProps) => {
33
38
  const theme = useAppTheme()
39
+ const insideHost = useInsideHost()
34
40
  const [open, setOpen] = useState(false)
35
41
 
36
42
  const show = useCallback(() => setOpen(true), [])
@@ -44,7 +50,7 @@ export const DatePicker: DatePickerComponent = ({
44
50
  [onValueChange]
45
51
  )
46
52
 
47
- return (
53
+ const field = (
48
54
  <>
49
55
  <Pressable
50
56
  alignSelf="flex-start"
@@ -71,4 +77,12 @@ export const DatePicker: DatePickerComponent = ({
71
77
  ) : null}
72
78
  </>
73
79
  )
80
+
81
+ return insideHost ? (
82
+ <RNHostView matchContents>
83
+ <Box>{field}</Box>
84
+ </RNHostView>
85
+ ) : (
86
+ field
87
+ )
74
88
  }
@@ -3,6 +3,7 @@ import {datePickerStyle, labelsHidden, tint} from '@expo/ui/swift-ui/modifiers'
3
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
4
  import {useMemo} from 'react'
5
5
 
6
+ import {useInsideHost} from '../../host-context'
6
7
  import type {DatePickerComponent, DatePickerMode, DatePickerProps} from './date-picker.types'
7
8
 
8
9
  const COMPONENTS = {
@@ -15,7 +16,8 @@ const COMPONENTS = {
15
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
16
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
17
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,
18
- * beside it; the calendar is in the accent.
19
+ * beside it; the calendar is in the accent. Inside a `Screen` or `Island` it renders into that host instead (see
20
+ * `useInsideHost`).
19
21
  */
20
22
  export const DatePicker: DatePickerComponent = ({
21
23
  max,
@@ -26,22 +28,31 @@ export const DatePicker: DatePickerComponent = ({
26
28
  value,
27
29
  }: DatePickerProps) => {
28
30
  const theme = useAppTheme()
31
+ const insideHost = useInsideHost()
29
32
 
30
33
  const modifiers = useMemo(
31
34
  () => [datePickerStyle('compact'), labelsHidden(), tint(theme.color('fill.accent.primary'))],
32
35
  [theme]
33
36
  )
34
37
 
38
+ const picker = (
39
+ <SwiftUIDatePicker
40
+ displayedComponents={[...COMPONENTS[mode]]}
41
+ modifiers={modifiers}
42
+ onDateChange={onValueChange}
43
+ range={min || max ? {end: max, start: min} : undefined}
44
+ selection={value}
45
+ testID={testID}
46
+ />
47
+ )
48
+
49
+ if (insideHost) {
50
+ return picker
51
+ }
52
+
35
53
  return (
36
54
  <Host colorScheme={theme.dark ? 'dark' : 'light'} matchContents>
37
- <SwiftUIDatePicker
38
- displayedComponents={[...COMPONENTS[mode]]}
39
- modifiers={modifiers}
40
- onDateChange={onValueChange}
41
- range={min || max ? {end: max, start: min} : undefined}
42
- selection={value}
43
- testID={testID}
44
- />
55
+ {picker}
45
56
  </Host>
46
57
  )
47
58
  }
@@ -5,7 +5,7 @@ export type DatePickerMode = 'date' | 'datetime' | 'time'
5
5
  /**
6
6
  * A date or time picker: SwiftUI's compact `DatePicker` on iOS, a field that opens Material's picker dialog on a tap
7
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`.
8
+ * its label like a `Text`. Inside a `Screen` or `Island` it joins that host instead, beside a native `Text` label.
9
9
  */
10
10
  export type DatePickerProps = {
11
11
  /** The latest date that can be picked. Android ignores it in `time` mode. */
@@ -12,7 +12,7 @@ import {useAppTheme} from '@hzblj/kara-theme'
12
12
  import {useEffect, useMemo, useState} from 'react'
13
13
 
14
14
  import {Glyph} from '../../glyph'
15
- import {HostBoundary} from '../../host-context'
15
+ import {HostBoundary, useInsideHost} from '../../host-context'
16
16
  import type {PickerComponent, PickerProps} from './picker.types'
17
17
 
18
18
  const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
@@ -20,9 +20,12 @@ const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
20
20
  /**
21
21
  * Material's exposed dropdown: a read-only outlined field the width of its container, opening a menu of the options
22
22
  * under it. Its own host, themed like an `Island`, so it drops into React Native layout like the iOS picker does.
23
+ * Inside a `Screen` or `Island` it renders into that host instead (see `useInsideHost`): a host of its own there
24
+ * would leave the outer one no width, and the native views beside it would disappear.
23
25
  */
24
26
  export const Picker: PickerComponent = ({disabled = false, onValueChange, options, testID, value}: PickerProps) => {
25
27
  const theme = useAppTheme()
28
+ const insideHost = useInsideHost()
26
29
  const [expanded, setExpanded] = useState(false)
27
30
  const selectedLabel = options.find(option => option.value === value)?.label ?? ''
28
31
  const label = useNativeState(selectedLabel)
@@ -37,52 +40,58 @@ export const Picker: PickerComponent = ({disabled = false, onValueChange, option
37
40
  label.set(selectedLabel)
38
41
  }, [label, selectedLabel])
39
42
 
40
- return (
41
- <Host colorScheme={theme.dark ? 'dark' : 'light'} matchContents={MATCH_HEIGHT_ONLY} seedColor={theme.materialSeed}>
42
- <HostBoundary>
43
- <ExposedDropdownMenuBox expanded={expanded} onExpandedChange={disabled ? undefined : setExpanded}>
44
- <OutlinedTextField
45
- enabled={!disabled}
46
- modifiers={fieldModifiers}
47
- readOnly
48
- singleLine
49
- textStyle={{
50
- color: theme.colorString('content.primary'),
51
- fontFamily: theme.typography.body.fontFamily,
52
- fontWeight: theme.typography.body.fontWeight,
43
+ const menu = (
44
+ <ExposedDropdownMenuBox expanded={expanded} onExpandedChange={disabled ? undefined : setExpanded}>
45
+ <OutlinedTextField
46
+ enabled={!disabled}
47
+ modifiers={fieldModifiers}
48
+ readOnly
49
+ singleLine
50
+ textStyle={{
51
+ color: theme.colorString('content.primary'),
52
+ fontFamily: theme.typography.body.fontFamily,
53
+ fontWeight: theme.typography.body.fontWeight,
54
+ }}
55
+ value={label}
56
+ >
57
+ <OutlinedTextField.TrailingIcon>
58
+ <Glyph color="content.secondary" name="chevronDown" size="s20" />
59
+ </OutlinedTextField.TrailingIcon>
60
+ </OutlinedTextField>
61
+ <ExposedDropdownMenu
62
+ containerColor={theme.color('surface.primary')}
63
+ expanded={expanded}
64
+ onDismissRequest={() => setExpanded(false)}
65
+ >
66
+ {options.map(option => (
67
+ <DropdownMenuItem
68
+ key={option.value}
69
+ onClick={() => {
70
+ setExpanded(false)
71
+ onValueChange?.(option.value)
53
72
  }}
54
- value={label}
55
73
  >
56
- <OutlinedTextField.TrailingIcon>
57
- <Glyph color="content.secondary" name="chevronDown" size="s20" />
58
- </OutlinedTextField.TrailingIcon>
59
- </OutlinedTextField>
60
- <ExposedDropdownMenu
61
- containerColor={theme.color('surface.primary')}
62
- expanded={expanded}
63
- onDismissRequest={() => setExpanded(false)}
64
- >
65
- {options.map(option => (
66
- <DropdownMenuItem
67
- key={option.value}
68
- onClick={() => {
69
- setExpanded(false)
70
- onValueChange?.(option.value)
71
- }}
74
+ <DropdownMenuItem.Text>
75
+ <ComposeText
76
+ color={theme.colorString(option.value === value ? 'content.accent' : 'content.primary')}
77
+ style={theme.typography.body}
72
78
  >
73
- <DropdownMenuItem.Text>
74
- <ComposeText
75
- color={theme.colorString(option.value === value ? 'content.accent' : 'content.primary')}
76
- style={theme.typography.body}
77
- >
78
- {option.label}
79
- </ComposeText>
80
- </DropdownMenuItem.Text>
81
- </DropdownMenuItem>
82
- ))}
83
- </ExposedDropdownMenu>
84
- </ExposedDropdownMenuBox>
85
- </HostBoundary>
79
+ {option.label}
80
+ </ComposeText>
81
+ </DropdownMenuItem.Text>
82
+ </DropdownMenuItem>
83
+ ))}
84
+ </ExposedDropdownMenu>
85
+ </ExposedDropdownMenuBox>
86
+ )
87
+
88
+ if (insideHost) {
89
+ return menu
90
+ }
91
+
92
+ return (
93
+ <Host colorScheme={theme.dark ? 'dark' : 'light'} matchContents={MATCH_HEIGHT_ONLY} seedColor={theme.materialSeed}>
94
+ <HostBoundary>{menu}</HostBoundary>
86
95
  </Host>
87
96
  )
88
97
  }
@@ -1,23 +1,44 @@
1
1
  import {Picker as NativePicker} from '@expo/ui/community/picker'
2
+ import {Picker as SwiftUIPicker, Text as SwiftUIText} from '@expo/ui/swift-ui'
3
+ import {disabled as disabledModifier, foregroundStyle, pickerStyle, tag} from '@expo/ui/swift-ui/modifiers'
2
4
  import {useAppTheme} from '@hzblj/kara-theme'
3
- import {useCallback} from 'react'
5
+ import {useCallback, useMemo} from 'react'
4
6
 
7
+ import {useInsideHost} from '../../host-context'
5
8
  import type {PickerComponent, PickerProps} from './picker.types'
6
9
 
10
+ /**
11
+ * A SwiftUI wheel. On its own it is `@expo/ui`'s community picker, which brings a host of its own. Inside a `Screen` or
12
+ * `Island` it is the same wheel rendered into that host instead (see `useInsideHost`): a host nested in the island's
13
+ * stack is given no height there, and the wheel spills over the views around it.
14
+ */
7
15
  export const Picker: PickerComponent = ({disabled = false, onValueChange, options, testID, value}: PickerProps) => {
8
16
  const theme = useAppTheme()
17
+ const insideHost = useInsideHost()
18
+ const color = theme.colorString('content.primary')
9
19
 
10
20
  const onChange = useCallback((next: string | number | null) => onValueChange?.(String(next)), [onValueChange])
21
+ const wheelModifiers = useMemo(
22
+ () => [pickerStyle('wheel'), ...(disabled ? [disabledModifier(true)] : [])],
23
+ [disabled]
24
+ )
25
+
26
+ if (insideHost) {
27
+ return (
28
+ <SwiftUIPicker modifiers={wheelModifiers} onSelectionChange={onChange} selection={value} testID={testID}>
29
+ {options.map(option => (
30
+ <SwiftUIText key={option.value} modifiers={[tag(option.value), foregroundStyle(color)]}>
31
+ {option.label}
32
+ </SwiftUIText>
33
+ ))}
34
+ </SwiftUIPicker>
35
+ )
36
+ }
11
37
 
12
38
  return (
13
39
  <NativePicker enabled={!disabled} onValueChange={onChange} selectedValue={value} testID={testID}>
14
40
  {options.map(option => (
15
- <NativePicker.Item
16
- color={theme.colorString('content.primary')}
17
- key={option.value}
18
- label={option.label}
19
- value={option.value}
20
- />
41
+ <NativePicker.Item color={color} key={option.value} label={option.label} value={option.value} />
21
42
  ))}
22
43
  </NativePicker>
23
44
  )
@@ -10,7 +10,8 @@ export type PickerOption = {
10
10
 
11
11
  /**
12
12
  * A single choice from a list: a SwiftUI wheel picker on iOS, Material's exposed dropdown on Android. It brings its
13
- * own host, so it renders outside `Screen` and `Island` as well.
13
+ * own host, so it renders outside `Screen` and `Island` as well. Inside one it renders into that host instead, so it
14
+ * can share a stack with other native views.
14
15
  */
15
16
  export type PickerProps = {
16
17
  /** Stops the user changing the selection, and dims the picker. */
@@ -19,7 +20,10 @@ export type PickerProps = {
19
20
  onValueChange?: (value: string) => void
20
21
  /** The choices, in order. */
21
22
  options: PickerOption[]
22
- /** Android: the dropdown field's test tag. iOS: `@expo/ui`'s wheel picker drops it. */
23
+ /**
24
+ * Android: the dropdown field's test tag. iOS: the wheel's, inside a `Screen` or `Island` only; on its own,
25
+ * `@expo/ui`'s wheel picker drops it.
26
+ */
23
27
  testID?: string
24
28
  /** The selected option's `value`. Controlled: pass back what `onValueChange` reports. */
25
29
  value: string
@@ -4,6 +4,7 @@ import {haptics} from '@hzblj/expo-rich-haptics'
4
4
  import {useAppTheme} from '@hzblj/kara-theme'
5
5
  import {useCallback, useMemo} from 'react'
6
6
 
7
+ import {useInsideHost} from '../../host-context'
7
8
  import type {SegmentedControlComponent, SegmentedControlProps} from './segmented-control.types'
8
9
 
9
10
  const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
@@ -13,8 +14,9 @@ const SEGMENT = [weight(1)]
13
14
 
14
15
  /**
15
16
  * Material's single-choice segmented buttons, the selected one filled with the accent like a checked `Switch`. Its own
16
- * host, themed like an `Island`, so it drops into React Native layout the way the iOS control does. Material plays
17
- * nothing on a new segment, so this plays `selection`.
17
+ * host, themed like an `Island`, so it drops into React Native layout the way the iOS control does. Inside a `Screen`
18
+ * or `Island` it renders into that host instead (see `useInsideHost`). Material plays nothing on a new segment, so this
19
+ * plays `selection`.
18
20
  */
19
21
  export const SegmentedControl: SegmentedControlComponent = ({
20
22
  disabled = false,
@@ -24,6 +26,7 @@ export const SegmentedControl: SegmentedControlComponent = ({
24
26
  values,
25
27
  }: SegmentedControlProps) => {
26
28
  const theme = useAppTheme()
29
+ const insideHost = useInsideHost()
27
30
 
28
31
  // Every segment keeps the same outline, the selected one too: an accent border would run its fill out over the line
29
32
  // the others draw inside, and make it look taller than its neighbours.
@@ -58,24 +61,32 @@ export const SegmentedControl: SegmentedControlComponent = ({
58
61
  [onChange, selectedIndex, values]
59
62
  )
60
63
 
64
+ const row = (
65
+ <SingleChoiceSegmentedButtonRow modifiers={rowModifiers}>
66
+ {values.map((label, index) => (
67
+ <SegmentedButton
68
+ colors={colors}
69
+ enabled={!disabled}
70
+ key={index}
71
+ modifiers={SEGMENT}
72
+ onClick={() => select(index)}
73
+ selected={index === selectedIndex}
74
+ >
75
+ <SegmentedButton.Label>
76
+ <Text style={theme.typography.subheadlineMedium}>{label}</Text>
77
+ </SegmentedButton.Label>
78
+ </SegmentedButton>
79
+ ))}
80
+ </SingleChoiceSegmentedButtonRow>
81
+ )
82
+
83
+ if (insideHost) {
84
+ return row
85
+ }
86
+
61
87
  return (
62
88
  <Host colorScheme={theme.dark ? 'dark' : 'light'} matchContents={MATCH_HEIGHT_ONLY} seedColor={theme.materialSeed}>
63
- <SingleChoiceSegmentedButtonRow modifiers={rowModifiers}>
64
- {values.map((label, index) => (
65
- <SegmentedButton
66
- colors={colors}
67
- enabled={!disabled}
68
- key={index}
69
- modifiers={SEGMENT}
70
- onClick={() => select(index)}
71
- selected={index === selectedIndex}
72
- >
73
- <SegmentedButton.Label>
74
- <Text style={theme.typography.subheadlineMedium}>{label}</Text>
75
- </SegmentedButton.Label>
76
- </SegmentedButton>
77
- ))}
78
- </SingleChoiceSegmentedButtonRow>
89
+ {row}
79
90
  </Host>
80
91
  )
81
92
  }
@@ -1,13 +1,18 @@
1
+ import {RNHostView} from '@expo/ui/swift-ui'
1
2
  import {SegmentedControl as NativeSegmentedControl} from '@hzblj/kara-native/swift-ui'
2
3
  import {useAppTheme} from '@hzblj/kara-theme'
3
4
  import {useCallback, useMemo} from 'react'
4
5
 
6
+ import {useInsideHost} from '../../host-context'
5
7
  import type {SegmentedControlComponent, SegmentedControlProps} from './segmented-control.types'
6
8
 
9
+ const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
10
+
7
11
  /**
8
12
  * kara's own `UISegmentedControl`, with the pill in the accent and white text on it — the system control tints only the
9
13
  * pill, and SwiftUI's segmented picker not even that. A plain `UIView` in React Native layout: it needs no host, takes
10
- * the width it is given and UIKit's height. UIKit plays the control's own feedback.
14
+ * the width it is given and UIKit's height. Inside a `Screen` or `Island` it is carried into that host by `RNHostView`
15
+ * (see `useInsideHost`), the host's width and its own height. UIKit plays the control's own feedback.
11
16
  */
12
17
  export const SegmentedControl: SegmentedControlComponent = ({
13
18
  disabled = false,
@@ -17,6 +22,7 @@ export const SegmentedControl: SegmentedControlComponent = ({
17
22
  values,
18
23
  }: SegmentedControlProps) => {
19
24
  const theme = useAppTheme()
25
+ const insideHost = useInsideHost()
20
26
  const face = theme.typography.footnoteMedium
21
27
 
22
28
  // The native control speaks in values; a segment's is its index, so two labels may read the same.
@@ -30,7 +36,7 @@ export const SegmentedControl: SegmentedControlComponent = ({
30
36
  [onChange, values]
31
37
  )
32
38
 
33
- return (
39
+ const control = (
34
40
  <NativeSegmentedControl
35
41
  activePillColor={theme.color('fill.accent.primary')}
36
42
  activeTextColor={theme.color('content.on.accent')}
@@ -44,4 +50,6 @@ export const SegmentedControl: SegmentedControlComponent = ({
44
50
  testID={testID}
45
51
  />
46
52
  )
53
+
54
+ return insideHost ? <RNHostView matchContents={MATCH_HEIGHT_ONLY}>{control}</RNHostView> : control
47
55
  }
@@ -3,7 +3,8 @@ import type {ReactNode} from 'react'
3
3
  /**
4
4
  * A row of mutually exclusive segments, the selected one in the accent: kara's own `UISegmentedControl` on iOS,
5
5
  * Material's single-choice segmented buttons on Android. It needs no `Screen` or `Island`: iOS is a plain view, Android
6
- * brings its own host. It takes the width it is given; the height is the platform's.
6
+ * brings its own host. Inside one it joins that host, so it can share a stack with other native views. It takes the
7
+ * width it is given; the height is the platform's.
7
8
  */
8
9
  export type SegmentedControlProps = {
9
10
  /** Stops the user changing the segment, and dims the control. */
@@ -0,0 +1,45 @@
1
+ import type {ColorToken} from '@hzblj/kara-theme'
2
+ import {Children, isValidElement, type ReactNode} from 'react'
3
+
4
+ /** A run of the text: a string child, or a nested `Text` with a colour of its own. */
5
+ export type TextSpan = {color?: ColorToken; text: string}
6
+
7
+ const textOf = (children: ReactNode): string =>
8
+ Children.toArray(children)
9
+ .map(child => (typeof child === 'string' || typeof child === 'number' ? String(child) : ''))
10
+ .join('')
11
+
12
+ /**
13
+ * The children as runs when any of them is a nested `Text` (`type`, the platform's own component), `null` when they
14
+ * are only text. A span keeps its `color`; anything else it sets is the outer text's.
15
+ */
16
+ export const spansOf = (children: ReactNode, type: unknown): TextSpan[] | null => {
17
+ const items = Children.toArray(children)
18
+
19
+ if (!items.some(child => isValidElement(child) && child.type === type)) {
20
+ return null
21
+ }
22
+
23
+ return items.flatMap<TextSpan>(child => {
24
+ if (typeof child === 'string' || typeof child === 'number') {
25
+ return [{text: String(child)}]
26
+ }
27
+ if (isValidElement<{children?: ReactNode; color?: ColorToken}>(child) && child.type === type) {
28
+ return [{color: child.props.color, text: textOf(child.props.children)}]
29
+ }
30
+ return []
31
+ })
32
+ }
33
+
34
+ /** The colours and the text folded into one number, for SwiftUI's `.animation(_:value:)`. A 32-bit FNV-1a hash. */
35
+ export const spansKey = (color: ColorToken, spans: readonly TextSpan[] | null, text: string): number => {
36
+ let hash = 0x811c9dc5
37
+ const value = spans ? spans.map(span => `${span.color ?? ''}:${span.text}`).join('|') : text
38
+
39
+ for (const character of `${color}|${value}`) {
40
+ hash ^= character.charCodeAt(0)
41
+ hash = Math.imul(hash, 0x01000193)
42
+ }
43
+
44
+ return hash >>> 0
45
+ }
@@ -1,10 +1,18 @@
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 {splitAccessibility, useAccessibilityModifiers, useSxModifiers} from '@hzblj/kara-sx'
4
- import {useAppTheme} from '@hzblj/kara-theme'
3
+ import {AnimatedText, type TextAnimation} from '@hzblj/kara-native/jetpack-compose'
4
+ import {
5
+ animateConfig,
6
+ type SxAnimateConfig,
7
+ splitAccessibility,
8
+ useAccessibilityModifiers,
9
+ useSxModifiers,
10
+ } from '@hzblj/kara-sx'
11
+ import {duration, easing, springPhysics, useAppTheme} from '@hzblj/kara-theme'
5
12
  import {useMemo} from 'react'
6
13
 
7
14
  import {useHostGuard} from '../../host-context'
15
+ import {spansOf} from './spans'
8
16
  import type {TextComponent, TextDecoration, TextProps, TextTransform} from './text.types'
9
17
 
10
18
  const DECORATION = {
@@ -24,6 +32,32 @@ const applyTransform = (children: TextProps['children'], transform: TextTransfor
24
32
  const decorationOf = (decoration: TextDecoration | undefined) =>
25
33
  decoration === undefined ? undefined : DECORATION[decoration]
26
34
 
35
+ const transformed = (text: string, transform: TextTransform | undefined) =>
36
+ transform === 'uppercase' ? text.toUpperCase() : transform === 'lowercase' ? text.toLowerCase() : text
37
+
38
+ /**
39
+ * `animate` for the spans' colours, as `@hzblj/kara-sx` animates the rest on Compose: a spring token's physics, and
40
+ * `smoothOut` as a no-bounce spring over its duration; the other curves as tweens along their béziers.
41
+ */
42
+ const textAnimation = (config: SxAnimateConfig): TextAnimation => {
43
+ if (config.spring) {
44
+ const {dampingRatio, stiffness} = springPhysics(config.spring)
45
+
46
+ return {dampingRatio, stiffness, type: 'spring'}
47
+ }
48
+
49
+ const milliseconds = duration[config.duration ?? 'fast']
50
+ const curve = config.easing ?? 'smoothOut'
51
+
52
+ if (curve === 'smoothOut') {
53
+ const omega = (2 * Math.PI * 1000) / milliseconds
54
+
55
+ return {dampingRatio: 1, stiffness: omega * omega, type: 'spring'}
56
+ }
57
+
58
+ return {delayMillis: config.delay, durationMillis: milliseconds, easing: easing[curve], type: 'tween'}
59
+ }
60
+
27
61
  export const Text: TextComponent = ({
28
62
  align,
29
63
  children,
@@ -45,6 +79,32 @@ export const Text: TextComponent = ({
45
79
  () => [...sxModifiers, ...(testID ? [testIdModifier(testID)] : []), ...accessibilityModifiers],
46
80
  [accessibilityModifiers, sxModifiers, testID]
47
81
  )
82
+ const spans = useMemo(() => spansOf(children, Text), [children])
83
+ const animate = sx.animate
84
+
85
+ // `@expo/ui`'s text takes spans but animates none: with `animate`, kara's own Compose text eases each span's colour.
86
+ if (animate !== undefined && animate !== false) {
87
+ const runs = spans ?? [{text: typeof children === 'string' || typeof children === 'number' ? String(children) : ''}]
88
+
89
+ return (
90
+ <AnimatedText
91
+ animation={textAnimation(animateConfig(animate))}
92
+ fontFamily={style.fontFamily}
93
+ fontSize={style.fontSize}
94
+ fontWeight={style.fontWeight}
95
+ lineHeight={style.lineHeight}
96
+ maxLines={numberOfLines}
97
+ modifiers={modifiers}
98
+ spans={runs.map(span => ({
99
+ color: span.color ? theme.colorString(span.color) : undefined,
100
+ text: transformed(span.text, transform),
101
+ }))}
102
+ textAlign={align}
103
+ textColor={theme.colorString(color)}
104
+ textDecoration={decorationOf(decoration)}
105
+ />
106
+ )
107
+ }
48
108
 
49
109
  return (
50
110
  <ComposeText
@@ -60,7 +120,13 @@ export const Text: TextComponent = ({
60
120
  textDecoration: decorationOf(decoration),
61
121
  }}
62
122
  >
63
- {applyTransform(children, transform)}
123
+ {spans
124
+ ? spans.map((span, index) => (
125
+ <ComposeText color={span.color ? theme.colorString(span.color) : undefined} key={index}>
126
+ {transformed(span.text, transform)}
127
+ </ComposeText>
128
+ ))
129
+ : applyTransform(children, transform)}
64
130
  </ComposeText>
65
131
  )
66
132
  }
@@ -10,11 +10,18 @@ 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, splitAccessibility, useAccessibilityModifiers, useSxModifiers} from '@hzblj/kara-sx'
13
+ import {
14
+ type FrameAlignment,
15
+ splitAccessibility,
16
+ textColorTransition,
17
+ useAccessibilityModifiers,
18
+ useSxModifiers,
19
+ } from '@hzblj/kara-sx'
14
20
  import {useAppTheme} from '@hzblj/kara-theme'
15
21
  import {useMemo} from 'react'
16
22
 
17
23
  import {useHostGuard} from '../../host-context'
24
+ import {spansKey, spansOf} from './spans'
18
25
  import type {TextAlign, TextComponent, TextProps} from './text.types'
19
26
 
20
27
  const ALIGNMENT = {
@@ -48,6 +55,13 @@ export const Text: TextComponent = ({
48
55
  const [accessibility, sx] = splitAccessibility(props)
49
56
  const sxModifiers = useSxModifiers(sx, FRAME_ALIGNMENT[align ?? 'left'])
50
57
  const accessibilityModifiers = useAccessibilityModifiers(accessibility)
58
+ const spans = useMemo(() => spansOf(children, Text), [children])
59
+ const animate = sx.animate
60
+ // With `animate`, SwiftUI interpolates the text when a colour changes: the text's own or a span's.
61
+ const transition = useMemo(
62
+ () => textColorTransition(animate, spansKey(color, spans, typeof children === 'string' ? children : '')),
63
+ [animate, children, color, spans]
64
+ )
51
65
 
52
66
  const modifiers = useMemo(() => {
53
67
  const list: ModifierConfig[] = [
@@ -78,12 +92,30 @@ export const Text: TextComponent = ({
78
92
  list.push(lineLimit(numberOfLines))
79
93
  }
80
94
 
81
- return [...list, ...sxModifiers, ...accessibilityModifiers]
82
- }, [accessibilityModifiers, align, color, decoration, numberOfLines, style, sxModifiers, theme, transform])
95
+ return [...list, ...transition, ...sxModifiers, ...accessibilityModifiers]
96
+ }, [
97
+ accessibilityModifiers,
98
+ align,
99
+ color,
100
+ decoration,
101
+ numberOfLines,
102
+ style,
103
+ sxModifiers,
104
+ theme,
105
+ transform,
106
+ transition,
107
+ ])
83
108
 
84
109
  return (
85
110
  <SwiftUIText modifiers={modifiers} testID={testID}>
86
- {children}
111
+ {spans
112
+ ? spans.map((span, index) => (
113
+ // Concatenated into one SwiftUI `Text`: a span takes its colour, the font and the rest are the outer text's.
114
+ <SwiftUIText key={index} modifiers={span.color ? [foregroundStyle(theme.color(span.color))] : undefined}>
115
+ {span.text}
116
+ </SwiftUIText>
117
+ ))
118
+ : children}
87
119
  </SwiftUIText>
88
120
  )
89
121
  }