@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +32 -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 +94 -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 +119 -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
@@ -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 {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,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 nativeLeadingItems = useMemo(() => leadingItems?.map(toNativeItem), [leadingItems])
33
- const nativeTrailingItems = useMemo(() => trailingItems?.map(toNativeItem), [trailingItems])
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
- <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>
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 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