@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.
- package/CHANGELOG.md +26 -0
- package/lib/module/atoms/activity-indicator/activity-indicator.android.js +14 -7
- package/lib/module/atoms/activity-indicator/activity-indicator.android.js.map +1 -1
- package/lib/module/atoms/activity-indicator/activity-indicator.ios.js +12 -2
- package/lib/module/atoms/activity-indicator/activity-indicator.ios.js.map +1 -1
- package/lib/module/atoms/date-picker/date-picker.android.js +13 -1
- package/lib/module/atoms/date-picker/date-picker.android.js.map +1 -1
- package/lib/module/atoms/date-picker/date-picker.ios.js +19 -12
- package/lib/module/atoms/date-picker/date-picker.ios.js.map +1 -1
- package/lib/module/atoms/picker/picker.android.js +48 -41
- package/lib/module/atoms/picker/picker.android.js.map +1 -1
- package/lib/module/atoms/picker/picker.ios.js +25 -2
- package/lib/module/atoms/picker/picker.ios.js.map +1 -1
- package/lib/module/atoms/segmented-control/segmented-control.android.js +25 -18
- package/lib/module/atoms/segmented-control/segmented-control.android.js.map +1 -1
- package/lib/module/atoms/segmented-control/segmented-control.ios.js +15 -2
- package/lib/module/atoms/segmented-control/segmented-control.ios.js.map +1 -1
- package/lib/module/atoms/text/spans.js +44 -0
- package/lib/module/atoms/text/spans.js.map +1 -0
- package/lib/module/atoms/text/text.android.js +68 -3
- package/lib/module/atoms/text/text.android.js.map +1 -1
- package/lib/module/atoms/text/text.ios.js +15 -4
- package/lib/module/atoms/text/text.ios.js.map +1 -1
- package/lib/module/host-context.js +8 -0
- package/lib/module/host-context.js.map +1 -1
- package/lib/typescript/atoms/activity-indicator/activity-indicator.ios.d.ts +4 -1
- package/lib/typescript/atoms/activity-indicator/activity-indicator.types.d.ts +2 -1
- package/lib/typescript/atoms/date-picker/date-picker.ios.d.ts +2 -1
- package/lib/typescript/atoms/date-picker/date-picker.types.d.ts +1 -1
- package/lib/typescript/atoms/picker/picker.ios.d.ts +5 -0
- package/lib/typescript/atoms/picker/picker.types.d.ts +6 -2
- package/lib/typescript/atoms/segmented-control/segmented-control.ios.d.ts +2 -1
- package/lib/typescript/atoms/segmented-control/segmented-control.types.d.ts +2 -1
- package/lib/typescript/atoms/text/spans.d.ts +14 -0
- package/lib/typescript/atoms/text/text.types.d.ts +8 -1
- package/lib/typescript/host-context.d.ts +7 -0
- package/package.json +4 -4
- package/src/atoms/activity-indicator/activity-indicator.android.tsx +18 -5
- package/src/atoms/activity-indicator/activity-indicator.ios.tsx +10 -2
- package/src/atoms/activity-indicator/activity-indicator.types.ts +2 -1
- package/src/atoms/date-picker/date-picker.android.tsx +15 -1
- package/src/atoms/date-picker/date-picker.ios.tsx +20 -9
- package/src/atoms/date-picker/date-picker.types.ts +1 -1
- package/src/atoms/picker/picker.android.tsx +53 -44
- package/src/atoms/picker/picker.ios.tsx +28 -7
- package/src/atoms/picker/picker.types.ts +6 -2
- package/src/atoms/segmented-control/segmented-control.android.tsx +29 -18
- package/src/atoms/segmented-control/segmented-control.ios.tsx +10 -2
- package/src/atoms/segmented-control/segmented-control.types.ts +2 -1
- package/src/atoms/text/spans.ts +45 -0
- package/src/atoms/text/text.android.tsx +69 -3
- package/src/atoms/text/text.ios.tsx +36 -4
- package/src/atoms/text/text.types.ts +8 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
<
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
/**
|
|
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.
|
|
17
|
-
* nothing on a new segment, so this
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
}, [
|
|
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
|
-
{
|
|
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
|
}
|