@hzblj/kara-components 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- 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/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/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/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/ripple-clip.d.ts +12 -0
- package/lib/typescript/use-stack-modifiers.d.ts +8 -0
- package/package.json +4 -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/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/ripple-clip.ts +47 -0
- package/src/use-stack-modifiers.ts +24 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SpaceSize } from '@hzblj/kara-theme';
|
|
1
2
|
import type { ReactNode } from 'react';
|
|
2
3
|
import type { IconName } from '../../atoms/icon';
|
|
3
4
|
/**
|
|
@@ -9,6 +10,8 @@ export type LabeledContentProps = {
|
|
|
9
10
|
icon?: IconName;
|
|
10
11
|
label: string;
|
|
11
12
|
onPress?: () => void;
|
|
13
|
+
/** Horizontal padding inside the row — inside its press area too, so Android's ripple reaches the card's edges. */
|
|
14
|
+
px?: SpaceSize;
|
|
12
15
|
testID?: string;
|
|
13
16
|
value?: string;
|
|
14
17
|
};
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import type { SpaceSize } from '@hzblj/kara-theme';
|
|
1
2
|
import type { ReactNode } from 'react';
|
|
2
3
|
import type { IconName } from '../../atoms/icon';
|
|
3
4
|
export type ListItemProps = {
|
|
4
5
|
icon?: IconName;
|
|
5
6
|
onPress?: () => void;
|
|
7
|
+
/**
|
|
8
|
+
* Horizontal padding inside the row — inside its press area too, so Android's ripple reaches the card's edges. For
|
|
9
|
+
* rows in a card of your own; a `List` insets its rows by itself.
|
|
10
|
+
*/
|
|
11
|
+
px?: SpaceSize;
|
|
6
12
|
subtitle?: string;
|
|
7
13
|
testID?: string;
|
|
8
14
|
title: string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ListItemProps } from '../list-item';
|
|
3
|
+
/**
|
|
4
|
+
* `ListItem`'s row in the React Native tier — plain views, no host. For lists long enough that a native host per row
|
|
5
|
+
* would cost too much to mount (a navigation list, a FlashList of rows), with the platform's own press feedback: a
|
|
6
|
+
* ripple across the whole row on Android, a highlight on iOS. In a rounded card, give the card `overflow="hidden"`:
|
|
7
|
+
* Android draws the ripple as the row's foreground, which only a parent's clip keeps inside rounded corners.
|
|
8
|
+
*/
|
|
9
|
+
export type RnListItemProps = ListItemProps;
|
|
10
|
+
export type RnListItemComponent = (props: RnListItemProps) => ReactNode;
|
|
@@ -4,7 +4,8 @@ import type { MenuAction } from '../menu';
|
|
|
4
4
|
/**
|
|
5
5
|
* A context menu on a React Native subtree — a card in a list, say — opened by a long press. iOS: a
|
|
6
6
|
* `UIContextMenuInteraction`, with an optional React Native `preview` lifted while the menu is open and a callback for
|
|
7
|
-
* a tap on it. Android
|
|
7
|
+
* a tap on it. Android, where Material has no lifted preview: a ripple and the long-press haptic, then a Material
|
|
8
|
+
* dropdown where the finger is, with the trigger highlighted while it is open. The trigger is ordinary React Native layout, so it
|
|
8
9
|
* needs no `Screen` or `Island`.
|
|
9
10
|
*/
|
|
10
11
|
export type ContextMenuProps = ResponsiveViewSx & {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ViewStyle } from 'react-native';
|
|
2
|
+
/**
|
|
3
|
+
* Splits a resolved style for a pressable with an Android ripple. The ripple is drawn as the pressable's foreground,
|
|
4
|
+
* which its own `overflow: hidden` does not clip — so it would spill past rounded corners. A parent clips its
|
|
5
|
+
* children's foregrounds, though: the outer view takes the shape, background, border and outer layout and clips; the
|
|
6
|
+
* pressable inside takes the padding and the content layout and fills it, so the ripple covers the whole card and
|
|
7
|
+
* stops at its corners.
|
|
8
|
+
*/
|
|
9
|
+
export declare const splitForRipple: (style: ViewStyle | undefined) => {
|
|
10
|
+
inner: ViewStyle[];
|
|
11
|
+
outer: ViewStyle;
|
|
12
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ModifierConfig } from '@expo/ui/jetpack-compose/modifiers';
|
|
2
|
+
import { type ResponsiveSx } from '@hzblj/kara-sx';
|
|
3
|
+
/**
|
|
4
|
+
* A Compose stack's modifiers in the order a pressable one needs: its frame, shape and background, then the press,
|
|
5
|
+
* then its padding. Compose clips a `clickable` to what comes after it, so with the padding first the ripple would stop
|
|
6
|
+
* at the padding and a row pressed by its edge would light up only in the middle.
|
|
7
|
+
*/
|
|
8
|
+
export declare const useStackModifiers: (sx: ResponsiveSx, onPress?: () => void, testID?: string) => ModifierConfig[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hzblj/kara-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "The atoms, molecules, organisms and templates of @hzblj/kara-ui — real SwiftUI and Jetpack Compose views through @expo/ui.",
|
|
5
5
|
"author": "Jan Blazej",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
]
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@hzblj/kara-native": "^0.
|
|
63
|
-
"@hzblj/kara-sx": "^0.
|
|
64
|
-
"@hzblj/kara-theme": "^0.
|
|
62
|
+
"@hzblj/kara-native": "^0.4.1",
|
|
63
|
+
"@hzblj/kara-sx": "^0.4.1",
|
|
64
|
+
"@hzblj/kara-theme": "^0.4.1",
|
|
65
65
|
"sf-symbols-typescript": "^2.2.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import {Row} from '@expo/ui/jetpack-compose'
|
|
2
|
-
import {clickable, testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
|
|
3
|
-
import {useSxModifiers} from '@hzblj/kara-sx'
|
|
4
2
|
import {spacing} from '@hzblj/kara-theme'
|
|
5
|
-
import {useMemo} from 'react'
|
|
6
3
|
import {useHostGuard} from '../../host-context'
|
|
4
|
+
import {useStackModifiers} from '../../use-stack-modifiers'
|
|
7
5
|
import type {HStackAlign, HStackComponent, HStackProps} from './h-stack.types'
|
|
8
6
|
|
|
9
7
|
const ALIGNMENT = {
|
|
@@ -21,11 +19,7 @@ export const HStack: HStackComponent = ({
|
|
|
21
19
|
...sx
|
|
22
20
|
}: HStackProps) => {
|
|
23
21
|
useHostGuard('HStack')
|
|
24
|
-
const
|
|
25
|
-
const modifiers = useMemo(
|
|
26
|
-
() => [...sxModifiers, ...(testID ? [testIdModifier(testID)] : []), ...(onPress ? [clickable(onPress)] : [])],
|
|
27
|
-
[onPress, sxModifiers, testID]
|
|
28
|
-
)
|
|
22
|
+
const modifiers = useStackModifiers(sx, onPress, testID)
|
|
29
23
|
|
|
30
24
|
return (
|
|
31
25
|
<Row
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import {Column} from '@expo/ui/jetpack-compose'
|
|
2
|
-
import {clickable, testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
|
|
3
|
-
import {useSxModifiers} from '@hzblj/kara-sx'
|
|
4
2
|
import {spacing} from '@hzblj/kara-theme'
|
|
5
|
-
import {useMemo} from 'react'
|
|
6
3
|
import {useHostGuard} from '../../host-context'
|
|
4
|
+
import {useStackModifiers} from '../../use-stack-modifiers'
|
|
7
5
|
import type {VStackAlign, VStackComponent, VStackProps} from './v-stack.types'
|
|
8
6
|
|
|
9
7
|
const ALIGNMENT = {
|
|
@@ -21,11 +19,7 @@ export const VStack: VStackComponent = ({
|
|
|
21
19
|
...sx
|
|
22
20
|
}: VStackProps) => {
|
|
23
21
|
useHostGuard('VStack')
|
|
24
|
-
const
|
|
25
|
-
const modifiers = useMemo(
|
|
26
|
-
() => [...sxModifiers, ...(testID ? [testIdModifier(testID)] : []), ...(onPress ? [clickable(onPress)] : [])],
|
|
27
|
-
[onPress, sxModifiers, testID]
|
|
28
|
-
)
|
|
22
|
+
const modifiers = useStackModifiers(sx, onPress, testID)
|
|
29
23
|
|
|
30
24
|
return (
|
|
31
25
|
<Column
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import {Box} from '@expo/ui/jetpack-compose'
|
|
2
|
-
import {clickable, testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
|
|
3
|
-
import {useSxModifiers} from '@hzblj/kara-sx'
|
|
4
|
-
import {useMemo} from 'react'
|
|
5
2
|
import {useHostGuard} from '../../host-context'
|
|
3
|
+
import {useStackModifiers} from '../../use-stack-modifiers'
|
|
6
4
|
import type {ZStackAlign, ZStackComponent, ZStackProps} from './z-stack.types'
|
|
7
5
|
|
|
8
6
|
const ALIGNMENT = {
|
|
@@ -13,11 +11,7 @@ const ALIGNMENT = {
|
|
|
13
11
|
|
|
14
12
|
export const ZStack: ZStackComponent = ({align = 'center', children, onPress, testID, ...sx}: ZStackProps) => {
|
|
15
13
|
useHostGuard('ZStack')
|
|
16
|
-
const
|
|
17
|
-
const modifiers = useMemo(
|
|
18
|
-
() => [...sxModifiers, ...(testID ? [testIdModifier(testID)] : []), ...(onPress ? [clickable(onPress)] : [])],
|
|
19
|
-
[onPress, sxModifiers, testID]
|
|
20
|
-
)
|
|
14
|
+
const modifiers = useStackModifiers(sx, onPress, testID)
|
|
21
15
|
|
|
22
16
|
return (
|
|
23
17
|
<Box contentAlignment={ALIGNMENT[align]} modifiers={modifiers}>
|
package/src/molecules/index.ts
CHANGED
|
@@ -16,10 +16,11 @@ export const LabeledContent: LabeledContentComponent = ({
|
|
|
16
16
|
icon,
|
|
17
17
|
label,
|
|
18
18
|
onPress,
|
|
19
|
+
px,
|
|
19
20
|
testID,
|
|
20
21
|
value,
|
|
21
22
|
}: LabeledContentProps) => (
|
|
22
|
-
<HStack align="center" onPress={onPress} py="s8" spacing="s12" testID={testID} wide>
|
|
23
|
+
<HStack align="center" onPress={onPress} px={px} py="s8" spacing="s12" testID={testID} wide>
|
|
23
24
|
{icon ? <Icon color="content.secondary" name={icon} size="s20" /> : null}
|
|
24
25
|
<Text variant="body">{label}</Text>
|
|
25
26
|
<Spacer />
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type {SpaceSize} from '@hzblj/kara-theme'
|
|
1
2
|
import type {ReactNode} from 'react'
|
|
2
3
|
import type {IconName} from '../../atoms/icon'
|
|
3
4
|
|
|
@@ -10,6 +11,8 @@ export type LabeledContentProps = {
|
|
|
10
11
|
icon?: IconName
|
|
11
12
|
label: string
|
|
12
13
|
onPress?: () => void
|
|
14
|
+
/** Horizontal padding inside the row — inside its press area too, so Android's ripple reaches the card's edges. */
|
|
15
|
+
px?: SpaceSize
|
|
13
16
|
testID?: string
|
|
14
17
|
value?: string
|
|
15
18
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {Platform} from 'react-native'
|
|
2
|
+
|
|
1
3
|
import {HStack} from '../../atoms/h-stack'
|
|
2
4
|
import {Icon} from '../../atoms/icon'
|
|
3
5
|
import {Spacer} from '../../atoms/spacer'
|
|
@@ -6,10 +8,16 @@ import {VStack} from '../../atoms/v-stack'
|
|
|
6
8
|
import {Glyph} from '../../glyph'
|
|
7
9
|
import type {ListItemComponent, ListItemProps} from './list-item.types'
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Compose measures a row's children in order, so a text column that fills the line leaves the chevron no room. With a
|
|
13
|
+
* weight it is measured last and takes only what is left; SwiftUI shares the line by itself, with a `Spacer`.
|
|
14
|
+
*/
|
|
15
|
+
const TEXT_FILLS_ROW = {weight: 1}
|
|
16
|
+
|
|
17
|
+
export const ListItem: ListItemComponent = ({icon, onPress, px, subtitle, testID, title}: ListItemProps) => (
|
|
18
|
+
<HStack align="center" onPress={onPress} px={px} py="s8" spacing="s12" testID={testID} wide>
|
|
11
19
|
{icon ? <Icon color="content.secondary" name={icon} size="s20" /> : null}
|
|
12
|
-
<VStack spacing="s2">
|
|
20
|
+
<VStack android={TEXT_FILLS_ROW} spacing="s2">
|
|
13
21
|
<Text variant="body">{title}</Text>
|
|
14
22
|
{subtitle ? (
|
|
15
23
|
<Text color="content.secondary" variant="footnote">
|
|
@@ -17,7 +25,7 @@ export const ListItem: ListItemComponent = ({icon, onPress, subtitle, testID, ti
|
|
|
17
25
|
</Text>
|
|
18
26
|
) : null}
|
|
19
27
|
</VStack>
|
|
20
|
-
<Spacer />
|
|
28
|
+
{Platform.OS === 'ios' ? <Spacer /> : null}
|
|
21
29
|
{onPress ? <Glyph color="content.quaternary" name="chevronRight" size="s16" /> : null}
|
|
22
30
|
</HStack>
|
|
23
31
|
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type {SpaceSize} from '@hzblj/kara-theme'
|
|
1
2
|
import type {ReactNode} from 'react'
|
|
2
3
|
|
|
3
4
|
import type {IconName} from '../../atoms/icon'
|
|
@@ -5,6 +6,11 @@ import type {IconName} from '../../atoms/icon'
|
|
|
5
6
|
export type ListItemProps = {
|
|
6
7
|
icon?: IconName
|
|
7
8
|
onPress?: () => void
|
|
9
|
+
/**
|
|
10
|
+
* Horizontal padding inside the row — inside its press area too, so Android's ripple reaches the card's edges. For
|
|
11
|
+
* rows in a card of your own; a `List` insets its rows by itself.
|
|
12
|
+
*/
|
|
13
|
+
px?: SpaceSize
|
|
8
14
|
subtitle?: string
|
|
9
15
|
testID?: string
|
|
10
16
|
title: string
|
|
@@ -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,47 @@
|
|
|
1
|
+
import {StyleSheet, type ViewStyle} from 'react-native'
|
|
2
|
+
|
|
3
|
+
/** What lays out a view's children — it goes on the pressable inside, so the content sits where the caller put it. */
|
|
4
|
+
const CONTENT_KEYS = new Set<string>([
|
|
5
|
+
'alignContent',
|
|
6
|
+
'alignItems',
|
|
7
|
+
'columnGap',
|
|
8
|
+
'flexDirection',
|
|
9
|
+
'flexWrap',
|
|
10
|
+
'gap',
|
|
11
|
+
'justifyContent',
|
|
12
|
+
'padding',
|
|
13
|
+
'paddingBottom',
|
|
14
|
+
'paddingEnd',
|
|
15
|
+
'paddingHorizontal',
|
|
16
|
+
'paddingLeft',
|
|
17
|
+
'paddingRight',
|
|
18
|
+
'paddingStart',
|
|
19
|
+
'paddingTop',
|
|
20
|
+
'paddingVertical',
|
|
21
|
+
'rowGap',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
const FILL: ViewStyle = {flexGrow: 1}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Splits a resolved style for a pressable with an Android ripple. The ripple is drawn as the pressable's foreground,
|
|
28
|
+
* which its own `overflow: hidden` does not clip — so it would spill past rounded corners. A parent clips its
|
|
29
|
+
* children's foregrounds, though: the outer view takes the shape, background, border and outer layout and clips; the
|
|
30
|
+
* pressable inside takes the padding and the content layout and fills it, so the ripple covers the whole card and
|
|
31
|
+
* stops at its corners.
|
|
32
|
+
*/
|
|
33
|
+
export const splitForRipple = (style: ViewStyle | undefined): {inner: ViewStyle[]; outer: ViewStyle} => {
|
|
34
|
+
const flat = StyleSheet.flatten(style) ?? {}
|
|
35
|
+
const outer: Record<string, unknown> = {overflow: 'hidden'}
|
|
36
|
+
const inner: Record<string, unknown> = {}
|
|
37
|
+
|
|
38
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
39
|
+
if (CONTENT_KEYS.has(key)) {
|
|
40
|
+
inner[key] = value
|
|
41
|
+
} else if (key !== 'overflow') {
|
|
42
|
+
outer[key] = value
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {inner: [FILL, inner as ViewStyle], outer: outer as ViewStyle}
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {clickable, type ModifierConfig, testID as testIdModifier} from '@expo/ui/jetpack-compose/modifiers'
|
|
2
|
+
import {type ResponsiveSx, useSxModifiers} from '@hzblj/kara-sx'
|
|
3
|
+
import {useMemo} from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A Compose stack's modifiers in the order a pressable one needs: its frame, shape and background, then the press,
|
|
7
|
+
* then its padding. Compose clips a `clickable` to what comes after it, so with the padding first the ripple would stop
|
|
8
|
+
* at the padding and a row pressed by its edge would light up only in the middle.
|
|
9
|
+
*/
|
|
10
|
+
export const useStackModifiers = (sx: ResponsiveSx, onPress?: () => void, testID?: string): ModifierConfig[] => {
|
|
11
|
+
const {p, pb, pl, pr, pt, px, py, ...frame} = sx
|
|
12
|
+
const frameModifiers = useSxModifiers(frame)
|
|
13
|
+
const paddingModifiers = useSxModifiers({p, pb, pl, pr, pt, px, py})
|
|
14
|
+
|
|
15
|
+
return useMemo(
|
|
16
|
+
() => [
|
|
17
|
+
...frameModifiers,
|
|
18
|
+
...(testID ? [testIdModifier(testID)] : []),
|
|
19
|
+
...(onPress ? [clickable(onPress)] : []),
|
|
20
|
+
...paddingModifiers,
|
|
21
|
+
],
|
|
22
|
+
[frameModifiers, onPress, paddingModifiers, testID]
|
|
23
|
+
)
|
|
24
|
+
}
|