@hzblj/kara-components 0.26.2 → 0.27.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 +32 -0
- package/lib/module/atoms/activity-indicator/activity-indicator.android.js +3 -4
- package/lib/module/atoms/activity-indicator/activity-indicator.android.js.map +1 -1
- package/lib/module/atoms/date-picker/date-picker.android.js +3 -4
- package/lib/module/atoms/date-picker/date-picker.android.js.map +1 -1
- package/lib/module/atoms/island/island.android.js +2 -6
- package/lib/module/atoms/island/island.android.js.map +1 -1
- package/lib/module/atoms/picker/picker.android.js +3 -4
- package/lib/module/atoms/picker/picker.android.js.map +1 -1
- package/lib/module/atoms/pressable/pressable.js +5 -4
- package/lib/module/atoms/pressable/pressable.js.map +1 -1
- package/lib/module/atoms/segmented-control/segmented-control.android.js +3 -4
- package/lib/module/atoms/segmented-control/segmented-control.android.js.map +1 -1
- package/lib/module/hooks/compose-host.js +27 -0
- package/lib/module/hooks/compose-host.js.map +1 -0
- package/lib/module/molecules/navigation-bar/bar-search-field.js +80 -0
- package/lib/module/molecules/navigation-bar/bar-search-field.js.map +1 -0
- package/lib/module/molecules/navigation-bar/navigation-bar.android.js +94 -22
- package/lib/module/molecules/navigation-bar/navigation-bar.android.js.map +1 -1
- package/lib/module/molecules/rn-list-item/rn-list-item.js +3 -2
- package/lib/module/molecules/rn-list-item/rn-list-item.js.map +1 -1
- package/lib/module/molecules/rn-tooltip/rn-tooltip.android.js +3 -4
- package/lib/module/molecules/rn-tooltip/rn-tooltip.android.js.map +1 -1
- package/lib/module/organisms/context-menu/context-menu.android.js +4 -5
- package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
- package/lib/module/organisms/header/header-options.android.js +2 -6
- package/lib/module/organisms/header/header-options.android.js.map +1 -1
- package/lib/module/organisms/header/header-search-accessory.android.js +29 -2
- package/lib/module/organisms/header/header-search-accessory.android.js.map +1 -1
- package/lib/module/organisms/header/header-search-input.android.js +11 -51
- package/lib/module/organisms/header/header-search-input.android.js.map +1 -1
- package/lib/module/organisms/header/use-header-search.js +19 -11
- package/lib/module/organisms/header/use-header-search.js.map +1 -1
- package/lib/module/organisms/sheet/sheet.js +17 -1
- package/lib/module/organisms/sheet/sheet.js.map +1 -1
- package/lib/module/organisms/sheet/use-sheet-header.js +7 -1
- package/lib/module/organisms/sheet/use-sheet-header.js.map +1 -1
- package/lib/module/templates/screen/screen.android.js +3 -6
- package/lib/module/templates/screen/screen.android.js.map +1 -1
- package/lib/typescript/hooks/compose-host.d.ts +8 -0
- package/lib/typescript/molecules/navigation-bar/bar-search-field.d.ts +29 -0
- package/lib/typescript/molecules/navigation-bar/navigation-bar.types.d.ts +8 -6
- package/lib/typescript/organisms/header/header.types.d.ts +20 -10
- package/lib/typescript/organisms/header/use-header-search.d.ts +5 -3
- package/lib/typescript/organisms/sheet/sheet.types.d.ts +1 -1
- package/package.json +4 -4
- package/src/atoms/activity-indicator/activity-indicator.android.tsx +3 -6
- package/src/atoms/date-picker/date-picker.android.tsx +3 -12
- package/src/atoms/island/island.android.tsx +3 -7
- package/src/atoms/picker/picker.android.tsx +3 -3
- package/src/atoms/pressable/pressable.tsx +5 -7
- package/src/atoms/segmented-control/segmented-control.android.tsx +3 -6
- package/src/hooks/compose-host.tsx +20 -0
- package/src/molecules/navigation-bar/bar-search-field.tsx +94 -0
- package/src/molecules/navigation-bar/navigation-bar.android.tsx +119 -19
- package/src/molecules/navigation-bar/navigation-bar.types.ts +8 -6
- package/src/molecules/rn-list-item/rn-list-item.tsx +2 -1
- package/src/molecules/rn-tooltip/rn-tooltip.android.tsx +4 -3
- package/src/organisms/context-menu/context-menu.android.tsx +5 -9
- package/src/organisms/header/header-options.android.tsx +3 -7
- package/src/organisms/header/header-search-accessory.android.tsx +37 -3
- package/src/organisms/header/header-search-input.android.tsx +14 -53
- package/src/organisms/header/header.types.ts +20 -10
- package/src/organisms/header/use-header-search.tsx +18 -10
- package/src/organisms/sheet/sheet.tsx +19 -2
- package/src/organisms/sheet/sheet.types.ts +1 -1
- package/src/organisms/sheet/use-sheet-header.tsx +7 -1
- package/src/templates/screen/screen.android.tsx +4 -7
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {iconResourceName} from '@hzblj/expo-native-icons'
|
|
2
|
+
import {SearchField, type SearchFieldRef} from '@hzblj/kara-native/jetpack-compose'
|
|
3
|
+
import {useAppTheme} from '@hzblj/kara-theme'
|
|
4
|
+
import {type ReactNode, type Ref, useMemo} from 'react'
|
|
5
|
+
import {StyleSheet, View} from 'react-native'
|
|
6
|
+
import type {IconName} from '../../atoms/icon'
|
|
7
|
+
import {ComposeHost} from '../../hooks/compose-host'
|
|
8
|
+
import type {SearchFieldVoice} from './navigation-bar.types'
|
|
9
|
+
|
|
10
|
+
/** Material's search field is 56dp; a row adds 8dp above it and 12dp below, under a top app bar. */
|
|
11
|
+
const FIELD_HEIGHT = 56
|
|
12
|
+
|
|
13
|
+
/** A search row's height under a bar, for what is laid out below it. */
|
|
14
|
+
export const BAR_SEARCH_ROW_HEIGHT = 8 + FIELD_HEIGHT + 12
|
|
15
|
+
|
|
16
|
+
const styles = StyleSheet.create({
|
|
17
|
+
field: {height: FIELD_HEIGHT},
|
|
18
|
+
row: {paddingBottom: 12, paddingHorizontal: 16, paddingTop: 8},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export type BarSearchFieldProps = {
|
|
22
|
+
/** Focuses the field and shows the keyboard as soon as it is composed. */
|
|
23
|
+
autoFocus?: boolean
|
|
24
|
+
/** The text button beside a focused field that empties and blurs it. `Cancel` by default. */
|
|
25
|
+
cancelLabel?: string
|
|
26
|
+
clearIcon?: IconName
|
|
27
|
+
onChangeText: (text: string) => void
|
|
28
|
+
onFocusChange?: (focused: boolean, hasText: boolean) => void
|
|
29
|
+
placeholder: string
|
|
30
|
+
ref?: Ref<SearchFieldRef>
|
|
31
|
+
testID?: string
|
|
32
|
+
voice?: SearchFieldVoice
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The search field kara draws on Android, in a header row or inside a bar: Material's input field in the theme's
|
|
37
|
+
* colours and body face, with the app's icons and the voice. A Compose view, so the caller brings the host. Import it
|
|
38
|
+
* only from `.android` files.
|
|
39
|
+
*/
|
|
40
|
+
export const BarSearchField = ({
|
|
41
|
+
autoFocus,
|
|
42
|
+
cancelLabel = 'Cancel',
|
|
43
|
+
clearIcon,
|
|
44
|
+
onChangeText,
|
|
45
|
+
onFocusChange,
|
|
46
|
+
placeholder,
|
|
47
|
+
ref,
|
|
48
|
+
testID,
|
|
49
|
+
voice,
|
|
50
|
+
}: BarSearchFieldProps) => {
|
|
51
|
+
const theme = useAppTheme()
|
|
52
|
+
const face = theme.typography.body
|
|
53
|
+
|
|
54
|
+
const nativeVoice = useMemo(
|
|
55
|
+
() =>
|
|
56
|
+
voice && {
|
|
57
|
+
...voice,
|
|
58
|
+
microphoneIconName: voice.microphoneIcon ? iconResourceName(voice.microphoneIcon) : undefined,
|
|
59
|
+
stopIconName: voice.stopIcon ? iconResourceName(voice.stopIcon) : undefined,
|
|
60
|
+
},
|
|
61
|
+
[voice]
|
|
62
|
+
)
|
|
63
|
+
const fieldClearIcon = voice?.clearIcon ?? clearIcon
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<SearchField
|
|
67
|
+
autoFocus={autoFocus}
|
|
68
|
+
cancelLabel={cancelLabel}
|
|
69
|
+
clearIconName={fieldClearIcon ? iconResourceName(fieldClearIcon) : undefined}
|
|
70
|
+
clearLabel={voice?.clearLabel ?? 'Clear'}
|
|
71
|
+
containerColor={theme.color('fill.quaternary')}
|
|
72
|
+
fontFamily={face.fontFamily}
|
|
73
|
+
fontSize={face.fontSize}
|
|
74
|
+
iconColor={theme.color('content.secondary')}
|
|
75
|
+
onFocusChange={onFocusChange}
|
|
76
|
+
onQueryChange={onChangeText}
|
|
77
|
+
placeholder={placeholder}
|
|
78
|
+
placeholderColor={theme.color('content.tertiary')}
|
|
79
|
+
ref={ref}
|
|
80
|
+
testID={testID}
|
|
81
|
+
textColor={theme.color('content.primary')}
|
|
82
|
+
voice={nativeVoice}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A search field's own row under a bar, in its own host. */
|
|
88
|
+
export const BarSearchRow = ({children}: {children: ReactNode}) => (
|
|
89
|
+
<View style={styles.row}>
|
|
90
|
+
<ComposeHost ignoreSafeAreaKeyboardInsets style={styles.field}>
|
|
91
|
+
{children}
|
|
92
|
+
</ComposeHost>
|
|
93
|
+
</View>
|
|
94
|
+
)
|
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
import {iconResourceName} from '@hzblj/expo-native-icons'
|
|
2
|
-
import {
|
|
2
|
+
import {GLYPHS} from '@hzblj/kara-native'
|
|
3
|
+
import {type SearchFieldRef, TopAppBar, type TopAppBarItem} from '@hzblj/kara-native/jetpack-compose'
|
|
3
4
|
import {useAppTheme} from '@hzblj/kara-theme'
|
|
4
|
-
import {useMemo} from 'react'
|
|
5
|
+
import {useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'
|
|
6
|
+
|
|
5
7
|
import {Island} from '../../atoms/island'
|
|
8
|
+
import {BarSearchField, BarSearchRow} from './bar-search-field'
|
|
6
9
|
import type {NavigationBarComponent, NavigationBarItem, NavigationBarProps} from './navigation-bar.types'
|
|
7
10
|
|
|
8
11
|
/** Material's small top app bar. */
|
|
9
12
|
const BAR_HEIGHT = 64
|
|
10
13
|
|
|
11
|
-
const DEFAULT_LABELS = {back: 'Back', close: 'Close'} as const
|
|
14
|
+
const DEFAULT_LABELS = {back: 'Back', close: 'Close', search: 'Search'} as const
|
|
15
|
+
|
|
16
|
+
const NO_ITEMS: NavigationBarItem[] = []
|
|
17
|
+
|
|
18
|
+
const ignoreDetachedView = () => undefined
|
|
12
19
|
|
|
13
20
|
const toNativeItem = ({onPress, testID, ...item}: NavigationBarItem): TopAppBarItem =>
|
|
14
21
|
item.type === 'icon'
|
|
15
22
|
? {iconName: iconResourceName(item.icon), label: item.label, onPress, testID, type: 'icon'}
|
|
16
23
|
: {label: item.label ?? DEFAULT_LABELS[item.type], onPress, testID, type: item.type}
|
|
17
24
|
|
|
18
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Material's top app bar in its own host. `search` is a field in a row under the bar (`stacked`), or a search item
|
|
27
|
+
* that grows into the field over the bar (`integratedButton`) and collapses back once the field is cancelled, cleared
|
|
28
|
+
* or left empty. `separated` has no Material counterpart.
|
|
29
|
+
*/
|
|
19
30
|
export const NavigationBar: NavigationBarComponent = ({
|
|
20
31
|
inverted = false,
|
|
21
32
|
leadingItems,
|
|
33
|
+
search,
|
|
22
34
|
testID,
|
|
23
35
|
title,
|
|
24
36
|
titleAlignment = 'center',
|
|
@@ -29,22 +41,110 @@ export const NavigationBar: NavigationBarComponent = ({
|
|
|
29
41
|
const font = theme.typography.headline
|
|
30
42
|
const color = theme.color(inverted ? 'content.on.accent' : 'content.primary')
|
|
31
43
|
|
|
32
|
-
const
|
|
33
|
-
const
|
|
44
|
+
const fieldRef = useRef<SearchFieldRef>(null)
|
|
45
|
+
const echo = useRef<string | null>(null)
|
|
46
|
+
const [searchActive, setSearchActive] = useState(false)
|
|
47
|
+
|
|
48
|
+
const integrated = search?.placement === 'integratedButton'
|
|
49
|
+
const buttonPlacement = search?.buttonPlacement ?? 'trailing'
|
|
50
|
+
const onChangeText = search?.onChangeText
|
|
51
|
+
const onFocusChange = search?.onFocusChange
|
|
52
|
+
|
|
53
|
+
const searchItem = useMemo<TopAppBarItem | undefined>(
|
|
54
|
+
() =>
|
|
55
|
+
integrated
|
|
56
|
+
? {
|
|
57
|
+
iconName: search?.buttonIcon ? iconResourceName(search.buttonIcon) : GLYPHS.search.android,
|
|
58
|
+
label: search?.buttonLabel ?? DEFAULT_LABELS.search,
|
|
59
|
+
onPress: () => setSearchActive(true),
|
|
60
|
+
type: 'icon',
|
|
61
|
+
}
|
|
62
|
+
: undefined,
|
|
63
|
+
[integrated, search?.buttonIcon, search?.buttonLabel]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const nativeLeadingItems = useMemo(() => {
|
|
67
|
+
const items = (leadingItems ?? NO_ITEMS).map(toNativeItem)
|
|
68
|
+
return searchItem && buttonPlacement === 'leading' ? [...items, searchItem] : items
|
|
69
|
+
}, [buttonPlacement, leadingItems, searchItem])
|
|
70
|
+
|
|
71
|
+
const nativeTrailingItems = useMemo(() => {
|
|
72
|
+
const items = (trailingItems ?? NO_ITEMS).map(toNativeItem)
|
|
73
|
+
return searchItem && buttonPlacement === 'trailing' ? [searchItem, ...items] : items
|
|
74
|
+
}, [buttonPlacement, searchItem, trailingItems])
|
|
75
|
+
|
|
76
|
+
const handleChangeText = useCallback(
|
|
77
|
+
(text: string) => {
|
|
78
|
+
if (echo.current === text) {
|
|
79
|
+
echo.current = null
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onChangeText?.(text)
|
|
84
|
+
},
|
|
85
|
+
[onChangeText]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const handleFocusChange = useCallback(
|
|
89
|
+
(focused: boolean, hasText: boolean) => {
|
|
90
|
+
onFocusChange?.(focused)
|
|
91
|
+
|
|
92
|
+
if (!focused && !hasText) {
|
|
93
|
+
setSearchActive(false)
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
[onFocusChange]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
useImperativeHandle(
|
|
100
|
+
search?.ref,
|
|
101
|
+
() => ({
|
|
102
|
+
clear: () => {
|
|
103
|
+
fieldRef.current?.clearQuery().catch(ignoreDetachedView)
|
|
104
|
+
fieldRef.current?.blurField().catch(ignoreDetachedView)
|
|
105
|
+
setSearchActive(false)
|
|
106
|
+
},
|
|
107
|
+
setText: (text: string) => {
|
|
108
|
+
echo.current = text
|
|
109
|
+
fieldRef.current?.setQuery(text).catch(ignoreDetachedView)
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
[]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const field = search ? (
|
|
116
|
+
<BarSearchField
|
|
117
|
+
autoFocus={integrated}
|
|
118
|
+
cancelLabel={search.cancelLabel}
|
|
119
|
+
clearIcon={search.clearIcon}
|
|
120
|
+
onChangeText={handleChangeText}
|
|
121
|
+
onFocusChange={handleFocusChange}
|
|
122
|
+
placeholder={search.placeholder}
|
|
123
|
+
ref={fieldRef}
|
|
124
|
+
testID={search.testID}
|
|
125
|
+
voice={search.voice}
|
|
126
|
+
/>
|
|
127
|
+
) : null
|
|
34
128
|
|
|
35
129
|
return (
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
130
|
+
<>
|
|
131
|
+
<Island h={BAR_HEIGHT} matchContents={false} testID={testID} w="100%">
|
|
132
|
+
<TopAppBar
|
|
133
|
+
fontFamily={font.fontFamily}
|
|
134
|
+
fontSize={font.fontSize}
|
|
135
|
+
itemColor={color}
|
|
136
|
+
leadingItems={nativeLeadingItems}
|
|
137
|
+
searchActive={integrated && searchActive}
|
|
138
|
+
title={title}
|
|
139
|
+
titleAlignment={titleAlignment}
|
|
140
|
+
titleColor={color}
|
|
141
|
+
titleVisible={titleVisible}
|
|
142
|
+
trailingItems={nativeTrailingItems}
|
|
143
|
+
>
|
|
144
|
+
{integrated ? <TopAppBar.Search>{field}</TopAppBar.Search> : null}
|
|
145
|
+
</TopAppBar>
|
|
146
|
+
</Island>
|
|
147
|
+
{search && !integrated ? <BarSearchRow>{field}</BarSearchRow> : null}
|
|
148
|
+
</>
|
|
49
149
|
)
|
|
50
150
|
}
|
|
@@ -72,20 +72,22 @@ export type NavigationBarSearchRef = {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Where the search field goes
|
|
76
|
-
*
|
|
75
|
+
* Where the search field goes, as a native-stack header places its search: `stacked` in a row of its own under the
|
|
76
|
+
* title, `integratedButton` as a search item that turns the bar into the field.
|
|
77
77
|
*/
|
|
78
78
|
export type NavigationBarSearchPlacement = 'integratedButton' | 'stacked'
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* Where `integratedButton` puts its search button: beside the trailing items, or after the leading ones. On iOS 26 the
|
|
82
|
-
* button grows into the field and the cancel splits off its end, the way the system's own search does
|
|
82
|
+
* button grows into the field and the cancel splits off its end, the way the system's own search does; on Android the
|
|
83
|
+
* field grows over the bar from its trailing edge.
|
|
83
84
|
*/
|
|
84
85
|
export type NavigationBarSearchButtonPlacement = 'leading' | 'trailing'
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
|
-
* A search field in the bar.
|
|
88
|
-
*
|
|
88
|
+
* A search field in the bar. A focused field gets a cancel that empties it and ends the search: a bar item on iOS,
|
|
89
|
+
* Material's text button beside the field on Android. An `integratedButton` search closes again once the field is
|
|
90
|
+
* cancelled, cleared or left empty.
|
|
89
91
|
*/
|
|
90
92
|
export type NavigationBarSearch = {
|
|
91
93
|
/** `integratedButton`: the search item's icon, one of the app's icons; the system magnifying glass unset. */
|
|
@@ -130,7 +132,7 @@ export type NavigationBarProps = {
|
|
|
130
132
|
inverted?: boolean
|
|
131
133
|
/** In screen order, from the leading edge. */
|
|
132
134
|
leadingItems?: NavigationBarItem[]
|
|
133
|
-
/** A search field
|
|
135
|
+
/** A search field: under the title, or behind a search item that turns the bar into the field. */
|
|
134
136
|
search?: NavigationBarSearch
|
|
135
137
|
/** Test identifier, forwarded to the native bar. iOS only; Android ignores it. */
|
|
136
138
|
testID?: string
|
|
@@ -22,8 +22,9 @@ export const RnListItem: RnListItemComponent = ({
|
|
|
22
22
|
// One element for a screen reader, as `ListItem` is: a button when it can be pressed.
|
|
23
23
|
const a11y = accessibilityProps({accessible: true, ...accessibility}, onPress ? 'button' : undefined)
|
|
24
24
|
const pressedColor = theme.color('fill.tertiary')
|
|
25
|
+
const rippleColor = theme.color('fill.ripple')
|
|
25
26
|
|
|
26
|
-
const ripple = useMemo(() => ({color:
|
|
27
|
+
const ripple = useMemo(() => ({color: rippleColor}), [rippleColor])
|
|
27
28
|
const highlight = useMemo(
|
|
28
29
|
() =>
|
|
29
30
|
Platform.OS === 'ios'
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {Text as ComposeText,
|
|
1
|
+
import {Text as ComposeText, RNHostView, Row, TextButton} from '@expo/ui/jetpack-compose'
|
|
2
2
|
|
|
3
3
|
import {TooltipBox} from '@hzblj/kara-native/jetpack-compose'
|
|
4
4
|
import {useAppTheme} from '@hzblj/kara-theme'
|
|
5
5
|
import {Button} from '../../atoms/button'
|
|
6
|
+
import {ComposeHost} from '../../hooks/compose-host'
|
|
6
7
|
import {HostBoundary} from '../../hooks/host-context'
|
|
7
8
|
import type {RnTooltipAction, RnTooltipComponent, RnTooltipProps} from './rn-tooltip.types'
|
|
8
9
|
|
|
@@ -53,7 +54,7 @@ export const RnTooltip: RnTooltipComponent = ({
|
|
|
53
54
|
const theme = useAppTheme()
|
|
54
55
|
|
|
55
56
|
return (
|
|
56
|
-
<
|
|
57
|
+
<ComposeHost matchContents>
|
|
57
58
|
<HostBoundary>
|
|
58
59
|
<TooltipBox
|
|
59
60
|
actionsAlignment={actionsAlignment}
|
|
@@ -90,6 +91,6 @@ export const RnTooltip: RnTooltipComponent = ({
|
|
|
90
91
|
<RNHostView matchContents>{children}</RNHostView>
|
|
91
92
|
</TooltipBox>
|
|
92
93
|
</HostBoundary>
|
|
93
|
-
</
|
|
94
|
+
</ComposeHost>
|
|
94
95
|
)
|
|
95
96
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {Text as ComposeText, DropdownMenu, DropdownMenuItem
|
|
1
|
+
import {Text as ComposeText, DropdownMenu, DropdownMenuItem} from '@expo/ui/jetpack-compose'
|
|
2
2
|
import {haptics} from '@hzblj/expo-rich-haptics'
|
|
3
3
|
import {useSx} from '@hzblj/kara-sx'
|
|
4
4
|
import {useAppTheme} from '@hzblj/kara-theme'
|
|
5
5
|
import {useCallback, useMemo, useRef, useState} from 'react'
|
|
6
6
|
import {type GestureResponderEvent, Pressable, StyleSheet, View} from 'react-native'
|
|
7
7
|
import {Icon} from '../../atoms/icon'
|
|
8
|
+
import {ComposeHost} from '../../hooks/compose-host'
|
|
8
9
|
import {HostBoundary} from '../../hooks/host-context'
|
|
9
10
|
import {splitForRipple} from '../../utils/ripple-clip'
|
|
10
11
|
import type {MenuAction} from '../menu'
|
|
@@ -73,7 +74,7 @@ const ContextMenuRoot = ({actions, children, testID, ...props}: ContextMenuProps
|
|
|
73
74
|
|
|
74
75
|
// The shape clips on an outer view, so the ripple and the highlight follow the trigger's rounded corners.
|
|
75
76
|
const {inner, outer} = useMemo(() => splitForRipple(resolved), [resolved])
|
|
76
|
-
const ripple = useMemo(() => ({color: theme.color('fill.
|
|
77
|
+
const ripple = useMemo(() => ({color: theme.color('fill.ripple')}), [theme])
|
|
77
78
|
const highlight = useMemo(() => ({backgroundColor: theme.color('fill.tertiary')}), [theme])
|
|
78
79
|
|
|
79
80
|
const open = useCallback((event: GestureResponderEvent) => {
|
|
@@ -96,12 +97,7 @@ const ContextMenuRoot = ({actions, children, testID, ...props}: ContextMenuProps
|
|
|
96
97
|
{expanded ? <View pointerEvents="none" style={[StyleSheet.absoluteFill, highlight]} /> : null}
|
|
97
98
|
{children}
|
|
98
99
|
{anchor ? (
|
|
99
|
-
<
|
|
100
|
-
colorScheme={theme.dark ? 'dark' : 'light'}
|
|
101
|
-
pointerEvents="none"
|
|
102
|
-
seedColor={theme.materialSeed}
|
|
103
|
-
style={[styles.anchor, {left: anchor.x, top: anchor.y}]}
|
|
104
|
-
>
|
|
100
|
+
<ComposeHost pointerEvents="none" style={[styles.anchor, {left: anchor.x, top: anchor.y}]}>
|
|
105
101
|
<HostBoundary>
|
|
106
102
|
<DropdownMenu color={theme.color('surface.primary')} expanded={expanded} onDismissRequest={dismiss}>
|
|
107
103
|
<DropdownMenu.Items>
|
|
@@ -111,7 +107,7 @@ const ContextMenuRoot = ({actions, children, testID, ...props}: ContextMenuProps
|
|
|
111
107
|
</DropdownMenu.Items>
|
|
112
108
|
</DropdownMenu>
|
|
113
109
|
</HostBoundary>
|
|
114
|
-
</
|
|
110
|
+
</ComposeHost>
|
|
115
111
|
) : null}
|
|
116
112
|
</Pressable>
|
|
117
113
|
</View>
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import {Host} from '@expo/ui/jetpack-compose'
|
|
2
|
-
import {useAppTheme} from '@hzblj/kara-theme'
|
|
3
1
|
import type {ReactNode} from 'react'
|
|
4
2
|
import {StyleSheet, View} from 'react-native'
|
|
5
|
-
|
|
6
3
|
import {ActivityIndicator} from '../../atoms/activity-indicator'
|
|
7
4
|
import {Button} from '../../atoms/button'
|
|
8
5
|
import {GlyphButton} from '../../atoms/glyph'
|
|
9
6
|
import {IconButton} from '../../atoms/icon-button'
|
|
7
|
+
import {ComposeHost} from '../../hooks/compose-host'
|
|
10
8
|
import {HostBoundary} from '../../hooks/host-context'
|
|
11
9
|
import {Menu, type MenuAction} from '../menu'
|
|
12
10
|
import type {
|
|
@@ -30,12 +28,10 @@ const styles = StyleSheet.create({
|
|
|
30
28
|
|
|
31
29
|
/** A host sized to its button both ways: the toolbar gives custom content no width to fill. */
|
|
32
30
|
const ActionHost = ({children}: {children: ReactNode}) => {
|
|
33
|
-
const theme = useAppTheme()
|
|
34
|
-
|
|
35
31
|
return (
|
|
36
|
-
<
|
|
32
|
+
<ComposeHost matchContents>
|
|
37
33
|
<HostBoundary>{children}</HostBoundary>
|
|
38
|
-
</
|
|
34
|
+
</ComposeHost>
|
|
39
35
|
)
|
|
40
36
|
}
|
|
41
37
|
|
|
@@ -1,4 +1,38 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {iconResourceName} from '@hzblj/expo-native-icons'
|
|
2
|
+
import {NavigationItemSearch} from '@hzblj/kara-native/jetpack-compose'
|
|
3
|
+
import {useAppTheme} from '@hzblj/kara-theme'
|
|
4
|
+
import {useMemo} from 'react'
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
6
|
+
import {headerColor} from './header-color'
|
|
7
|
+
import type {HeaderSearchAccessoryComponent, HeaderSearchAccessoryProps} from './header-search-accessory.types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The toolbar search's glyphs and voice on Android (`integratedButton`): react-native-screens' `SearchView` belongs to
|
|
11
|
+
* the toolbar, so a zero-size native view in the screen's tree puts the microphone into its field and gives its X the
|
|
12
|
+
* bar's ripple and the app's clear glyph. The toolbar has no cancel glyph; back closes the search.
|
|
13
|
+
*/
|
|
14
|
+
export const HeaderSearchAccessory: HeaderSearchAccessoryComponent = ({
|
|
15
|
+
clearIcon,
|
|
16
|
+
voice,
|
|
17
|
+
}: HeaderSearchAccessoryProps) => {
|
|
18
|
+
const theme = useAppTheme()
|
|
19
|
+
const nativeVoice = useMemo(
|
|
20
|
+
() =>
|
|
21
|
+
voice && {
|
|
22
|
+
...voice,
|
|
23
|
+
microphoneIconName: voice.microphoneIcon ? iconResourceName(voice.microphoneIcon) : undefined,
|
|
24
|
+
stopIconName: voice.stopIcon ? iconResourceName(voice.stopIcon) : undefined,
|
|
25
|
+
},
|
|
26
|
+
[voice]
|
|
27
|
+
)
|
|
28
|
+
const fieldClearIcon = voice?.clearIcon ?? clearIcon
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<NavigationItemSearch
|
|
32
|
+
clearIconName={fieldClearIcon ? iconResourceName(fieldClearIcon) : undefined}
|
|
33
|
+
iconColor={voice?.color ?? headerColor(theme, 'content.primary')}
|
|
34
|
+
rippleColor={theme.color('fill.ripple')}
|
|
35
|
+
voice={nativeVoice}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -1,22 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {SearchField, type SearchFieldRef} from '@hzblj/kara-native/jetpack-compose'
|
|
4
|
-
import {useAppTheme} from '@hzblj/kara-theme'
|
|
5
|
-
import {useImperativeHandle, useMemo, useRef} from 'react'
|
|
6
|
-
import {StyleSheet, View} from 'react-native'
|
|
1
|
+
import type {SearchFieldRef} from '@hzblj/kara-native/jetpack-compose'
|
|
2
|
+
import {useImperativeHandle, useRef} from 'react'
|
|
7
3
|
|
|
4
|
+
import {BAR_SEARCH_ROW_HEIGHT, BarSearchField, BarSearchRow} from '../../molecules/navigation-bar/bar-search-field'
|
|
8
5
|
import type {HeaderSearchInputComponent, HeaderSearchInputProps} from './header-search-input.types'
|
|
9
6
|
|
|
10
|
-
/** Material's search field is 56dp; the row adds 8dp above it and 12dp below, under the top app bar. */
|
|
11
|
-
const FIELD_HEIGHT = 56
|
|
12
|
-
|
|
13
7
|
/** The row's height under the bar, for what is laid out below it (the scrim starts there). */
|
|
14
|
-
export const HEADER_SEARCH_ROW_HEIGHT =
|
|
15
|
-
|
|
16
|
-
const styles = StyleSheet.create({
|
|
17
|
-
field: {height: FIELD_HEIGHT},
|
|
18
|
-
row: {paddingBottom: 12, paddingHorizontal: 16, paddingTop: 8},
|
|
19
|
-
})
|
|
8
|
+
export const HEADER_SEARCH_ROW_HEIGHT = BAR_SEARCH_ROW_HEIGHT
|
|
20
9
|
|
|
21
10
|
const ignoreDetachedView = () => undefined
|
|
22
11
|
|
|
@@ -28,9 +17,7 @@ export const HeaderSearchInput: HeaderSearchInputComponent = ({
|
|
|
28
17
|
ref,
|
|
29
18
|
voice,
|
|
30
19
|
}: HeaderSearchInputProps) => {
|
|
31
|
-
const theme = useAppTheme()
|
|
32
20
|
const fieldRef = useRef<SearchFieldRef>(null)
|
|
33
|
-
const face = theme.typography.body
|
|
34
21
|
|
|
35
22
|
useImperativeHandle(
|
|
36
23
|
ref,
|
|
@@ -48,42 +35,16 @@ export const HeaderSearchInput: HeaderSearchInputComponent = ({
|
|
|
48
35
|
[]
|
|
49
36
|
)
|
|
50
37
|
|
|
51
|
-
const nativeVoice = useMemo(
|
|
52
|
-
() =>
|
|
53
|
-
voice && {
|
|
54
|
-
...voice,
|
|
55
|
-
microphoneIconName: voice.microphoneIcon ? iconResourceName(voice.microphoneIcon) : undefined,
|
|
56
|
-
stopIconName: voice.stopIcon ? iconResourceName(voice.stopIcon) : undefined,
|
|
57
|
-
},
|
|
58
|
-
[voice]
|
|
59
|
-
)
|
|
60
|
-
const fieldClearIcon = voice?.clearIcon ?? clearIcon
|
|
61
|
-
|
|
62
38
|
return (
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
clearLabel={voice?.clearLabel ?? 'Clear'}
|
|
74
|
-
containerColor={theme.color('fill.quaternary')}
|
|
75
|
-
fontFamily={face.fontFamily}
|
|
76
|
-
fontSize={face.fontSize}
|
|
77
|
-
iconColor={theme.color('content.secondary')}
|
|
78
|
-
onFocusChange={onFocusChange}
|
|
79
|
-
onQueryChange={onChangeText}
|
|
80
|
-
placeholder={placeholder}
|
|
81
|
-
placeholderColor={theme.color('content.tertiary')}
|
|
82
|
-
ref={fieldRef}
|
|
83
|
-
textColor={theme.color('content.primary')}
|
|
84
|
-
voice={nativeVoice}
|
|
85
|
-
/>
|
|
86
|
-
</Host>
|
|
87
|
-
</View>
|
|
39
|
+
<BarSearchRow>
|
|
40
|
+
<BarSearchField
|
|
41
|
+
clearIcon={clearIcon}
|
|
42
|
+
onChangeText={onChangeText}
|
|
43
|
+
onFocusChange={onFocusChange}
|
|
44
|
+
placeholder={placeholder}
|
|
45
|
+
ref={fieldRef}
|
|
46
|
+
voice={voice}
|
|
47
|
+
/>
|
|
48
|
+
</BarSearchRow>
|
|
88
49
|
)
|
|
89
50
|
}
|
|
@@ -278,23 +278,33 @@ export type HeaderOptionsResult = HeaderContent & {
|
|
|
278
278
|
export type HeaderSearchBarOptions = {
|
|
279
279
|
allowToolbarIntegration?: boolean
|
|
280
280
|
autoCapitalize?: 'characters' | 'none' | 'sentences' | 'words'
|
|
281
|
+
/** Android: the toolbar search's magnifier and X. */
|
|
282
|
+
headerIconColor?: string
|
|
281
283
|
hideNavigationBar?: boolean
|
|
282
284
|
hideWhenScrolling?: boolean
|
|
285
|
+
/** Android: the placeholder's colour. */
|
|
286
|
+
hintTextColor?: string
|
|
283
287
|
obscureBackground?: boolean
|
|
284
288
|
onBlur?: () => void
|
|
285
289
|
onCancelButtonPress?: () => void
|
|
286
290
|
onChangeText?: (event: {nativeEvent: {text: string}}) => void
|
|
291
|
+
/** Android: the toolbar search closed (back, or the X on an empty field). */
|
|
292
|
+
onClose?: () => void
|
|
287
293
|
onFocus?: () => void
|
|
294
|
+
/** Android: the toolbar search opened. */
|
|
295
|
+
onOpen?: () => void
|
|
288
296
|
placeholder?: string
|
|
289
297
|
placement?: 'automatic' | 'inline' | 'integrated' | 'integratedButton' | 'integratedCentered' | 'stacked'
|
|
290
298
|
ref?: RefObject<SearchBarCommands | null>
|
|
299
|
+
/** Android: the typed text. */
|
|
300
|
+
textColor?: string
|
|
291
301
|
tintColor?: string
|
|
292
302
|
}
|
|
293
303
|
|
|
294
304
|
/**
|
|
295
|
-
* Where the search goes
|
|
296
|
-
*
|
|
297
|
-
*
|
|
305
|
+
* Where the search goes: `stacked` in a row under the title (title and items kept); `integratedButton` as a search
|
|
306
|
+
* button in the bar's trailing corner that opens the field in the bar, the title and items hidden meanwhile. On iOS the
|
|
307
|
+
* integrated field's close is the system's, which takes no other glyph; on Android back closes it.
|
|
298
308
|
*/
|
|
299
309
|
export type HeaderSearchPlacement = 'integratedButton' | 'stacked'
|
|
300
310
|
|
|
@@ -307,16 +317,16 @@ export type HeaderSearchParams = {
|
|
|
307
317
|
enabled?: boolean
|
|
308
318
|
/** Shown while the field is empty. `Search` by default. */
|
|
309
319
|
placeholder?: string
|
|
310
|
-
/**
|
|
320
|
+
/** `stacked` by default. */
|
|
311
321
|
placement?: HeaderSearchPlacement
|
|
312
322
|
/** A microphone in the field: see `SearchFieldVoice`. Write what it hears with `setText`. */
|
|
313
323
|
voice?: SearchFieldVoice
|
|
314
324
|
}
|
|
315
325
|
|
|
316
326
|
/**
|
|
317
|
-
* A header's search: what to give `useHeader` (`searchBar
|
|
318
|
-
* the scrim that washes the content out while the field is focused and empty (render it last, over
|
|
319
|
-
* the query to filter by.
|
|
327
|
+
* A header's search: what to give `useHeader` (`searchBar`), the row Android puts under its bar for `stacked`
|
|
328
|
+
* (`searchInput`), the scrim that washes the content out while the field is focused and empty (render it last, over
|
|
329
|
+
* the content), and the query to filter by.
|
|
320
330
|
*/
|
|
321
331
|
export type HeaderSearch = {
|
|
322
332
|
/** Empties the field and ends the search. */
|
|
@@ -324,13 +334,13 @@ export type HeaderSearch = {
|
|
|
324
334
|
/** The field's text. */
|
|
325
335
|
query: string
|
|
326
336
|
/**
|
|
327
|
-
* Over the content while the field is focused and empty; a tap on it ends the search.
|
|
337
|
+
* Over the content while the field is focused and empty; a tap on it ends the search. It also carries the header
|
|
328
338
|
* field's glyphs and voice, which have to sit in the screen's tree. Render it last.
|
|
329
339
|
*/
|
|
330
340
|
scrim: ReactElement | null
|
|
331
|
-
/** Android
|
|
341
|
+
/** Android, `stacked`: the Material search field, a row to put right under the bar. `null` otherwise. */
|
|
332
342
|
searchInput: ReactElement | null
|
|
333
|
-
/**
|
|
343
|
+
/** `search` for `useHeader`: always on iOS, for `integratedButton` on Android. `undefined` otherwise. */
|
|
334
344
|
searchBar: HeaderSearchBarOptions | undefined
|
|
335
345
|
/** Puts text into the field and the query, the way dictation fills it in. */
|
|
336
346
|
setText: (text: string) => void
|