@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/lib/module/atoms/h-stack/h-stack.android.js +2 -5
  3. package/lib/module/atoms/h-stack/h-stack.android.js.map +1 -1
  4. package/lib/module/atoms/v-stack/v-stack.android.js +2 -5
  5. package/lib/module/atoms/v-stack/v-stack.android.js.map +1 -1
  6. package/lib/module/atoms/z-stack/z-stack.android.js +2 -5
  7. package/lib/module/atoms/z-stack/z-stack.android.js.map +1 -1
  8. package/lib/module/molecules/index.js +1 -0
  9. package/lib/module/molecules/index.js.map +1 -1
  10. package/lib/module/molecules/labeled-content/labeled-content.js +2 -0
  11. package/lib/module/molecules/labeled-content/labeled-content.js.map +1 -1
  12. package/lib/module/molecules/list-item/list-item.js +12 -1
  13. package/lib/module/molecules/list-item/list-item.js.map +1 -1
  14. package/lib/module/molecules/rn-list-item/index.js +5 -0
  15. package/lib/module/molecules/rn-list-item/index.js.map +1 -0
  16. package/lib/module/molecules/rn-list-item/rn-list-item.js +71 -0
  17. package/lib/module/molecules/rn-list-item/rn-list-item.js.map +1 -0
  18. package/lib/module/molecules/rn-list-item/rn-list-item.types.js +4 -0
  19. package/lib/module/molecules/rn-list-item/rn-list-item.types.js.map +1 -0
  20. package/lib/module/organisms/context-menu/context-menu.android.js +83 -38
  21. package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
  22. package/lib/module/ripple-clip.js +36 -0
  23. package/lib/module/ripple-clip.js.map +1 -0
  24. package/lib/module/use-stack-modifiers.js +35 -0
  25. package/lib/module/use-stack-modifiers.js.map +1 -0
  26. package/lib/typescript/molecules/index.d.ts +1 -0
  27. package/lib/typescript/molecules/labeled-content/labeled-content.types.d.ts +3 -0
  28. package/lib/typescript/molecules/list-item/list-item.types.d.ts +6 -0
  29. package/lib/typescript/molecules/rn-list-item/index.d.ts +2 -0
  30. package/lib/typescript/molecules/rn-list-item/rn-list-item.d.ts +2 -0
  31. package/lib/typescript/molecules/rn-list-item/rn-list-item.types.d.ts +10 -0
  32. package/lib/typescript/organisms/context-menu/context-menu.types.d.ts +2 -1
  33. package/lib/typescript/ripple-clip.d.ts +12 -0
  34. package/lib/typescript/use-stack-modifiers.d.ts +8 -0
  35. package/package.json +4 -4
  36. package/src/atoms/h-stack/h-stack.android.tsx +2 -8
  37. package/src/atoms/v-stack/v-stack.android.tsx +2 -8
  38. package/src/atoms/z-stack/z-stack.android.tsx +2 -8
  39. package/src/molecules/index.ts +1 -0
  40. package/src/molecules/labeled-content/labeled-content.tsx +2 -1
  41. package/src/molecules/labeled-content/labeled-content.types.ts +3 -0
  42. package/src/molecules/list-item/list-item.tsx +12 -4
  43. package/src/molecules/list-item/list-item.types.ts +6 -0
  44. package/src/molecules/rn-list-item/index.ts +2 -0
  45. package/src/molecules/rn-list-item/rn-list-item.tsx +48 -0
  46. package/src/molecules/rn-list-item/rn-list-item.types.ts +13 -0
  47. package/src/organisms/context-menu/context-menu.android.tsx +63 -38
  48. package/src/organisms/context-menu/context-menu.types.ts +2 -1
  49. package/src/ripple-clip.ts +47 -0
  50. 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,2 @@
1
+ export * from './rn-list-item';
2
+ export * from './rn-list-item.types';
@@ -0,0 +1,2 @@
1
+ import type { RnListItemComponent } from './rn-list-item.types';
2
+ export declare const RnListItem: RnListItemComponent;
@@ -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: a Material dropdown under the subtree. The trigger is ordinary React Native layout, so it
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.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.3.0",
63
- "@hzblj/kara-sx": "^0.3.0",
64
- "@hzblj/kara-theme": "^0.3.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 sxModifiers = useSxModifiers(sx)
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 sxModifiers = useSxModifiers(sx)
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 sxModifiers = useSxModifiers(sx)
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}>
@@ -7,6 +7,7 @@ export * from './list-item'
7
7
  export * from './navigation-bar'
8
8
  export * from './pager'
9
9
  export * from './radio-group'
10
+ export * from './rn-list-item'
10
11
  export * from './rn-tooltip'
11
12
  export * from './scroll-edge-container'
12
13
  export * from './search-bar'
@@ -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
- export const ListItem: ListItemComponent = ({icon, onPress, subtitle, testID, title}: ListItemProps) => (
10
- <HStack align="center" onPress={onPress} py="s8" spacing="s12" testID={testID} wide>
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,2 @@
1
+ export * from './rn-list-item'
2
+ export * from './rn-list-item.types'
@@ -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 {useCallback, useState} from 'react'
5
- import {Pressable, StyleSheet, View} from 'react-native'
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
- * The menu's anchor: a point at the trigger's bottom start, so the dropdown opens just under it. The host lies outside
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
- anchor: {bottom: 0, height: 1, left: 0, position: 'absolute', width: 1},
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
- * The trigger stays a plain React Native subtree; a long press on it expands a Compose `DropdownMenu` anchored under
47
- * it. The host behind the menu mounts on the first long press and stays — a list of triggers that are only scrolled
48
- * past never builds one. `preview` and `onPreviewPress` are iOS-only: Material has no lifted preview.
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 style = useSx(sx)
54
- const [isHostMounted, setIsHostMounted] = useState(false)
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
- const open = useCallback(() => {
58
- setIsHostMounted(true)
59
- setExpanded(true)
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
- <Pressable onLongPress={open} style={style} testID={testID}>
66
- {children}
67
- {isHostMounted ? (
68
- <Host
69
- colorScheme={theme.dark ? 'dark' : 'light'}
70
- pointerEvents="none"
71
- seedColor={theme.materialSeed}
72
- style={styles.anchor}
73
- >
74
- <DropdownMenu expanded={expanded} onDismissRequest={dismiss}>
75
- <DropdownMenu.Items>
76
- {actions.map(action => (
77
- <MenuRow action={action} key={action.label} onDismiss={dismiss} />
78
- ))}
79
- </DropdownMenu.Items>
80
- </DropdownMenu>
81
- </Host>
82
- ) : null}
83
- </Pressable>
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: a Material dropdown under the subtree. The trigger is ordinary React Native layout, so it
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
+ }