ReviewOS

also looking at this

stacks/stx

feat(native): share map tilt and colored pin components

#2083
Merged chris wants to merge codex/wildloop-native-map-components into main
6 files +115 -7

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/stx/src/native/components/primitives.tsmodified+27-4
Changes to packages/stx/src/native/components/primitives.ts
@@ -452,8 +452,27 @@ export interface ImageBackgroundProps extends ImageProps {
452452// Map Components
453453// ============================================================================
454454
455export interface MapCoordinate {
456 latitude: number
457 longitude: number
458}
459
460export interface MapTileOverlay {
461 /** HTTPS raster template containing {z}, {x}, and {y}. */
462 urlTemplate: string
463 /** Provider credit kept visible by the native host. */
464 attribution: string
465}
466
467export interface MapEdgePadding {
468 top?: number
469 right?: number
470 bottom?: number
471 left?: number
472}
473
455474export interface MapRegion {
456 center: { latitude: number; longitude: number }
475 center: MapCoordinate
457476 span: { latitudeDelta: number; longitudeDelta: number }
458477}
459478
@@ -463,9 +482,9 @@ export interface MapProps extends BaseProps {
463482 /** Initial region for maps without recorded overlay geometry. */
464483 initialRegion?: MapRegion
465484 /** A native raster basemap; credit remains visible on the map. */
466 tileOverlay?: { urlTemplate: string, attribution: string }
485 tileOverlay?: MapTileOverlay
467486 /** Logical points reserved for floating controls when fitting overlays. */
468 edgePadding?: { top?: number, right?: number, bottom?: number, left?: number }
487 edgePadding?: MapEdgePadding
469488 /** Fit the camera to all marker and polyline overlay geometry. */
470489 fitToBounds?: boolean
471490 /** Show the user dot only when the app already has location permission. */
@@ -473,6 +492,8 @@ export interface MapProps extends BaseProps {
473492 scrollEnabled?: boolean
474493 zoomEnabled?: boolean
475494 rotateEnabled?: boolean
495 /** Enable native two-finger tilt gestures. Programmatic cameraPitch is independent. */
496 pitchEnabled?: boolean
476497 mapType?: 'standard' | 'satellite' | 'hybrid' | 'terrain'
477498 showsCompass?: boolean
478499 showsScale?: boolean
@@ -480,7 +501,7 @@ export interface MapProps extends BaseProps {
480501 cameraPitch?: number
481502 /** Clockwise camera heading from north. */
482503 cameraHeading?: number
483 onPress?: (event: { nativeEvent: { latitude: number, longitude: number } }) => void
504 onPress?: (event: { nativeEvent: MapCoordinate }) => void
484505 /** Completed user camera gestures; programmatic camera changes do not echo. */
485506 onRegionChangeComplete?: (event: { nativeEvent: MapRegion & { heading: number, pitch: number } }) => void
486507 onMarkerPress?: (event: { nativeEvent: { id: string } }) => void
@@ -498,6 +519,8 @@ export interface MapMarkerProps extends BaseProps {
498519 geometry: number[]
499520 id: string
500521 title?: string
522 /** CSS color for a place pin or the position selected on an elevation profile. */
523 pinColor?: string
501524 /** Route-direction arrow, clockwise from north; absent uses a place marker. */
502525 bearing?: number
503526 onPress?: (event: { nativeEvent: { id: string } }) => void
packages/stx/src/native/index.tsmodified+3-0
Changes to packages/stx/src/native/index.ts
@@ -58,6 +58,9 @@ export type {
5858 MapPolylineProps,
5959 MapMarkerProps,
6060 MapRegion,
61 MapCoordinate,
62 MapTileOverlay,
63 MapEdgePadding,
6164 TextInputProps,
6265 ScrollViewProps,
6366 FlatListProps,
packages/stx/src/native/primitives/Map.stxmodified+2-1
Changes to packages/stx/src/native/primitives/Map.stx
@@ -1,4 +1,5 @@
1{{-- Map: a native map host with overlay children. --}}
1{{-- Map: native MapKit on iOS or the configured Craft native Android provider.
2 The compiler preserves typed geometry, camera props and overlay children. --}}
23<script server>
34const classes = $props.class ?? $props.className ?? ''
45</script>
packages/stx/src/native/prop-names.tsmodified+2-2
Changes to packages/stx/src/native/prop-names.ts
@@ -19,7 +19,7 @@ const NATIVE_PROP_NAMES = [
1919 'showsHorizontalScrollIndicator', 'showsVerticalScrollIndicator', 'statusBarTranslucent',
2020 'stickyHeaderIndices', 'testID', 'textContentType', 'thumbColor', 'thumbTintColor', 'titleColor', 'trackColor',
2121 'underlayColor', 'windowSize',
22 'initialRegion', 'tileOverlay', 'edgePadding', 'fitToBounds', 'showsUserLocation', 'zoomEnabled', 'rotateEnabled', 'mapType', 'strokeColor',
22 'initialRegion', 'tileOverlay', 'edgePadding', 'fitToBounds', 'showsUserLocation', 'zoomEnabled', 'rotateEnabled', 'pitchEnabled', 'mapType', 'strokeColor', 'pinColor',
2323 'showsCompass', 'showsScale', 'cameraPitch', 'cameraHeading',
2424 'autoScrollBottom', 'scrollKey',
2525 // A video floating in Picture in Picture, and one element across an
@@ -37,7 +37,7 @@ export const NATIVE_BOOLEAN_PROPS = new Set([
3737 'editable', 'hidesWhenStopped', 'horizontal', 'loop', 'multiline', 'muted', 'pagingEnabled', 'paused',
3838 'refreshing', 'scrollEnabled', 'secureTextEntry', 'selectTextOnFocus', 'showsHorizontalScrollIndicator',
3939 'showsVerticalScrollIndicator', 'transparent', 'visible',
40 'fitToBounds', 'showsUserLocation', 'zoomEnabled', 'rotateEnabled', 'showsCompass', 'showsScale',
40 'fitToBounds', 'showsUserLocation', 'zoomEnabled', 'rotateEnabled', 'pitchEnabled', 'showsCompass', 'showsScale',
4141 'pictureInPicture',
4242])
4343
packages/stx/test/native/map-components.test.tsadded+78-0
Changes to packages/stx/test/native/map-components.test.ts
@@ -0,0 +1,78 @@
1import { describe, expect, it } from 'bun:test'
2import path from 'node:path'
3import vm from 'node:vm'
4import { compileSharedScreenBundle } from '../../src/native/compiler/bundle'
5import { compileScreenSource } from '../../src/native/compiler/render-screen'
6import { createTempDir } from '../../src/temp-dir'
7
8describe('portable native map components', () => {
9 it('keeps native camera controls, provider credit, colored pins and direction signs through the component pipeline', async () => {
10 const { document } = await compileScreenSource(`<Map class="h-[320px] w-full rounded-[20px]" pitch-enabled="false" rotate-enabled="true" shows-compass shows-scale camera-pitch="30" camera-heading="90"
11 :tileOverlay="{urlTemplate:'https://example.org/{z}/{y}/{x}.png',attribution:'Test map data'}"
12 :edgePadding="{top:32,bottom:180,left:24,right:24}" fit-to-bounds>
13 <MapPolyline id="route" geometry="[37,-119,37.01,-119.01]" stroke-color="#24724b" stroke-width="5" />
14 <MapMarker id="start" geometry="[37,-119]" pin-color="#24724b" title="Trailhead" @press="selectMarker($event)" />
15 <MapMarker id="direction" geometry="[37.005,-119.005]" bearing="90" />
16 </Map>`, path.join(import.meta.dir, 'NativeMapComponents.stx'))
17 expect(document.root.type).toBe('Map')
18 expect(document.root.style).toMatchObject({ height: 320, width: '100%', borderRadius: 20 })
19 expect(document.root.props).toMatchObject({
20 pitchEnabled: false, rotateEnabled: true, showsCompass: true, showsScale: true, cameraPitch: 30, cameraHeading: 90, fitToBounds: true,
21 tileOverlay: { urlTemplate: 'https://example.org/{z}/{y}/{x}.png', attribution: 'Test map data' },
22 edgePadding: { top: 32, bottom: 180, left: 24, right: 24 },
23 })
24 const children = document.root.children.filter(child => typeof child !== 'string')
25 expect(children.map(child => child.type)).toEqual(['MapPolyline', 'MapMarker', 'MapMarker'])
26 expect(children[0].props).toMatchObject({ geometry: [37, -119, 37.01, -119.01], strokeColor: '#24724b', strokeWidth: 5 })
27 expect(children[1].props).toMatchObject({ pinColor: '#24724b', title: 'Trailhead' })
28 expect(children[1].events).toEqual({ onPress: 'selectMarker($event)' })
29 expect(children[2].props.bearing).toBe(90)
30 })
31
32 for (const platform of ['ios', 'android']) {
33 it(`updates the ${platform} map through native mutations without browser globals or a replacement screen`, async () => {
34 const directory = createTempDir('stx-native-map-')
35 const file = path.join(directory, 'MapScreen.stx')
36 await Bun.write(file, `<script client>
37const tilted = state(true)
38const pin = state('#24724b')
39const route = state([37,-119,37.01,-119.01])
40function updateMap() { tilted.set(false); pin.set('#2878c8'); route.set([37,-119,37.02,-119.02]) }
41</script>
42<View>
43 <Map class="h-[320px]" :pitchEnabled="tilted()" showsCompass>
44 <MapPolyline :geometry="route()" strokeColor="#24724b" strokeWidth="5" />
45 <MapMarker id="start" geometry="[37,-119]" :pinColor="pin()" />
46 </Map>
47 <Button @click="updateMap()">Update map</Button>
48</View>`)
49 const { code } = await compileSharedScreenBundle(file)
50 const sent: any[] = []
51 let receive: (message: string) => void = () => {}
52 vm.runInNewContext(code, {
53 console, Promise, Date, Map, Set, WeakMap, WeakSet, setTimeout, clearTimeout,
54 __stxNativeBridge: {
55 platform,
56 mutationProtocolVersion: 1,
57 postMessage: (raw: string) => sent.push(JSON.parse(raw)),
58 onMessage: (callback: typeof receive) => { receive = callback },
59 },
60 })
61 await Bun.sleep(0)
62 const root = sent.find(message => message.type === 'RENDER').payload.document
63 const [map, button] = root.children
64 const [line, marker] = map.children
65 const operations = () => sent.filter(message => message.type === 'MUTATE').flatMap(message => message.payload.operations)
66 expect(operations()).toContainEqual(expect.objectContaining({ id: map.id, patch: { props: { pitchEnabled: true } } }))
67 expect(operations()).toContainEqual(expect.objectContaining({ id: marker.id, patch: { props: { pinColor: '#24724b' } } }))
68 const handler = operations().find(operation => operation.id === button.id && operation.patch?.events?.onPress).patch.events.onPress
69 sent.length = 0
70 receive(JSON.stringify({ type: 'EVENT', payload: { handlerId: handler, nativeEvent: {} } }))
71 await Bun.sleep(0)
72 expect(operations()).toContainEqual(expect.objectContaining({ id: map.id, patch: { props: { pitchEnabled: false } } }))
73 expect(operations()).toContainEqual(expect.objectContaining({ id: marker.id, patch: { props: { pinColor: '#2878c8' } } }))
74 expect(operations()).toContainEqual(expect.objectContaining({ id: line.id, patch: { props: { geometry: [37, -119, 37.02, -119.02] } } }))
75 expect(sent.some(message => message.type === 'RENDER')).toBe(false)
76 })
77 }
78})