@hzblj/kara-components 0.2.1 → 0.4.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.
- package/CHANGELOG.md +34 -0
- package/README.md +2 -3
- package/lib/module/atoms/h-stack/h-stack.android.js +2 -5
- package/lib/module/atoms/h-stack/h-stack.android.js.map +1 -1
- package/lib/module/atoms/v-stack/v-stack.android.js +2 -5
- package/lib/module/atoms/v-stack/v-stack.android.js.map +1 -1
- package/lib/module/atoms/z-stack/z-stack.android.js +2 -5
- package/lib/module/atoms/z-stack/z-stack.android.js.map +1 -1
- package/lib/module/molecules/index.js +1 -0
- package/lib/module/molecules/index.js.map +1 -1
- package/lib/module/molecules/labeled-content/labeled-content.js +2 -0
- package/lib/module/molecules/labeled-content/labeled-content.js.map +1 -1
- package/lib/module/molecules/list-item/list-item.js +12 -1
- package/lib/module/molecules/list-item/list-item.js.map +1 -1
- package/lib/module/molecules/navigation-bar/navigation-bar.android.js +65 -35
- package/lib/module/molecules/navigation-bar/navigation-bar.android.js.map +1 -1
- package/lib/module/molecules/navigation-bar/navigation-bar.ios.js +48 -13
- package/lib/module/molecules/navigation-bar/navigation-bar.ios.js.map +1 -1
- package/lib/module/molecules/rn-list-item/index.js +5 -0
- package/lib/module/molecules/rn-list-item/index.js.map +1 -0
- package/lib/module/molecules/rn-list-item/rn-list-item.js +71 -0
- package/lib/module/molecules/rn-list-item/rn-list-item.js.map +1 -0
- package/lib/module/molecules/rn-list-item/rn-list-item.types.js +4 -0
- package/lib/module/molecules/rn-list-item/rn-list-item.types.js.map +1 -0
- package/lib/module/organisms/context-menu/context-menu.android.js +83 -38
- package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
- package/lib/module/organisms/sheet/index.js +2 -1
- package/lib/module/organisms/sheet/index.js.map +1 -1
- package/lib/module/organisms/sheet/sheet-header.js +7 -0
- package/lib/module/organisms/sheet/sheet-header.js.map +1 -0
- package/lib/module/organisms/sheet/sheet-scroll.js +62 -0
- package/lib/module/organisms/sheet/sheet-scroll.js.map +1 -0
- package/lib/module/organisms/sheet/sheet.js +136 -0
- package/lib/module/organisms/sheet/sheet.js.map +1 -0
- package/lib/module/organisms/sheet/split-sheet-children.js +21 -0
- package/lib/module/organisms/sheet/split-sheet-children.js.map +1 -0
- package/lib/module/organisms/sheet/use-sheet-header.js +118 -0
- package/lib/module/organisms/sheet/use-sheet-header.js.map +1 -0
- package/lib/module/ripple-clip.js +36 -0
- package/lib/module/ripple-clip.js.map +1 -0
- package/lib/module/use-stack-modifiers.js +35 -0
- package/lib/module/use-stack-modifiers.js.map +1 -0
- package/lib/typescript/molecules/index.d.ts +1 -0
- package/lib/typescript/molecules/labeled-content/labeled-content.types.d.ts +3 -0
- package/lib/typescript/molecules/list-item/list-item.types.d.ts +6 -0
- package/lib/typescript/molecules/navigation-bar/navigation-bar.types.d.ts +56 -17
- package/lib/typescript/molecules/rn-list-item/index.d.ts +2 -0
- package/lib/typescript/molecules/rn-list-item/rn-list-item.d.ts +2 -0
- package/lib/typescript/molecules/rn-list-item/rn-list-item.types.d.ts +10 -0
- package/lib/typescript/organisms/context-menu/context-menu.types.d.ts +2 -1
- package/lib/typescript/organisms/sheet/index.d.ts +1 -0
- package/lib/typescript/organisms/sheet/sheet-header.d.ts +3 -0
- package/lib/typescript/organisms/sheet/sheet-scroll.d.ts +9 -0
- package/lib/typescript/organisms/sheet/sheet.d.ts +7 -1
- package/lib/typescript/organisms/sheet/sheet.types.d.ts +86 -3
- package/lib/typescript/organisms/sheet/split-sheet-children.d.ts +9 -0
- package/lib/typescript/organisms/sheet/use-sheet-header.d.ts +24 -0
- package/lib/typescript/ripple-clip.d.ts +12 -0
- package/lib/typescript/use-stack-modifiers.d.ts +8 -0
- package/package.json +6 -4
- package/src/atoms/h-stack/h-stack.android.tsx +2 -8
- package/src/atoms/v-stack/v-stack.android.tsx +2 -8
- package/src/atoms/z-stack/z-stack.android.tsx +2 -8
- package/src/molecules/index.ts +1 -0
- package/src/molecules/labeled-content/labeled-content.tsx +2 -1
- package/src/molecules/labeled-content/labeled-content.types.ts +3 -0
- package/src/molecules/list-item/list-item.tsx +12 -4
- package/src/molecules/list-item/list-item.types.ts +6 -0
- package/src/molecules/navigation-bar/navigation-bar.android.tsx +50 -23
- package/src/molecules/navigation-bar/navigation-bar.ios.tsx +54 -15
- package/src/molecules/navigation-bar/navigation-bar.types.ts +56 -17
- package/src/molecules/rn-list-item/index.ts +2 -0
- package/src/molecules/rn-list-item/rn-list-item.tsx +48 -0
- package/src/molecules/rn-list-item/rn-list-item.types.ts +13 -0
- package/src/organisms/context-menu/context-menu.android.tsx +63 -38
- package/src/organisms/context-menu/context-menu.types.ts +2 -1
- package/src/organisms/sheet/index.ts +1 -0
- package/src/organisms/sheet/sheet-header.tsx +6 -0
- package/src/organisms/sheet/sheet-scroll.tsx +69 -0
- package/src/organisms/sheet/sheet.tsx +140 -0
- package/src/organisms/sheet/sheet.types.ts +93 -3
- package/src/organisms/sheet/split-sheet-children.ts +23 -0
- package/src/organisms/sheet/use-sheet-header.tsx +135 -0
- package/src/ripple-clip.ts +47 -0
- package/src/use-stack-modifiers.ts +24 -0
- package/lib/module/organisms/sheet/sheet.android.js +0 -26
- package/lib/module/organisms/sheet/sheet.android.js.map +0 -1
- package/lib/module/organisms/sheet/sheet.ios.js +0 -25
- package/lib/module/organisms/sheet/sheet.ios.js.map +0 -1
- package/lib/typescript/organisms/sheet/sheet.ios.d.ts +0 -2
- package/src/organisms/sheet/sheet.android.tsx +0 -25
- package/src/organisms/sheet/sheet.ios.tsx +0 -23
|
@@ -1,28 +1,55 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {iconResourceName} from '@hzblj/kara-native'
|
|
2
|
+
import {TopAppBar, type TopAppBarItem} from '@hzblj/kara-native/jetpack-compose'
|
|
3
|
+
import {useAppTheme} from '@hzblj/kara-theme'
|
|
4
|
+
import {useMemo} from 'react'
|
|
5
|
+
import {StyleSheet} from 'react-native'
|
|
2
6
|
import {Island} from '../../atoms/island'
|
|
3
|
-
import {
|
|
4
|
-
import {Text} from '../../atoms/text'
|
|
5
|
-
import {GlyphButton} from '../../glyph'
|
|
6
|
-
import type {NavigationBarComponent, NavigationBarProps} from './navigation-bar.types'
|
|
7
|
+
import type {NavigationBarComponent, NavigationBarItem, NavigationBarProps} from './navigation-bar.types'
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/** Material's small top app bar. */
|
|
10
|
+
const BAR_HEIGHT = 64
|
|
11
|
+
|
|
12
|
+
const styles = StyleSheet.create({
|
|
13
|
+
bar: {height: BAR_HEIGHT, width: '100%'},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const DEFAULT_LABELS = {back: 'Back', close: 'Close'} as const
|
|
17
|
+
|
|
18
|
+
const toNativeItem = ({onPress, testID, ...item}: NavigationBarItem): TopAppBarItem =>
|
|
19
|
+
item.type === 'icon'
|
|
20
|
+
? {iconName: iconResourceName(item.icon), label: item.label, onPress, testID, type: 'icon'}
|
|
21
|
+
: {label: item.label ?? DEFAULT_LABELS[item.type], onPress, testID, type: item.type}
|
|
22
|
+
|
|
23
|
+
/** Material's top app bar in its own host. `search` is iOS-only for now, and `separated` has no Material counterpart. */
|
|
12
24
|
export const NavigationBar: NavigationBarComponent = ({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
onBack,
|
|
16
|
-
onClose,
|
|
25
|
+
inverted = false,
|
|
26
|
+
leadingItems,
|
|
17
27
|
testID,
|
|
18
28
|
title,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
29
|
+
titleAlignment = 'center',
|
|
30
|
+
titleVisible = true,
|
|
31
|
+
trailingItems,
|
|
32
|
+
}: NavigationBarProps) => {
|
|
33
|
+
const theme = useAppTheme()
|
|
34
|
+
const font = theme.typography.headline
|
|
35
|
+
const color = theme.color(inverted ? 'content.on.bg' : 'content.primary')
|
|
36
|
+
|
|
37
|
+
const nativeLeadingItems = useMemo(() => leadingItems?.map(toNativeItem), [leadingItems])
|
|
38
|
+
const nativeTrailingItems = useMemo(() => trailingItems?.map(toNativeItem), [trailingItems])
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Island matchContents={false} style={styles.bar} testID={testID}>
|
|
42
|
+
<TopAppBar
|
|
43
|
+
fontFamily={font.fontFamily}
|
|
44
|
+
fontSize={font.fontSize}
|
|
45
|
+
itemColor={color}
|
|
46
|
+
leadingItems={nativeLeadingItems}
|
|
47
|
+
title={title}
|
|
48
|
+
titleAlignment={titleAlignment}
|
|
49
|
+
titleColor={color}
|
|
50
|
+
titleVisible={titleVisible}
|
|
51
|
+
trailingItems={nativeTrailingItems}
|
|
52
|
+
/>
|
|
53
|
+
</Island>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -1,33 +1,72 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {iconResourceName} from '@hzblj/kara-native'
|
|
2
|
+
import {
|
|
3
|
+
NavigationBar as NativeNavigationBar,
|
|
4
|
+
type NavigationBarItem as NativeNavigationBarItem,
|
|
5
|
+
type NavigationBarRef,
|
|
6
|
+
} from '@hzblj/kara-native/swift-ui'
|
|
2
7
|
import {useAppTheme} from '@hzblj/kara-theme'
|
|
8
|
+
import {useImperativeHandle, useMemo, useRef} from 'react'
|
|
3
9
|
import {StyleSheet} from 'react-native'
|
|
4
|
-
import type {NavigationBarComponent, NavigationBarProps} from './navigation-bar.types'
|
|
10
|
+
import type {NavigationBarComponent, NavigationBarItem, NavigationBarProps} from './navigation-bar.types'
|
|
5
11
|
|
|
6
|
-
/**
|
|
7
|
-
const BAR_HEIGHT =
|
|
12
|
+
/** Fits iOS 26's 44 pt glass items with the bar's own margin; the bar centres itself vertically in it. */
|
|
13
|
+
const BAR_HEIGHT = 56
|
|
8
14
|
|
|
9
15
|
const styles = StyleSheet.create({
|
|
10
16
|
bar: {height: BAR_HEIGHT, width: '100%'},
|
|
11
17
|
})
|
|
12
18
|
|
|
19
|
+
const DEFAULT_LABELS = {back: 'Back', close: 'Close'} as const
|
|
20
|
+
|
|
21
|
+
const toNativeItem = (item: NavigationBarItem): NativeNavigationBarItem =>
|
|
22
|
+
item.type === 'icon'
|
|
23
|
+
? {...item, imageName: iconResourceName(item.icon), type: 'icon'}
|
|
24
|
+
: {...item, label: item.label ?? DEFAULT_LABELS[item.type]}
|
|
25
|
+
|
|
26
|
+
const ignoreDetachedView = () => undefined
|
|
27
|
+
|
|
13
28
|
export const NavigationBar: NavigationBarComponent = ({
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
29
|
+
inverted = false,
|
|
30
|
+
leadingItems,
|
|
31
|
+
search,
|
|
32
|
+
testID,
|
|
33
|
+
title,
|
|
34
|
+
titleAlignment = 'center',
|
|
35
|
+
titleVisible = true,
|
|
36
|
+
trailingItems,
|
|
17
37
|
}: NavigationBarProps) => {
|
|
18
38
|
const theme = useAppTheme()
|
|
19
|
-
const
|
|
39
|
+
const barRef = useRef<NavigationBarRef>(null)
|
|
40
|
+
const font = theme.typography.headline
|
|
41
|
+
const color = theme.color(inverted ? 'content.on.bg' : 'content.primary')
|
|
42
|
+
|
|
43
|
+
useImperativeHandle(
|
|
44
|
+
search?.ref,
|
|
45
|
+
() => ({clear: () => barRef.current?.clearSearchText().catch(ignoreDetachedView)}),
|
|
46
|
+
[]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const nativeLeadingItems = useMemo(() => leadingItems?.map(toNativeItem), [leadingItems])
|
|
50
|
+
const nativeTrailingItems = useMemo(() => trailingItems?.map(toNativeItem), [trailingItems])
|
|
20
51
|
|
|
21
52
|
return (
|
|
22
53
|
<NativeNavigationBar
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
fontFamily={font.fontFamily}
|
|
55
|
+
fontSize={font.fontSize}
|
|
56
|
+
itemColor={color}
|
|
57
|
+
leadingItems={nativeLeadingItems}
|
|
58
|
+
onSearchFocusChange={search?.onFocusChange}
|
|
59
|
+
onSearchTextChange={search?.onChangeText}
|
|
60
|
+
ref={barRef}
|
|
61
|
+
searchPlaceholder={search?.placeholder}
|
|
62
|
+
searchTestID={search?.testID}
|
|
29
63
|
style={styles.bar}
|
|
30
|
-
|
|
64
|
+
testID={testID}
|
|
65
|
+
title={title}
|
|
66
|
+
titleAlignment={titleAlignment}
|
|
67
|
+
titleColor={color}
|
|
68
|
+
titleVisible={titleVisible}
|
|
69
|
+
trailingItems={nativeTrailingItems}
|
|
31
70
|
/>
|
|
32
71
|
)
|
|
33
72
|
}
|
|
@@ -1,26 +1,65 @@
|
|
|
1
|
-
import type {ReactNode} from 'react'
|
|
1
|
+
import type {ReactNode, Ref} from 'react'
|
|
2
|
+
|
|
3
|
+
import type {IconName} from '../../atoms/icon'
|
|
4
|
+
|
|
5
|
+
type NavigationBarItemBase = {
|
|
6
|
+
onPress: () => void
|
|
7
|
+
/**
|
|
8
|
+
* iOS 26: neighbouring items on one side share a glass capsule; this gives the item its own. A close next to
|
|
9
|
+
* actions usually wants it. Material has no grouped capsules, so Android ignores it.
|
|
10
|
+
*/
|
|
11
|
+
separated?: boolean
|
|
12
|
+
/** iOS: the item's accessibility identifier; Android: its test tag. */
|
|
13
|
+
testID?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One item of the bar. `back` and `close` are the platform's own — a chevron and the system close item on iOS, the
|
|
18
|
+
* Material arrow and cross on Android — with `Back` / `Close` as their labels unless given others. `icon` is one of the
|
|
19
|
+
* app's icons and names itself for VoiceOver and TalkBack.
|
|
20
|
+
*/
|
|
21
|
+
export type NavigationBarItem =
|
|
22
|
+
| (NavigationBarItemBase & {label?: string; type: 'back' | 'close'})
|
|
23
|
+
| (NavigationBarItemBase & {icon: IconName; label: string; type: 'icon'})
|
|
24
|
+
|
|
25
|
+
export type NavigationBarSearchRef = {
|
|
26
|
+
/** Empties the field and ends editing. */
|
|
27
|
+
clear: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A search field in place of the title — iOS only for now; Android keeps the title. */
|
|
31
|
+
export type NavigationBarSearch = {
|
|
32
|
+
onChangeText: (text: string) => void
|
|
33
|
+
onFocusChange?: (focused: boolean) => void
|
|
34
|
+
placeholder: string
|
|
35
|
+
ref?: Ref<NavigationBarSearchRef>
|
|
36
|
+
testID?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type NavigationBarTitleAlignment = 'center' | 'leading'
|
|
2
40
|
|
|
3
41
|
/**
|
|
4
|
-
* A title with
|
|
5
|
-
* `UINavigationBar`, so the items press and render exactly like a native-stack header's
|
|
6
|
-
*
|
|
7
|
-
*
|
|
42
|
+
* A title with items on either side, for a header that is not a navigation screen's — `Sheet.Header` is built on it.
|
|
43
|
+
* On iOS a standalone `UINavigationBar`, so the items press and render exactly like a native-stack header's: on iOS 26
|
|
44
|
+
* neighbours share a glass capsule instead of swelling as standalone glass buttons do. On Android Material's top app
|
|
45
|
+
* bar. Where an item goes is the caller's choice — the bar places nothing by itself, so a close can sit on either side.
|
|
46
|
+
* The React Native tier: it needs no host.
|
|
8
47
|
*/
|
|
9
48
|
export type NavigationBarProps = {
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
/** iOS: the close item's accessibility identifier. */
|
|
17
|
-
closeTestID?: string
|
|
18
|
-
/** Shows the back item. */
|
|
19
|
-
onBack?: () => void
|
|
20
|
-
/** Shows the close item. */
|
|
21
|
-
onClose?: () => void
|
|
49
|
+
/** Light items and title, for a bar laid over an image or a dark hero. */
|
|
50
|
+
inverted?: boolean
|
|
51
|
+
/** In screen order, from the leading edge. */
|
|
52
|
+
leadingItems?: NavigationBarItem[]
|
|
53
|
+
/** Replaces the title with a search field (iOS). */
|
|
54
|
+
search?: NavigationBarSearch
|
|
22
55
|
testID?: string
|
|
23
56
|
title?: string
|
|
57
|
+
/** `center` by default — iOS's own; `leading` is Material's top app bar. */
|
|
58
|
+
titleAlignment?: NavigationBarTitleAlignment
|
|
59
|
+
/** Fades the title out and in without moving the items — for a large title that scrolls under the bar. */
|
|
60
|
+
titleVisible?: boolean
|
|
61
|
+
/** In screen order, toward the trailing edge. */
|
|
62
|
+
trailingItems?: NavigationBarItem[]
|
|
24
63
|
}
|
|
25
64
|
|
|
26
65
|
export type NavigationBarComponent = (props: NavigationBarProps) => ReactNode
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {useAppTheme} from '@hzblj/kara-theme'
|
|
2
|
+
import {useMemo} from 'react'
|
|
3
|
+
import {Platform, Pressable, type PressableStateCallbackType} from 'react-native'
|
|
4
|
+
|
|
5
|
+
import {Box} from '../../atoms/box'
|
|
6
|
+
import {RnIcon} from '../../atoms/rn-icon'
|
|
7
|
+
import {RnText} from '../../atoms/rn-text'
|
|
8
|
+
import {RnGlyph} from '../../glyph'
|
|
9
|
+
import type {RnListItemComponent, RnListItemProps} from './rn-list-item.types'
|
|
10
|
+
|
|
11
|
+
export const RnListItem: RnListItemComponent = ({icon, onPress, px, subtitle, testID, title}: RnListItemProps) => {
|
|
12
|
+
const theme = useAppTheme()
|
|
13
|
+
const pressedColor = theme.color('fill.tertiary')
|
|
14
|
+
|
|
15
|
+
const ripple = useMemo(() => ({color: pressedColor}), [pressedColor])
|
|
16
|
+
const highlight = useMemo(
|
|
17
|
+
() =>
|
|
18
|
+
Platform.OS === 'ios'
|
|
19
|
+
? ({pressed}: PressableStateCallbackType) => (pressed ? {backgroundColor: pressedColor} : null)
|
|
20
|
+
: undefined,
|
|
21
|
+
[pressedColor]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const row = (
|
|
25
|
+
<Box align="center" flexDirection="row" gap="s12" px={px} py="s12">
|
|
26
|
+
{icon ? <RnIcon color="content.secondary" name={icon} size="s20" /> : null}
|
|
27
|
+
<Box flex={1} gap="s2">
|
|
28
|
+
<RnText variant="body">{title}</RnText>
|
|
29
|
+
{subtitle ? (
|
|
30
|
+
<RnText color="content.secondary" variant="footnote">
|
|
31
|
+
{subtitle}
|
|
32
|
+
</RnText>
|
|
33
|
+
) : null}
|
|
34
|
+
</Box>
|
|
35
|
+
{onPress ? <RnGlyph color="content.quaternary" name="chevronRight" size="s16" /> : null}
|
|
36
|
+
</Box>
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if (!onPress) {
|
|
40
|
+
return <Box testID={testID}>{row}</Box>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Pressable accessibilityRole="button" android_ripple={ripple} onPress={onPress} style={highlight} testID={testID}>
|
|
45
|
+
{row}
|
|
46
|
+
</Pressable>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
import type {ListItemProps} from '../list-item'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `ListItem`'s row in the React Native tier — plain views, no host. For lists long enough that a native host per row
|
|
7
|
+
* would cost too much to mount (a navigation list, a FlashList of rows), with the platform's own press feedback: a
|
|
8
|
+
* ripple across the whole row on Android, a highlight on iOS. In a rounded card, give the card `overflow="hidden"`:
|
|
9
|
+
* Android draws the ripple as the row's foreground, which only a parent's clip keeps inside rounded corners.
|
|
10
|
+
*/
|
|
11
|
+
export type RnListItemProps = ListItemProps
|
|
12
|
+
|
|
13
|
+
export type RnListItemComponent = (props: RnListItemProps) => ReactNode
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import {Text as ComposeText, DropdownMenu, DropdownMenuItem, Host} from '@expo/ui/jetpack-compose'
|
|
2
2
|
import {useSx} from '@hzblj/kara-sx'
|
|
3
3
|
import {useAppTheme} from '@hzblj/kara-theme'
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import * as Haptics from 'expo-haptics'
|
|
5
|
+
import {useCallback, useMemo, useRef, useState} from 'react'
|
|
6
|
+
import {type GestureResponderEvent, Pressable, StyleSheet, View} from 'react-native'
|
|
7
|
+
import {Icon} from '../../atoms/icon'
|
|
8
|
+
import {HostBoundary} from '../../host-context'
|
|
9
|
+
import {splitForRipple} from '../../ripple-clip'
|
|
6
10
|
import type {MenuAction} from '../menu'
|
|
7
11
|
import type {ContextMenuComponent, ContextMenuExclusionProps, ContextMenuProps} from './context-menu.types'
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* the touch path — the dropdown is a popup window of its own, which takes its touches regardless.
|
|
12
|
-
*/
|
|
13
|
+
type Point = {x: number; y: number}
|
|
14
|
+
|
|
13
15
|
const styles = StyleSheet.create({
|
|
14
|
-
|
|
16
|
+
/** A point under the finger: the dropdown opens from it. The menu is a popup window, so nothing clips it. */
|
|
17
|
+
anchor: {height: 1, position: 'absolute', width: 1},
|
|
15
18
|
})
|
|
16
19
|
|
|
17
20
|
type MenuRowProps = {
|
|
@@ -22,6 +25,7 @@ type MenuRowProps = {
|
|
|
22
25
|
/** A row's `onClick` closes over its own action, so memoising it needs a component per row. */
|
|
23
26
|
const MenuRow = ({action, onDismiss}: MenuRowProps) => {
|
|
24
27
|
const theme = useAppTheme()
|
|
28
|
+
const color = action.destructive ? 'content.destructive' : 'content.primary'
|
|
25
29
|
|
|
26
30
|
const onClick = useCallback(() => {
|
|
27
31
|
onDismiss()
|
|
@@ -31,56 +35,77 @@ const MenuRow = ({action, onDismiss}: MenuRowProps) => {
|
|
|
31
35
|
return (
|
|
32
36
|
<DropdownMenuItem onClick={onClick}>
|
|
33
37
|
<DropdownMenuItem.Text>
|
|
34
|
-
<ComposeText
|
|
35
|
-
color={theme.colorString(action.destructive ? 'content.destructive' : 'content.primary')}
|
|
36
|
-
style={theme.typography.body}
|
|
37
|
-
>
|
|
38
|
+
<ComposeText color={theme.colorString(color)} style={theme.typography.body}>
|
|
38
39
|
{action.label}
|
|
39
40
|
</ComposeText>
|
|
40
41
|
</DropdownMenuItem.Text>
|
|
42
|
+
{action.icon ? (
|
|
43
|
+
<DropdownMenuItem.LeadingIcon>
|
|
44
|
+
<Icon color={color} name={action.icon} size="s20" />
|
|
45
|
+
</DropdownMenuItem.LeadingIcon>
|
|
46
|
+
) : null}
|
|
41
47
|
</DropdownMenuItem>
|
|
42
48
|
)
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
52
|
+
* Android's own take on a context menu, since Material has no lifted preview: a long press ripples the trigger, ticks
|
|
53
|
+
* the long-press haptic, and opens a Material dropdown where the finger is — the trigger stays highlighted while the
|
|
54
|
+
* menu is up. The host behind the menu mounts on the first long press and stays, so a list of triggers that are only
|
|
55
|
+
* scrolled past never builds one. `preview` and `onPreviewPress` are iOS-only.
|
|
49
56
|
*/
|
|
50
57
|
const ContextMenuRoot = ({actions, children, testID, ...props}: ContextMenuProps) => {
|
|
51
58
|
const {onPreviewPress: _onPreviewPress, preview: _preview, ...sx} = props
|
|
52
59
|
const theme = useAppTheme()
|
|
53
|
-
const
|
|
54
|
-
const
|
|
60
|
+
const resolved = useSx(sx)
|
|
61
|
+
const trigger = useRef<View>(null)
|
|
62
|
+
const [anchor, setAnchor] = useState<Point | null>(null)
|
|
55
63
|
const [expanded, setExpanded] = useState(false)
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
// The shape clips on an outer view, so the ripple and the highlight follow the trigger's rounded corners.
|
|
66
|
+
const {inner, outer} = useMemo(() => splitForRipple(resolved), [resolved])
|
|
67
|
+
const ripple = useMemo(() => ({color: theme.color('fill.tertiary')}), [theme])
|
|
68
|
+
const highlight = useMemo(() => ({backgroundColor: theme.color('fill.tertiary')}), [theme])
|
|
69
|
+
|
|
70
|
+
const open = useCallback((event: GestureResponderEvent) => {
|
|
71
|
+
const {pageX, pageY} = event.nativeEvent
|
|
72
|
+
|
|
73
|
+
Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Long_Press).catch(() => undefined)
|
|
74
|
+
// The touch in the trigger's own coordinates: `locationX/Y` are relative to whichever child was touched.
|
|
75
|
+
trigger.current?.measure((_x, _y, _width, _height, left, top) => {
|
|
76
|
+
setAnchor({x: pageX - left, y: pageY - top})
|
|
77
|
+
setExpanded(true)
|
|
78
|
+
})
|
|
60
79
|
}, [])
|
|
61
80
|
|
|
62
81
|
const dismiss = useCallback(() => setExpanded(false), [])
|
|
63
82
|
|
|
64
83
|
return (
|
|
65
|
-
<
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
<View style={outer}>
|
|
85
|
+
<Pressable android_ripple={ripple} onLongPress={open} ref={trigger} style={inner} testID={testID}>
|
|
86
|
+
{/* Under the content, over the trigger's own background: the card tints, its text stays. */}
|
|
87
|
+
{expanded ? <View pointerEvents="none" style={[StyleSheet.absoluteFill, highlight]} /> : null}
|
|
88
|
+
{children}
|
|
89
|
+
{anchor ? (
|
|
90
|
+
<Host
|
|
91
|
+
colorScheme={theme.dark ? 'dark' : 'light'}
|
|
92
|
+
pointerEvents="none"
|
|
93
|
+
seedColor={theme.materialSeed}
|
|
94
|
+
style={[styles.anchor, {left: anchor.x, top: anchor.y}]}
|
|
95
|
+
>
|
|
96
|
+
<HostBoundary>
|
|
97
|
+
<DropdownMenu color={theme.color('surface.primary')} expanded={expanded} onDismissRequest={dismiss}>
|
|
98
|
+
<DropdownMenu.Items>
|
|
99
|
+
{actions.map(action => (
|
|
100
|
+
<MenuRow action={action} key={action.label} onDismiss={dismiss} />
|
|
101
|
+
))}
|
|
102
|
+
</DropdownMenu.Items>
|
|
103
|
+
</DropdownMenu>
|
|
104
|
+
</HostBoundary>
|
|
105
|
+
</Host>
|
|
106
|
+
) : null}
|
|
107
|
+
</Pressable>
|
|
108
|
+
</View>
|
|
84
109
|
)
|
|
85
110
|
}
|
|
86
111
|
|
|
@@ -5,7 +5,8 @@ import type {MenuAction} from '../menu'
|
|
|
5
5
|
/**
|
|
6
6
|
* A context menu on a React Native subtree — a card in a list, say — opened by a long press. iOS: a
|
|
7
7
|
* `UIContextMenuInteraction`, with an optional React Native `preview` lifted while the menu is open and a callback for
|
|
8
|
-
* a tap on it. Android
|
|
8
|
+
* a tap on it. Android, where Material has no lifted preview: a ripple and the long-press haptic, then a Material
|
|
9
|
+
* dropdown where the finger is, with the trigger highlighted while it is open. The trigger is ordinary React Native layout, so it
|
|
9
10
|
* needs no `Screen` or `Island`.
|
|
10
11
|
*/
|
|
11
12
|
export type ContextMenuProps = ResponsiveViewSx & {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type {SheetHeaderProps} from './sheet.types'
|
|
2
|
+
|
|
3
|
+
/** A slot, never rendered: `Sheet` reads its props and builds the header into the native sheet's own header area. */
|
|
4
|
+
export const SheetHeader = (_props: SheetHeaderProps): never => {
|
|
5
|
+
throw new Error('[kara-ui] <Sheet.Header> must be a direct child of <Sheet>.')
|
|
6
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {useSx} from '@hzblj/kara-sx'
|
|
2
|
+
import {createContext, useCallback, useContext, useMemo} from 'react'
|
|
3
|
+
import {ScrollView} from 'react-native'
|
|
4
|
+
|
|
5
|
+
import type {SheetScrollContextValue, SheetScrollHandler, SheetScrollViewProps} from './sheet.types'
|
|
6
|
+
|
|
7
|
+
export const SheetScrollContext = createContext<SheetScrollContextValue | null>(null)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* For a scroll view of your own inside a sheet (a `RnFlatList`, say): pad its content by `topInset`, render
|
|
11
|
+
* `largeTitle` first, and forward `onScroll` — which is what `<Sheet.ScrollView>` does.
|
|
12
|
+
*/
|
|
13
|
+
export const useSheetScroll = (): SheetScrollContextValue => {
|
|
14
|
+
const context = useContext(SheetScrollContext)
|
|
15
|
+
|
|
16
|
+
if (context === null) {
|
|
17
|
+
throw new Error('[kara-ui] useSheetScroll and <Sheet.ScrollView> must be used inside <Sheet>.')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return context
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NO_SX = {}
|
|
24
|
+
|
|
25
|
+
/** The sheet's scrolling body: starts below the header on iOS 26, carries a large title, and reports its scroll. */
|
|
26
|
+
export const SheetScrollView = ({
|
|
27
|
+
children,
|
|
28
|
+
content,
|
|
29
|
+
keyboardShouldPersistTaps,
|
|
30
|
+
onScroll,
|
|
31
|
+
testID,
|
|
32
|
+
}: SheetScrollViewProps) => {
|
|
33
|
+
const {largeTitle, onScroll: onSheetScroll, topInset} = useSheetScroll()
|
|
34
|
+
const contentStyle = useSx(content ?? NO_SX)
|
|
35
|
+
// The header's room goes on top of the content's own top padding, not in place of it.
|
|
36
|
+
const containerStyle = useMemo(() => {
|
|
37
|
+
if (topInset === 0) {
|
|
38
|
+
return contentStyle
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ownTop = typeof contentStyle.paddingTop === 'number' ? contentStyle.paddingTop : 0
|
|
42
|
+
|
|
43
|
+
return [contentStyle, {paddingTop: topInset + ownTop}]
|
|
44
|
+
}, [contentStyle, topInset])
|
|
45
|
+
|
|
46
|
+
const handleScroll = useCallback<SheetScrollHandler>(
|
|
47
|
+
event => {
|
|
48
|
+
onSheetScroll?.(event)
|
|
49
|
+
onScroll?.(event)
|
|
50
|
+
},
|
|
51
|
+
[onScroll, onSheetScroll]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<ScrollView
|
|
56
|
+
contentContainerStyle={containerStyle}
|
|
57
|
+
contentInsetAdjustmentBehavior="never"
|
|
58
|
+
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
|
59
|
+
nestedScrollEnabled
|
|
60
|
+
onScroll={onSheetScroll || onScroll ? handleScroll : undefined}
|
|
61
|
+
overScrollMode="always"
|
|
62
|
+
scrollEventThrottle={16}
|
|
63
|
+
testID={testID}
|
|
64
|
+
>
|
|
65
|
+
{largeTitle}
|
|
66
|
+
{children}
|
|
67
|
+
</ScrollView>
|
|
68
|
+
)
|
|
69
|
+
}
|