@bsky.app/peek-menu 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.eslintrc.js +5 -0
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +121 -0
  4. package/build/ExpoContextMenuNativeView.android.d.ts +6 -0
  5. package/build/ExpoContextMenuNativeView.android.d.ts.map +1 -0
  6. package/build/ExpoContextMenuNativeView.android.js +9 -0
  7. package/build/ExpoContextMenuNativeView.android.js.map +1 -0
  8. package/build/ExpoContextMenuNativeView.d.ts +5 -0
  9. package/build/ExpoContextMenuNativeView.d.ts.map +1 -0
  10. package/build/ExpoContextMenuNativeView.js +4 -0
  11. package/build/ExpoContextMenuNativeView.js.map +1 -0
  12. package/build/ExpoContextMenuNativeView.web.d.ts +7 -0
  13. package/build/ExpoContextMenuNativeView.web.d.ts.map +1 -0
  14. package/build/ExpoContextMenuNativeView.web.js +10 -0
  15. package/build/ExpoContextMenuNativeView.web.js.map +1 -0
  16. package/build/Menu.d.ts +6 -0
  17. package/build/Menu.d.ts.map +1 -0
  18. package/build/Menu.js +10 -0
  19. package/build/Menu.js.map +1 -0
  20. package/build/MenuItem.d.ts +11 -0
  21. package/build/MenuItem.d.ts.map +1 -0
  22. package/build/MenuItem.js +10 -0
  23. package/build/MenuItem.js.map +1 -0
  24. package/build/MenuItemIcon.d.ts +6 -0
  25. package/build/MenuItemIcon.d.ts.map +1 -0
  26. package/build/MenuItemIcon.js +11 -0
  27. package/build/MenuItemIcon.js.map +1 -0
  28. package/build/MenuItemText.d.ts +5 -0
  29. package/build/MenuItemText.d.ts.map +1 -0
  30. package/build/MenuItemText.js +11 -0
  31. package/build/MenuItemText.js.map +1 -0
  32. package/build/Root.d.ts +8 -0
  33. package/build/Root.d.ts.map +1 -0
  34. package/build/Root.js +81 -0
  35. package/build/Root.js.map +1 -0
  36. package/build/Trigger.d.ts +15 -0
  37. package/build/Trigger.d.ts.map +1 -0
  38. package/build/Trigger.js +10 -0
  39. package/build/Trigger.js.map +1 -0
  40. package/build/index.d.ts +8 -0
  41. package/build/index.d.ts.map +1 -0
  42. package/build/index.js +7 -0
  43. package/build/index.js.map +1 -0
  44. package/build/registry.d.ts +13 -0
  45. package/build/registry.d.ts.map +1 -0
  46. package/build/registry.js +18 -0
  47. package/build/registry.js.map +1 -0
  48. package/build/types.d.ts +70 -0
  49. package/build/types.d.ts.map +1 -0
  50. package/build/types.js +2 -0
  51. package/build/types.js.map +1 -0
  52. package/expo-module.config.json +6 -0
  53. package/ios/ExpoBlueskyPeekMenu.podspec +22 -0
  54. package/ios/ExpoBlueskyPeekMenuModule.swift +24 -0
  55. package/ios/ExpoBlueskyPeekMenuView.swift +111 -0
  56. package/ios/IconRenderer.swift +69 -0
  57. package/ios/ImagePreviewController.swift +133 -0
  58. package/ios/MenuBuilder.swift +51 -0
  59. package/ios/PreviewFactory.swift +27 -0
  60. package/ios/SVGPathParser.swift +320 -0
  61. package/package.json +38 -0
  62. package/src/ExpoContextMenuNativeView.android.tsx +10 -0
  63. package/src/ExpoContextMenuNativeView.tsx +10 -0
  64. package/src/ExpoContextMenuNativeView.web.tsx +11 -0
  65. package/src/Menu.tsx +17 -0
  66. package/src/MenuItem.tsx +22 -0
  67. package/src/MenuItemIcon.tsx +17 -0
  68. package/src/MenuItemText.tsx +16 -0
  69. package/src/Root.tsx +119 -0
  70. package/src/Trigger.tsx +26 -0
  71. package/src/index.ts +12 -0
  72. package/src/registry.ts +32 -0
  73. package/src/types.ts +71 -0
  74. package/tsconfig.json +9 -0
  75. package/vitest.config.ts +7 -0
@@ -0,0 +1,320 @@
1
+ import UIKit
2
+
3
+ /// Minimal SVG path `d` parser. Handles the subset used by Bluesky's icon set:
4
+ /// M m L l H h V v C c S s Q q T t A a Z z.
5
+ /// Assumes well-formed input from the app's own compiled-in icon paths.
6
+ enum SVGPathParser {
7
+ static func parse(_ d: String) -> UIBezierPath {
8
+ let path = UIBezierPath()
9
+ var tokens = Tokenizer(d)
10
+ var currentPoint = CGPoint.zero
11
+ var subpathStart = CGPoint.zero
12
+ var lastControl: CGPoint?
13
+ var lastQuadControl: CGPoint?
14
+ var command: Character = "M"
15
+
16
+ while let next = tokens.peek() {
17
+ if next.isLetter {
18
+ command = next
19
+ tokens.consume()
20
+ }
21
+
22
+ switch command {
23
+ case "M", "m":
24
+ let p = tokens.readPoint()
25
+ let abs = command == "M" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
26
+ path.move(to: abs)
27
+ currentPoint = abs
28
+ subpathStart = abs
29
+ lastControl = nil
30
+ lastQuadControl = nil
31
+ // Subsequent coordinate pairs after M/m are implicit L/l
32
+ command = command == "M" ? "L" : "l"
33
+
34
+ case "L", "l":
35
+ let p = tokens.readPoint()
36
+ let abs = command == "L" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
37
+ path.addLine(to: abs)
38
+ currentPoint = abs
39
+ lastControl = nil
40
+ lastQuadControl = nil
41
+
42
+ case "H", "h":
43
+ let x = tokens.readNumber()
44
+ let abs = command == "H" ? CGPoint(x: x, y: currentPoint.y) : CGPoint(x: currentPoint.x + x, y: currentPoint.y)
45
+ path.addLine(to: abs)
46
+ currentPoint = abs
47
+ lastControl = nil
48
+ lastQuadControl = nil
49
+
50
+ case "V", "v":
51
+ let y = tokens.readNumber()
52
+ let abs = command == "V" ? CGPoint(x: currentPoint.x, y: y) : CGPoint(x: currentPoint.x, y: currentPoint.y + y)
53
+ path.addLine(to: abs)
54
+ currentPoint = abs
55
+ lastControl = nil
56
+ lastQuadControl = nil
57
+
58
+ case "C", "c":
59
+ let c1 = tokens.readPoint()
60
+ let c2 = tokens.readPoint()
61
+ let p = tokens.readPoint()
62
+ let (ac1, ac2, ap): (CGPoint, CGPoint, CGPoint)
63
+ if command == "C" {
64
+ ac1 = c1; ac2 = c2; ap = p
65
+ } else {
66
+ ac1 = CGPoint(x: currentPoint.x + c1.x, y: currentPoint.y + c1.y)
67
+ ac2 = CGPoint(x: currentPoint.x + c2.x, y: currentPoint.y + c2.y)
68
+ ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
69
+ }
70
+ path.addCurve(to: ap, controlPoint1: ac1, controlPoint2: ac2)
71
+ currentPoint = ap
72
+ lastControl = ac2
73
+ lastQuadControl = nil
74
+
75
+ case "S", "s":
76
+ let c2 = tokens.readPoint()
77
+ let p = tokens.readPoint()
78
+ let reflected = lastControl.map {
79
+ CGPoint(x: 2 * currentPoint.x - $0.x, y: 2 * currentPoint.y - $0.y)
80
+ } ?? currentPoint
81
+ let (ac2, ap): (CGPoint, CGPoint)
82
+ if command == "S" {
83
+ ac2 = c2; ap = p
84
+ } else {
85
+ ac2 = CGPoint(x: currentPoint.x + c2.x, y: currentPoint.y + c2.y)
86
+ ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
87
+ }
88
+ path.addCurve(to: ap, controlPoint1: reflected, controlPoint2: ac2)
89
+ currentPoint = ap
90
+ lastControl = ac2
91
+ lastQuadControl = nil
92
+
93
+ case "Q", "q":
94
+ let c = tokens.readPoint()
95
+ let p = tokens.readPoint()
96
+ let (ac, ap): (CGPoint, CGPoint)
97
+ if command == "Q" {
98
+ ac = c; ap = p
99
+ } else {
100
+ ac = CGPoint(x: currentPoint.x + c.x, y: currentPoint.y + c.y)
101
+ ap = CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
102
+ }
103
+ path.addQuadCurve(to: ap, controlPoint: ac)
104
+ currentPoint = ap
105
+ lastControl = nil
106
+ lastQuadControl = ac
107
+
108
+ case "T", "t":
109
+ let p = tokens.readPoint()
110
+ let reflected = lastQuadControl.map {
111
+ CGPoint(x: 2 * currentPoint.x - $0.x, y: 2 * currentPoint.y - $0.y)
112
+ } ?? currentPoint
113
+ let ap = command == "T" ? p : CGPoint(x: currentPoint.x + p.x, y: currentPoint.y + p.y)
114
+ path.addQuadCurve(to: ap, controlPoint: reflected)
115
+ currentPoint = ap
116
+ lastControl = nil
117
+ lastQuadControl = reflected
118
+
119
+ case "A", "a":
120
+ let rx = tokens.readNumber()
121
+ let ry = tokens.readNumber()
122
+ let xAxisRotation = tokens.readNumber() * .pi / 180
123
+ let largeArc = tokens.readNumber() != 0
124
+ let sweep = tokens.readNumber() != 0
125
+ let end = tokens.readPoint()
126
+ let absEnd = command == "A" ? end : CGPoint(x: currentPoint.x + end.x, y: currentPoint.y + end.y)
127
+ ArcBuilder.addArc(
128
+ to: path,
129
+ from: currentPoint,
130
+ to: absEnd,
131
+ rx: rx,
132
+ ry: ry,
133
+ xAxisRotation: xAxisRotation,
134
+ largeArc: largeArc,
135
+ sweep: sweep
136
+ )
137
+ currentPoint = absEnd
138
+ lastControl = nil
139
+ lastQuadControl = nil
140
+
141
+ case "Z", "z":
142
+ path.close()
143
+ currentPoint = subpathStart
144
+ lastControl = nil
145
+ lastQuadControl = nil
146
+
147
+ default:
148
+ tokens.consume()
149
+ }
150
+ }
151
+
152
+ return path
153
+ }
154
+ }
155
+
156
+ private struct Tokenizer {
157
+ private let chars: [Character]
158
+ private var index = 0
159
+
160
+ init(_ s: String) { self.chars = Array(s) }
161
+
162
+ mutating func peek() -> Character? {
163
+ skipSeparators()
164
+ return index < chars.count ? chars[index] : nil
165
+ }
166
+
167
+ mutating func consume() {
168
+ if index < chars.count { index += 1 }
169
+ }
170
+
171
+ mutating func readNumber() -> CGFloat {
172
+ skipSeparators()
173
+ let start = index
174
+ var sawDot = false
175
+ var sawE = false
176
+ while index < chars.count {
177
+ let c = chars[index]
178
+ if index == start && (c == "+" || c == "-") {
179
+ index += 1
180
+ continue
181
+ }
182
+ if c == "." {
183
+ if sawDot || sawE { break }
184
+ sawDot = true
185
+ index += 1
186
+ continue
187
+ }
188
+ if c == "e" || c == "E" {
189
+ if sawE { break }
190
+ sawE = true
191
+ index += 1
192
+ if index < chars.count && (chars[index] == "+" || chars[index] == "-") {
193
+ index += 1
194
+ }
195
+ continue
196
+ }
197
+ if c.isNumber {
198
+ index += 1
199
+ continue
200
+ }
201
+ break
202
+ }
203
+ let slice = String(chars[start..<index])
204
+ return CGFloat(Double(slice) ?? 0)
205
+ }
206
+
207
+ mutating func readPoint() -> CGPoint {
208
+ let x = readNumber()
209
+ let y = readNumber()
210
+ return CGPoint(x: x, y: y)
211
+ }
212
+
213
+ private mutating func skipSeparators() {
214
+ while index < chars.count {
215
+ let c = chars[index]
216
+ if c == " " || c == "," || c == "\t" || c == "\n" || c == "\r" {
217
+ index += 1
218
+ } else {
219
+ break
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ private enum ArcBuilder {
226
+ /// Converts an SVG elliptical arc to a series of cubic Bezier segments and
227
+ /// appends them to the given path. Based on the W3C "Elliptical Arc
228
+ /// Implementation Notes" conversion.
229
+ static func addArc(
230
+ to path: UIBezierPath,
231
+ from start: CGPoint,
232
+ to end: CGPoint,
233
+ rx rxIn: CGFloat,
234
+ ry ryIn: CGFloat,
235
+ xAxisRotation phi: CGFloat,
236
+ largeArc: Bool,
237
+ sweep: Bool
238
+ ) {
239
+ if start == end { return }
240
+ if rxIn == 0 || ryIn == 0 {
241
+ path.addLine(to: end)
242
+ return
243
+ }
244
+
245
+ var rx = abs(rxIn)
246
+ var ry = abs(ryIn)
247
+ let cosPhi = cos(phi)
248
+ let sinPhi = sin(phi)
249
+
250
+ let dx = (start.x - end.x) / 2
251
+ let dy = (start.y - end.y) / 2
252
+ let x1p = cosPhi * dx + sinPhi * dy
253
+ let y1p = -sinPhi * dx + cosPhi * dy
254
+
255
+ let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
256
+ if lambda > 1 {
257
+ let s = sqrt(lambda)
258
+ rx *= s
259
+ ry *= s
260
+ }
261
+
262
+ let sign: CGFloat = (largeArc == sweep) ? -1 : 1
263
+ let numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
264
+ let denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p
265
+ let factor = sign * sqrt(max(0, numerator / denominator))
266
+ let cxp = factor * (rx * y1p / ry)
267
+ let cyp = factor * (-ry * x1p / rx)
268
+
269
+ let cx = cosPhi * cxp - sinPhi * cyp + (start.x + end.x) / 2
270
+ let cy = sinPhi * cxp + cosPhi * cyp + (start.y + end.y) / 2
271
+
272
+ let startVec = CGPoint(x: (x1p - cxp) / rx, y: (y1p - cyp) / ry)
273
+ let endVec = CGPoint(x: (-x1p - cxp) / rx, y: (-y1p - cyp) / ry)
274
+ let theta1 = angle(from: CGPoint(x: 1, y: 0), to: startVec)
275
+ var deltaTheta = angle(from: startVec, to: endVec)
276
+ if !sweep && deltaTheta > 0 {
277
+ deltaTheta -= 2 * .pi
278
+ } else if sweep && deltaTheta < 0 {
279
+ deltaTheta += 2 * .pi
280
+ }
281
+
282
+ // Split into up to 4 cubic beziers (each covering <= 90°).
283
+ let segments = max(1, Int(ceil(abs(deltaTheta) / (.pi / 2))))
284
+ let delta = deltaTheta / CGFloat(segments)
285
+ let t = (4.0 / 3.0) * tan(delta / 4)
286
+
287
+ var theta = theta1
288
+ for _ in 0..<segments {
289
+ let cosT = cos(theta)
290
+ let sinT = sin(theta)
291
+ let cosT2 = cos(theta + delta)
292
+ let sinT2 = sin(theta + delta)
293
+
294
+ let p1 = CGPoint(x: cosT - t * sinT, y: sinT + t * cosT)
295
+ let p2 = CGPoint(x: cosT2 + t * sinT2, y: sinT2 - t * cosT2)
296
+ let p3 = CGPoint(x: cosT2, y: sinT2)
297
+
298
+ let c1 = transformEllipsePoint(p1, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
299
+ let c2 = transformEllipsePoint(p2, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
300
+ let c3 = transformEllipsePoint(p3, rx: rx, ry: ry, phi: phi, cx: cx, cy: cy)
301
+
302
+ path.addCurve(to: c3, controlPoint1: c1, controlPoint2: c2)
303
+ theta += delta
304
+ }
305
+ }
306
+
307
+ private static func transformEllipsePoint(_ p: CGPoint, rx: CGFloat, ry: CGFloat, phi: CGFloat, cx: CGFloat, cy: CGFloat) -> CGPoint {
308
+ let x = rx * p.x
309
+ let y = ry * p.y
310
+ let rx_ = cos(phi) * x - sin(phi) * y + cx
311
+ let ry_ = sin(phi) * x + cos(phi) * y + cy
312
+ return CGPoint(x: rx_, y: ry_)
313
+ }
314
+
315
+ private static func angle(from u: CGPoint, to v: CGPoint) -> CGFloat {
316
+ let dot = u.x * v.x + u.y * v.y
317
+ let det = u.x * v.y - u.y * v.x
318
+ return atan2(det, dot)
319
+ }
320
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@bsky.app/peek-menu",
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "description": "Native iOS context menu with peek preview for images.",
6
+ "repository": "https://github.com/bluesky-social/toolbox",
7
+ "author": "Bluesky Social PBC <hello@blueskyweb.xyz>",
8
+ "homepage": "https://github.com/bluesky-social/toolbox/tree/main/packages/peek-menu",
9
+ "main": "build/index.js",
10
+ "types": "build/index.d.ts",
11
+ "keywords": [
12
+ "react-native",
13
+ "expo",
14
+ "context-menu",
15
+ "peek",
16
+ "ios"
17
+ ],
18
+ "devDependencies": {
19
+ "@types/react": "~19.1.1",
20
+ "expo": "^55.0.8",
21
+ "expo-module-scripts": "^55.0.2",
22
+ "expo-modules-core": "^55.0.22",
23
+ "react-native": "0.82.1",
24
+ "vitest": "^3.1.1"
25
+ },
26
+ "peerDependencies": {
27
+ "expo": "*",
28
+ "react": "*",
29
+ "react-native": "*"
30
+ },
31
+ "scripts": {
32
+ "build": "expo-module clean && EXPO_NONINTERACTIVE=1 expo-module build",
33
+ "clean": "expo-module clean",
34
+ "lint": "expo-module lint",
35
+ "test": "vitest run",
36
+ "expo-module": "expo-module"
37
+ }
38
+ }
@@ -0,0 +1,10 @@
1
+ import {View} from 'react-native'
2
+
3
+ import {type NativeViewProps} from './types'
4
+
5
+ /**
6
+ * Android fallback: passthrough wrapper with no context menu interaction.
7
+ */
8
+ export default function NativeView({children, style}: NativeViewProps) {
9
+ return <View style={style}>{children}</View>
10
+ }
@@ -0,0 +1,10 @@
1
+ import {type ComponentType} from 'react'
2
+ import {requireNativeViewManager} from 'expo-modules-core'
3
+
4
+ import {type NativeViewProps} from './types'
5
+
6
+ const NativeView: ComponentType<NativeViewProps> = requireNativeViewManager(
7
+ 'ExpoBlueskyPeekMenu',
8
+ )
9
+
10
+ export default NativeView
@@ -0,0 +1,11 @@
1
+ import {View} from 'react-native'
2
+
3
+ import {type NativeViewProps} from './types'
4
+
5
+ /**
6
+ * Web fallback: passthrough. Long-press is a no-op; tap handling is delegated
7
+ * to children.
8
+ */
9
+ export default function NativeView({children, style}: NativeViewProps) {
10
+ return <View style={style}>{children}</View>
11
+ }
package/src/Menu.tsx ADDED
@@ -0,0 +1,17 @@
1
+ import {type ReactNode} from 'react'
2
+
3
+ import {tag} from './registry'
4
+
5
+ export type MenuProps = {
6
+ children: ReactNode
7
+ }
8
+
9
+ /**
10
+ * Sentinel: does not render. `Root` reads this element's children to collect
11
+ * menu items.
12
+ */
13
+ function MenuImpl(_: MenuProps): null {
14
+ return null
15
+ }
16
+
17
+ export const Menu = tag(MenuImpl, 'menu')
@@ -0,0 +1,22 @@
1
+ import {type ReactNode} from 'react'
2
+
3
+ import {tag} from './registry'
4
+
5
+ export type MenuItemProps = {
6
+ id: string
7
+ destructive?: boolean
8
+ disabled?: boolean
9
+ onSelect: () => void
10
+ /** Children must include a `MenuItemIcon` and a `MenuItemText`. */
11
+ children: ReactNode
12
+ }
13
+
14
+ /**
15
+ * Sentinel: does not render. `Root` walks the children tree to extract icon +
16
+ * label, then ships a plain menu item spec to native.
17
+ */
18
+ function MenuItemImpl(_: MenuItemProps): null {
19
+ return null
20
+ }
21
+
22
+ export const MenuItem = tag(MenuItemImpl, 'item')
@@ -0,0 +1,17 @@
1
+ import {tag} from './registry'
2
+ import {type MenuItemIconSource} from './types'
3
+
4
+ export type MenuItemIconProps = {
5
+ icon: MenuItemIconSource
6
+ }
7
+
8
+ /**
9
+ * Sentinel: does not render any React output. `Root` introspects this element
10
+ * during its collection pass to pull the SVG path data off the icon component,
11
+ * then ships the data to native.
12
+ */
13
+ function MenuItemIconImpl(_: MenuItemIconProps): null {
14
+ return null
15
+ }
16
+
17
+ export const MenuItemIcon = tag(MenuItemIconImpl, 'item-icon')
@@ -0,0 +1,16 @@
1
+ import {tag} from './registry'
2
+
3
+ export type MenuItemTextProps = {
4
+ children: string
5
+ }
6
+
7
+ /**
8
+ * Sentinel: does not render. `Root` reads `children` as the menu item label.
9
+ * Keeping this a sentinel (vs. a real Text) mirrors how `Menu.ItemText` is
10
+ * used elsewhere while letting iOS draw the menu chrome natively.
11
+ */
12
+ function MenuItemTextImpl(_: MenuItemTextProps): null {
13
+ return null
14
+ }
15
+
16
+ export const MenuItemText = tag(MenuItemTextImpl, 'item-text')
package/src/Root.tsx ADDED
@@ -0,0 +1,119 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ useCallback,
7
+ useMemo,
8
+ } from 'react'
9
+ import {type StyleProp, type ViewStyle} from 'react-native'
10
+
11
+ import NativeView from './ExpoContextMenuNativeView'
12
+ import {type MenuProps} from './Menu'
13
+ import {type MenuItemProps} from './MenuItem'
14
+ import {type MenuItemIconProps} from './MenuItemIcon'
15
+ import {type MenuItemTextProps} from './MenuItemText'
16
+ import {kindOf} from './registry'
17
+ import {type TriggerProps} from './Trigger'
18
+ import {type MenuItemSpec} from './types'
19
+
20
+ export type RootProps = {
21
+ children: ReactNode
22
+ style?: StyleProp<ViewStyle>
23
+ }
24
+
25
+ export function Root({children, style}: RootProps) {
26
+ const {trigger, menu} = collectTriggerAndMenu(children)
27
+
28
+ const {menuItems, selectById} = useMemo(() => {
29
+ const items: MenuItemSpec[] = []
30
+ const map: Record<string, () => void> = {}
31
+ if (menu) {
32
+ Children.forEach(menu.props.children, child => {
33
+ if (!isValidElement(child)) return
34
+ if (kindOf(child.type) !== 'item') return
35
+ const spec = specFromItem(child as ReactElement<MenuItemProps>)
36
+ if (!spec) return
37
+ items.push(spec.item)
38
+ map[spec.item.id] = spec.onSelect
39
+ })
40
+ }
41
+ return {menuItems: items, selectById: map}
42
+ }, [menu])
43
+
44
+ const handleItemPress = useCallback(
45
+ (e: {nativeEvent: {id: string}}) => {
46
+ selectById[e.nativeEvent.id]?.()
47
+ },
48
+ [selectById],
49
+ )
50
+
51
+ const onPreviewPress = trigger?.props.onPreviewPress
52
+ const handlePreviewPress = useCallback(() => {
53
+ onPreviewPress?.()
54
+ }, [onPreviewPress])
55
+
56
+ if (!trigger) {
57
+ return <>{children}</>
58
+ }
59
+
60
+ return (
61
+ <NativeView
62
+ preview={trigger.props.preview}
63
+ menuItems={menuItems}
64
+ previewCornerRadius={trigger.props.borderRadius ?? 0}
65
+ onItemPress={handleItemPress}
66
+ onPreviewPress={handlePreviewPress}
67
+ style={[style, trigger.props.style]}>
68
+ {trigger.props.children}
69
+ </NativeView>
70
+ )
71
+ }
72
+
73
+ // -----------------------------------------------------------------------------
74
+
75
+ type Collected = {
76
+ trigger?: ReactElement<TriggerProps>
77
+ menu?: ReactElement<MenuProps>
78
+ }
79
+
80
+ function collectTriggerAndMenu(children: ReactNode): Collected {
81
+ const result: Collected = {}
82
+ Children.forEach(children, child => {
83
+ if (!isValidElement(child)) return
84
+ const kind = kindOf(child.type)
85
+ if (kind === 'trigger') result.trigger = child as ReactElement<TriggerProps>
86
+ else if (kind === 'menu') result.menu = child as ReactElement<MenuProps>
87
+ })
88
+ return result
89
+ }
90
+
91
+ function specFromItem(
92
+ element: ReactElement<MenuItemProps>,
93
+ ): {item: MenuItemSpec; onSelect: () => void} | null {
94
+ const {id, destructive, disabled, onSelect, children} = element.props
95
+ let label = ''
96
+ let icon: MenuItemSpec['icon']
97
+ Children.forEach(children, child => {
98
+ if (!isValidElement(child)) return
99
+ const kind = kindOf(child.type)
100
+ if (kind === 'item-text') {
101
+ const text = (child as ReactElement<MenuItemTextProps>).props.children
102
+ if (typeof text === 'string') label = text
103
+ } else if (kind === 'item-icon') {
104
+ const iconSource = (child as ReactElement<MenuItemIconProps>).props.icon
105
+ if (iconSource?.svgPaths?.length) {
106
+ icon = {
107
+ paths: iconSource.svgPaths,
108
+ viewBox: iconSource.svgViewBox,
109
+ strokeWidth: iconSource.svgStrokeWidth,
110
+ }
111
+ }
112
+ }
113
+ })
114
+ if (!label) return null
115
+ return {
116
+ item: {id, label, destructive, disabled, icon},
117
+ onSelect,
118
+ }
119
+ }
@@ -0,0 +1,26 @@
1
+ import {type ReactNode} from 'react'
2
+ import {type StyleProp, type ViewStyle} from 'react-native'
3
+
4
+ import {tag} from './registry'
5
+ import {type PreviewContent} from './types'
6
+
7
+ export type TriggerProps = {
8
+ preview?: PreviewContent
9
+ /** Fires when the user taps the expanded preview to "commit" into it. */
10
+ onPreviewPress?: () => void
11
+ /** Border radius of the thumbnail being wrapped. Used natively to clip the
12
+ * targeted-preview lift animation. */
13
+ borderRadius?: number
14
+ style?: StyleProp<ViewStyle>
15
+ children: ReactNode
16
+ }
17
+
18
+ /**
19
+ * Sentinel: does not render. `Root` reads props + children off this element
20
+ * and hosts `children` inside the native context-menu view.
21
+ */
22
+ function TriggerImpl(_: TriggerProps): null {
23
+ return null
24
+ }
25
+
26
+ export const Trigger = tag(TriggerImpl, 'trigger')
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export {Menu} from './Menu'
2
+ export {MenuItem} from './MenuItem'
3
+ export {MenuItemIcon} from './MenuItemIcon'
4
+ export {MenuItemText} from './MenuItemText'
5
+ export {Root} from './Root'
6
+ export {Trigger} from './Trigger'
7
+ export type {
8
+ MenuItemIconSource,
9
+ MenuItemSpec,
10
+ PreviewContent,
11
+ SvgIconMeta,
12
+ } from './types'
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Marker keys and type tags shared between `Root`, `Trigger`, `Menu`, and
3
+ * `MenuItem*`. `Root` walks its children looking for these tags so the
4
+ * composition API doesn't rely on string component names or display names.
5
+ */
6
+ export const CONTEXT_MENU_KIND = '__ExpoBlueskyPeekMenuKind__'
7
+
8
+ export type ContextMenuKind =
9
+ | 'trigger'
10
+ | 'menu'
11
+ | 'item'
12
+ | 'item-icon'
13
+ | 'item-text'
14
+
15
+ export type TaggedComponent<P> = React.FunctionComponent<P> & {
16
+ [CONTEXT_MENU_KIND]: ContextMenuKind
17
+ }
18
+
19
+ export function tag<P>(
20
+ component: React.FunctionComponent<P>,
21
+ kind: ContextMenuKind,
22
+ ): TaggedComponent<P> {
23
+ ;(component as TaggedComponent<P>)[CONTEXT_MENU_KIND] = kind
24
+ return component as TaggedComponent<P>
25
+ }
26
+
27
+ export function kindOf(type: unknown): ContextMenuKind | undefined {
28
+ if (type && typeof type === 'function') {
29
+ return (type as TaggedComponent<unknown>)[CONTEXT_MENU_KIND]
30
+ }
31
+ return undefined
32
+ }