@hzblj/kara-components 0.26.2 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/lib/module/atoms/activity-indicator/activity-indicator.android.js +3 -4
  3. package/lib/module/atoms/activity-indicator/activity-indicator.android.js.map +1 -1
  4. package/lib/module/atoms/date-picker/date-picker.android.js +3 -4
  5. package/lib/module/atoms/date-picker/date-picker.android.js.map +1 -1
  6. package/lib/module/atoms/island/island.android.js +2 -6
  7. package/lib/module/atoms/island/island.android.js.map +1 -1
  8. package/lib/module/atoms/picker/picker.android.js +3 -4
  9. package/lib/module/atoms/picker/picker.android.js.map +1 -1
  10. package/lib/module/atoms/pressable/pressable.js +5 -4
  11. package/lib/module/atoms/pressable/pressable.js.map +1 -1
  12. package/lib/module/atoms/segmented-control/segmented-control.android.js +3 -4
  13. package/lib/module/atoms/segmented-control/segmented-control.android.js.map +1 -1
  14. package/lib/module/hooks/compose-host.js +27 -0
  15. package/lib/module/hooks/compose-host.js.map +1 -0
  16. package/lib/module/molecules/navigation-bar/bar-search-field.js +80 -0
  17. package/lib/module/molecules/navigation-bar/bar-search-field.js.map +1 -0
  18. package/lib/module/molecules/navigation-bar/navigation-bar.android.js +97 -22
  19. package/lib/module/molecules/navigation-bar/navigation-bar.android.js.map +1 -1
  20. package/lib/module/molecules/rn-list-item/rn-list-item.js +3 -2
  21. package/lib/module/molecules/rn-list-item/rn-list-item.js.map +1 -1
  22. package/lib/module/molecules/rn-tooltip/rn-tooltip.android.js +3 -4
  23. package/lib/module/molecules/rn-tooltip/rn-tooltip.android.js.map +1 -1
  24. package/lib/module/organisms/context-menu/context-menu.android.js +4 -5
  25. package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
  26. package/lib/module/organisms/header/header-options.android.js +2 -6
  27. package/lib/module/organisms/header/header-options.android.js.map +1 -1
  28. package/lib/module/organisms/header/header-search-accessory.android.js +29 -2
  29. package/lib/module/organisms/header/header-search-accessory.android.js.map +1 -1
  30. package/lib/module/organisms/header/header-search-input.android.js +11 -51
  31. package/lib/module/organisms/header/header-search-input.android.js.map +1 -1
  32. package/lib/module/organisms/header/use-header-search.js +19 -11
  33. package/lib/module/organisms/header/use-header-search.js.map +1 -1
  34. package/lib/module/organisms/sheet/sheet.js +17 -1
  35. package/lib/module/organisms/sheet/sheet.js.map +1 -1
  36. package/lib/module/organisms/sheet/use-sheet-header.js +7 -1
  37. package/lib/module/organisms/sheet/use-sheet-header.js.map +1 -1
  38. package/lib/module/templates/screen/screen.android.js +3 -6
  39. package/lib/module/templates/screen/screen.android.js.map +1 -1
  40. package/lib/typescript/hooks/compose-host.d.ts +8 -0
  41. package/lib/typescript/molecules/navigation-bar/bar-search-field.d.ts +29 -0
  42. package/lib/typescript/molecules/navigation-bar/navigation-bar.types.d.ts +8 -6
  43. package/lib/typescript/organisms/header/header.types.d.ts +20 -10
  44. package/lib/typescript/organisms/header/use-header-search.d.ts +5 -3
  45. package/lib/typescript/organisms/sheet/sheet.types.d.ts +1 -1
  46. package/package.json +4 -4
  47. package/src/atoms/activity-indicator/activity-indicator.android.tsx +3 -6
  48. package/src/atoms/date-picker/date-picker.android.tsx +3 -12
  49. package/src/atoms/island/island.android.tsx +3 -7
  50. package/src/atoms/picker/picker.android.tsx +3 -3
  51. package/src/atoms/pressable/pressable.tsx +5 -7
  52. package/src/atoms/segmented-control/segmented-control.android.tsx +3 -6
  53. package/src/hooks/compose-host.tsx +20 -0
  54. package/src/molecules/navigation-bar/bar-search-field.tsx +94 -0
  55. package/src/molecules/navigation-bar/navigation-bar.android.tsx +123 -19
  56. package/src/molecules/navigation-bar/navigation-bar.types.ts +8 -6
  57. package/src/molecules/rn-list-item/rn-list-item.tsx +2 -1
  58. package/src/molecules/rn-tooltip/rn-tooltip.android.tsx +4 -3
  59. package/src/organisms/context-menu/context-menu.android.tsx +5 -9
  60. package/src/organisms/header/header-options.android.tsx +3 -7
  61. package/src/organisms/header/header-search-accessory.android.tsx +37 -3
  62. package/src/organisms/header/header-search-input.android.tsx +14 -53
  63. package/src/organisms/header/header.types.ts +20 -10
  64. package/src/organisms/header/use-header-search.tsx +18 -10
  65. package/src/organisms/sheet/sheet.tsx +19 -2
  66. package/src/organisms/sheet/sheet.types.ts +1 -1
  67. package/src/organisms/sheet/use-sheet-header.tsx +7 -1
  68. package/src/templates/screen/screen.android.tsx +4 -7
@@ -1,24 +1,36 @@
1
1
  import {iconResourceName} from '@hzblj/expo-native-icons'
2
- import {TopAppBar, type TopAppBarItem} from '@hzblj/kara-native/jetpack-compose'
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
- /** Material's top app bar in its own host. `search` is iOS-only for now, and `separated` has no Material counterpart. */
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,114 @@ 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 nativeLeadingItems = useMemo(() => leadingItems?.map(toNativeItem), [leadingItems])
33
- const nativeTrailingItems = useMemo(() => trailingItems?.map(toNativeItem), [trailingItems])
44
+ const fieldRef = useRef<SearchFieldRef>(null)
45
+ const query = useRef('')
46
+ const echo = useRef<string | null>(null)
47
+ const [searchActive, setSearchActive] = useState(false)
48
+
49
+ const integrated = search?.placement === 'integratedButton'
50
+ const buttonPlacement = search?.buttonPlacement ?? 'trailing'
51
+ const onChangeText = search?.onChangeText
52
+ const onFocusChange = search?.onFocusChange
53
+
54
+ const searchItem = useMemo<TopAppBarItem | undefined>(
55
+ () =>
56
+ integrated
57
+ ? {
58
+ iconName: search?.buttonIcon ? iconResourceName(search.buttonIcon) : GLYPHS.search.android,
59
+ label: search?.buttonLabel ?? DEFAULT_LABELS.search,
60
+ onPress: () => setSearchActive(true),
61
+ type: 'icon',
62
+ }
63
+ : undefined,
64
+ [integrated, search?.buttonIcon, search?.buttonLabel]
65
+ )
66
+
67
+ const nativeLeadingItems = useMemo(() => {
68
+ const items = (leadingItems ?? NO_ITEMS).map(toNativeItem)
69
+ return searchItem && buttonPlacement === 'leading' ? [...items, searchItem] : items
70
+ }, [buttonPlacement, leadingItems, searchItem])
71
+
72
+ const nativeTrailingItems = useMemo(() => {
73
+ const items = (trailingItems ?? NO_ITEMS).map(toNativeItem)
74
+ return searchItem && buttonPlacement === 'trailing' ? [searchItem, ...items] : items
75
+ }, [buttonPlacement, searchItem, trailingItems])
76
+
77
+ const handleChangeText = useCallback(
78
+ (text: string) => {
79
+ query.current = text
80
+
81
+ if (echo.current === text) {
82
+ echo.current = null
83
+ return
84
+ }
85
+
86
+ onChangeText?.(text)
87
+ },
88
+ [onChangeText]
89
+ )
90
+
91
+ const handleFocusChange = useCallback(
92
+ (focused: boolean) => {
93
+ onFocusChange?.(focused)
94
+
95
+ if (!focused && query.current === '') {
96
+ setSearchActive(false)
97
+ }
98
+ },
99
+ [onFocusChange]
100
+ )
101
+
102
+ useImperativeHandle(
103
+ search?.ref,
104
+ () => ({
105
+ clear: () => {
106
+ fieldRef.current?.clearQuery().catch(ignoreDetachedView)
107
+ fieldRef.current?.blurField().catch(ignoreDetachedView)
108
+ setSearchActive(false)
109
+ },
110
+ setText: (text: string) => {
111
+ echo.current = text
112
+ query.current = text
113
+ fieldRef.current?.setQuery(text).catch(ignoreDetachedView)
114
+ },
115
+ }),
116
+ []
117
+ )
118
+
119
+ const field = search ? (
120
+ <BarSearchField
121
+ autoFocus={integrated}
122
+ cancelLabel={search.cancelLabel}
123
+ clearIcon={search.clearIcon}
124
+ onChangeText={handleChangeText}
125
+ onFocusChange={handleFocusChange}
126
+ placeholder={search.placeholder}
127
+ ref={fieldRef}
128
+ testID={search.testID}
129
+ voice={search.voice}
130
+ />
131
+ ) : null
34
132
 
35
133
  return (
36
- <Island h={BAR_HEIGHT} matchContents={false} testID={testID} w="100%">
37
- <TopAppBar
38
- fontFamily={font.fontFamily}
39
- fontSize={font.fontSize}
40
- itemColor={color}
41
- leadingItems={nativeLeadingItems}
42
- title={title}
43
- titleAlignment={titleAlignment}
44
- titleColor={color}
45
- titleVisible={titleVisible}
46
- trailingItems={nativeTrailingItems}
47
- />
48
- </Island>
134
+ <>
135
+ <Island h={BAR_HEIGHT} matchContents={false} testID={testID} w="100%">
136
+ <TopAppBar
137
+ fontFamily={font.fontFamily}
138
+ fontSize={font.fontSize}
139
+ itemColor={color}
140
+ leadingItems={nativeLeadingItems}
141
+ searchActive={integrated && searchActive}
142
+ title={title}
143
+ titleAlignment={titleAlignment}
144
+ titleColor={color}
145
+ titleVisible={titleVisible}
146
+ trailingItems={nativeTrailingItems}
147
+ >
148
+ {integrated ? <TopAppBar.Search>{field}</TopAppBar.Search> : null}
149
+ </TopAppBar>
150
+ </Island>
151
+ {search && !integrated ? <BarSearchRow>{field}</BarSearchRow> : null}
152
+ </>
49
153
  )
50
154
  }
@@ -72,20 +72,22 @@ export type NavigationBarSearchRef = {
72
72
  }
73
73
 
74
74
  /**
75
- * Where the search field goes on iOS, as a native-stack header places its search: `stacked` in a row of its own under
76
- * the title, `integratedButton` as a search item beside the trailing items that turns the bar into the field.
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. iOS only for now; Android keeps the title. A focused field gets a cancel item that
88
- * empties it and ends the search, a bar item like the others.
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 (iOS): under the title, or behind a search item beside the trailing items. */
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: pressedColor}), [pressedColor])
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, Host, RNHostView, Row, TextButton} from '@expo/ui/jetpack-compose'
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
- <Host colorScheme={theme.dark ? 'dark' : 'light'} matchContents seedColor={theme.materialSeed}>
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
- </Host>
94
+ </ComposeHost>
94
95
  )
95
96
  }
@@ -1,10 +1,11 @@
1
- import {Text as ComposeText, DropdownMenu, DropdownMenuItem, Host} from '@expo/ui/jetpack-compose'
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.tertiary')}), [theme])
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
- <Host
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
- </Host>
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
- <Host colorScheme={theme.dark ? 'dark' : 'light'} matchContents seedColor={theme.materialSeed}>
32
+ <ComposeHost matchContents>
37
33
  <HostBoundary>{children}</HostBoundary>
38
- </Host>
34
+ </ComposeHost>
39
35
  )
40
36
  }
41
37
 
@@ -1,4 +1,38 @@
1
- import type {HeaderSearchAccessoryComponent} from './header-search-accessory.types'
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
- /** Android's field is a Compose view of kara's own, which takes its glyphs and voice directly (`searchInput`). */
4
- export const HeaderSearchAccessory: HeaderSearchAccessoryComponent = () => null
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 {Host} from '@expo/ui/jetpack-compose'
2
- import {iconResourceName} from '@hzblj/expo-native-icons'
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 = 8 + FIELD_HEIGHT + 12
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
- <View style={styles.row}>
64
- <Host
65
- colorScheme={theme.dark ? 'dark' : 'light'}
66
- ignoreSafeAreaKeyboardInsets
67
- seedColor={theme.materialSeed}
68
- style={styles.field}
69
- >
70
- <SearchField
71
- cancelLabel="Cancel"
72
- clearIconName={fieldClearIcon ? iconResourceName(fieldClearIcon) : undefined}
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 on iOS: `stacked` in a row under the title (title and items kept); `integratedButton` as a
296
- * search button in the bar's trailing corner that opens the field in the bar, the title and items hidden meanwhile.
297
- * The integrated field's close is the system's: iOS gives it no other glyph.
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
- /** iOS: `stacked` by default. Android always puts its field in a row under the bar. */
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`, iOS), the row Android puts under its bar (`searchInput`),
318
- * the scrim that washes the content out while the field is focused and empty (render it last, over the content), and
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. On iOS it also carries the
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: the Material search field, a row to put right under the bar. `null` on iOS. */
341
+ /** Android, `stacked`: the Material search field, a row to put right under the bar. `null` otherwise. */
332
342
  searchInput: ReactElement | null
333
- /** iOS: `search` for `useHeader`. `undefined` on Android. */
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
@@ -19,9 +19,11 @@ const PLACEMENT_OPTIONS: Record<HeaderSearchPlacement, Partial<HeaderSearchBarOp
19
19
  /**
20
20
  * A search field for a screen's header. iOS: UIKit's search bar stacked under the title, with the title and items kept
21
21
  * and nothing dimmed by the system, so a list can filter live, or behind a search button in the bar's corner
22
- * (`placement`); pass `searchBar` to `useHeader` as `search`. Android: Material's search field with its cancel button,
23
- * as a row for the screen to put right under the top app bar (`searchInput`). Both report the `query`; `scrim` washes
24
- * the content out while the field is focused and empty and, on iOS, carries the field's glyphs and voice.
22
+ * (`placement`); pass `searchBar` to `useHeader` as `search`. Android: `stacked` is Material's search field with its
23
+ * cancel button, as a row for the screen to put right under the top app bar (`searchInput`); `integratedButton` is the
24
+ * toolbar's own search, a magnifier in the bar's corner that expands over the title (`searchBar`, as on iOS). Both
25
+ * report the `query`; `scrim` washes the content out while the field is focused and empty and carries the header
26
+ * field's glyphs and voice.
25
27
  *
26
28
  * With `voice` the field has a microphone; the app runs the dictation and writes what it hears with `setText`. Typing
27
29
  * over it, or ending the search, calls `voice.onCancel`.
@@ -41,6 +43,7 @@ export const useHeaderSearch = ({
41
43
  const inputRef = useRef<HeaderSearchInputRef>(null)
42
44
  const lastSetText = useRef<string | null>(null)
43
45
  const voiceRef = useRef(voice)
46
+ const headerField = Platform.OS === 'ios' || placement === 'integratedButton'
44
47
 
45
48
  voiceRef.current = voice
46
49
 
@@ -68,28 +71,33 @@ export const useHeaderSearch = ({
68
71
  }, [cancelVoice])
69
72
 
70
73
  const searchBar = useMemo((): HeaderSearchBarOptions | undefined => {
71
- if (!enabled || Platform.OS !== 'ios') {
74
+ if (!enabled || !headerField) {
72
75
  return undefined
73
76
  }
74
77
 
75
78
  return {
76
79
  ...PLACEMENT_OPTIONS[placement],
77
80
  autoCapitalize: 'none',
81
+ headerIconColor: headerColor(theme, 'content.primary'),
78
82
  hideWhenScrolling: false,
83
+ hintTextColor: headerColor(theme, 'content.tertiary'),
79
84
  obscureBackground: false,
80
85
  onBlur: () => setFocused(false),
81
86
  onCancelButtonPress: reset,
82
87
  onChangeText: event => changeQuery(event.nativeEvent.text),
88
+ onClose: reset,
83
89
  onFocus: () => setFocused(true),
90
+ onOpen: () => setFocused(true),
84
91
  placeholder,
85
92
  placement,
86
93
  ref: searchBarRef,
94
+ textColor: Platform.OS === 'android' ? headerColor(theme, 'content.primary') : undefined,
87
95
  tintColor: headerColor(theme, 'content.primary'),
88
96
  }
89
- }, [changeQuery, enabled, placeholder, placement, reset, theme])
97
+ }, [changeQuery, enabled, headerField, placeholder, placement, reset, theme])
90
98
 
91
99
  const searchInput = useMemo(() => {
92
- if (!enabled || Platform.OS !== 'android') {
100
+ if (!enabled || headerField) {
93
101
  return null
94
102
  }
95
103
 
@@ -103,7 +111,7 @@ export const useHeaderSearch = ({
103
111
  voice={voice}
104
112
  />
105
113
  )
106
- }, [changeQuery, clearIcon, enabled, placeholder, voice])
114
+ }, [changeQuery, clearIcon, enabled, headerField, placeholder, voice])
107
115
 
108
116
  const clear = useCallback(() => {
109
117
  reset()
@@ -128,11 +136,11 @@ export const useHeaderSearch = ({
128
136
 
129
137
  return (
130
138
  <>
131
- {dimmed ? <HeaderSearchScrim onPress={clear} topOffset={HEADER_SEARCH_ROW_HEIGHT} /> : null}
132
- <HeaderSearchAccessory cancelIcon={cancelIcon} clearIcon={clearIcon} voice={voice} />
139
+ {dimmed ? <HeaderSearchScrim onPress={clear} topOffset={headerField ? 0 : HEADER_SEARCH_ROW_HEIGHT} /> : null}
140
+ {headerField ? <HeaderSearchAccessory cancelIcon={cancelIcon} clearIcon={clearIcon} voice={voice} /> : null}
133
141
  </>
134
142
  )
135
- }, [cancelIcon, clear, clearIcon, dimmed, enabled, voice])
143
+ }, [cancelIcon, clear, clearIcon, dimmed, enabled, headerField, voice])
136
144
 
137
145
  return {clear, query, scrim, searchBar, searchInput, setText}
138
146
  }
@@ -1,5 +1,5 @@
1
- import {useAppTheme} from '@hzblj/kara-theme'
2
- import {type DetentChangeEvent, TrueSheet} from '@lodev09/react-native-true-sheet'
1
+ import {useAppTheme, withOpacity} from '@hzblj/kara-theme'
2
+ import {type DetentChangeEvent, type GrabberOptions, TrueSheet} from '@lodev09/react-native-true-sheet'
3
3
  import {useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef} from 'react'
4
4
  import {Platform, StyleSheet, View} from 'react-native'
5
5
  import type {SheetComponent, SheetDetent, SheetProps} from './sheet.types'
@@ -39,6 +39,22 @@ const SheetRoot = ({
39
39
  const theme = useAppTheme()
40
40
  const {content, header: headerProps} = useMemo(() => splitSheetChildren(children), [children])
41
41
  const sheetHeader = useSheetHeader({header: headerProps, scrollable})
42
+ const inverted = headerProps?.inverted ?? false
43
+ /**
44
+ * Material's drag handle, `onSurfaceVariant` at 40 %, in the app's theme. TrueSheet's own Android grabber picks black
45
+ * or white by the system's night mode, so an app themed apart from the device drew it in the sheet's own colour.
46
+ */
47
+ const grabberOptions = useMemo<GrabberOptions | undefined>(() => {
48
+ if (Platform.OS !== 'android') {
49
+ return undefined
50
+ }
51
+
52
+ const color = inverted
53
+ ? theme.color('content.on.accent:o40')
54
+ : withOpacity(String(theme.color('content.secondary')), 'o40')
55
+
56
+ return {adaptive: false, color}
57
+ }, [inverted, theme])
42
58
  const sheetRef = useRef<TrueSheet>(null)
43
59
  const presented = useRef(false)
44
60
  /** A dismissal we asked for: `onDismiss` reports the user's, never ours. */
@@ -114,6 +130,7 @@ const SheetRoot = ({
114
130
  draggable={draggable}
115
131
  footer={footer}
116
132
  grabber={grabber}
133
+ grabberOptions={grabberOptions}
117
134
  header={sheetHeader.header}
118
135
  headerStyle={sheetHeader.headerStyle}
119
136
  onDetentChange={handleDetentChange}