also looking at this
fix(native): interaction, audio, document and checkpoint gaps stranded after #401
#415
19 files
+987
-50
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.
| @@ -157,7 +157,7 @@ and linear gradients are applied by UIKit with the same style keys as Android; | ||
| 157 | 157 | `elevation` remains Android-specific. Gradient layers follow the native view's |
| 158 | 158 | bounds and corners through layout changes. |
| 159 | 159 | |
| 160 | The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md). | |
| 160 | The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. Dynamic `craft.shortcuts.set`/`clear` calls on an all-native screen resolve `false` (the typed `unsupported` result); configure static `appShortcuts` in `craft.config.json` when the app shell should publish home-screen quick actions. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md). | |
| 161 | 161 | |
| 162 | 162 | `FlatList` rows are authored as keyed `:for` child rows in native STX; the |
| 163 | 163 | maintained protocol does not serialize `data`/`renderItem` callbacks. |
| @@ -2941,6 +2941,28 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 2941 | 2941 | XCTAssertTrue(disabled.accessibilityTraits.contains(.notEnabled)) |
| 2942 | 2942 | } |
| 2943 | 2943 | |
| 2944 | func testNestedPressRecognizersGiveTheInnermostActionFirstRefusal() throws { | |
| 2945 | let controller = CraftNativeScreenController(config: CraftConfig()) | |
| 2946 | controller.loadViewIfNeeded() | |
| 2947 | controller.render(document([[ | |
| 2948 | "type": "Pressable", "props": ["key": "outer"], "events": ["onPress": "outerPressed"], | |
| 2949 | "children": [[ | |
| 2950 | "type": "Pressable", "props": ["key": "inner"], "events": ["onPress": "innerPressed"], | |
| 2951 | "children": [["type": "Text", "props": ["key": "label"], "children": ["Tap"]]], | |
| 2952 | ]], | |
| 2953 | ]])) | |
| 2954 | ||
| 2955 | let outer = try XCTUnwrap(find(UIView.self, key: "outer", below: controller.view)) | |
| 2956 | let inner = try XCTUnwrap(find(UIView.self, key: "inner", below: controller.view)) | |
| 2957 | let outerTap = try XCTUnwrap(outer.gestureRecognizers?.first { $0 is UITapGestureRecognizer }) | |
| 2958 | let innerTap = try XCTUnwrap(inner.gestureRecognizers?.first { $0 is UITapGestureRecognizer }) | |
| 2959 | let delegate = try XCTUnwrap(outerTap.delegate as? CraftPressGestureDelegate) | |
| 2960 | XCTAssertTrue(innerTap.delegate === CraftPressGestureDelegate.shared) | |
| 2961 | XCTAssertTrue(delegate.gestureRecognizer(outerTap, shouldRequireFailureOf: innerTap)) | |
| 2962 | XCTAssertFalse(delegate.gestureRecognizer(innerTap, shouldRequireFailureOf: outerTap)) | |
| 2963 | XCTAssertFalse(outerTap.cancelsTouchesInView) | |
| 2964 | } | |
| 2965 | ||
| 2944 | 2966 | func testModalCloseRequestAndDismissAreIndependentVisibilityEvents() throws { |
| 2945 | 2967 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 2946 | 2968 | controller.loadViewIfNeeded() |
| @@ -3492,6 +3514,34 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 3492 | 3514 | XCTAssertEqual(authored.accessibilityContainerType, .none) |
| 3493 | 3515 | } |
| 3494 | 3516 | |
| 3517 | func testLabelledModalAndDrawerExposeTheirInteractiveChildren() throws { | |
| 3518 | let controller = CraftNativeScreenController(config: CraftConfig()) | |
| 3519 | controller.loadViewIfNeeded() | |
| 3520 | controller.render(document([ | |
| 3521 | [ | |
| 3522 | "type": "Modal", | |
| 3523 | "props": ["key": "labelled-modal", "visible": true, "accessibilityLabel": "Outer dialog"], | |
| 3524 | "children": [["type": "Button", "props": ["key": "modal-action"], "children": ["Open nested"]]], | |
| 3525 | ], | |
| 3526 | [ | |
| 3527 | "type": "Drawer", | |
| 3528 | "props": ["key": "labelled-drawer", "accessibilityLabel": "Filters"], | |
| 3529 | "children": [["type": "Button", "props": ["key": "drawer-action"], "children": ["Apply"]]], | |
| 3530 | ], | |
| 3531 | ])) | |
| 3532 | ||
| 3533 | let modal = try XCTUnwrap(find(UIView.self, key: "labelled-modal", below: controller.view)) | |
| 3534 | let drawer = try XCTUnwrap(find(UIView.self, key: "labelled-drawer", below: controller.view)) | |
| 3535 | XCTAssertFalse(modal.isAccessibilityElement) | |
| 3536 | XCTAssertFalse(drawer.isAccessibilityElement) | |
| 3537 | XCTAssertEqual(modal.accessibilityLabel, "Outer dialog") | |
| 3538 | XCTAssertEqual(drawer.accessibilityLabel, "Filters") | |
| 3539 | XCTAssertEqual(modal.accessibilityContainerType, .semanticGroup) | |
| 3540 | XCTAssertEqual(drawer.accessibilityContainerType, .semanticGroup) | |
| 3541 | XCTAssertTrue(try XCTUnwrap(find(UIButton.self, key: "modal-action", below: modal)).isAccessibilityElement) | |
| 3542 | XCTAssertTrue(try XCTUnwrap(find(UIButton.self, key: "drawer-action", below: drawer)).isAccessibilityElement) | |
| 3543 | } | |
| 3544 | ||
| 3495 | 3545 | func testSwitchAccessibilityMatchesUIKitAcrossValueAndEnabledChanges() throws { |
| 3496 | 3546 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 3497 | 3547 | controller.loadViewIfNeeded() |
| @@ -1611,6 +1611,16 @@ describe('Craft iOS builder', () => { | ||
| 1611 | 1611 | expect(screen).toContain('is not available on a native screen') |
| 1612 | 1612 | }) |
| 1613 | 1613 | |
| 1614 | it('exposes the native shortcut module as an explicit unsupported result', async () => { | |
| 1615 | const output = createTempDir('craft-ios-native-shortcuts-') | |
| 1616 | await init({ runtimeDir: null, name: 'NativeShortcuts', bundleId: 'org.example.nativeshortcuts', output, config: { renderer: 'native' } as any }) | |
| 1617 | const screen = readFileSync(join(output, 'Sources', 'CraftNativeScreen.swift'), 'utf8') | |
| 1618 | expect(screen).toContain("keep('shortcuts', {") | |
| 1619 | expect(screen).toContain('set: function() { return Promise.resolve(false); }') | |
| 1620 | expect(screen).toContain('clear: function() { return Promise.resolve(false); }') | |
| 1621 | expect(screen).toContain('onShortcut: function() { return function() {}; }') | |
| 1622 | }) | |
| 1623 | ||
| 1614 | 1624 | it('keeps generated iOS media limits strict and finite', async () => { |
| 1615 | 1625 | const output = createTempDir('craft-ios-native-media-numbers-') |
| 1616 | 1626 | await init({ runtimeDir: null, name: 'NativeMediaNumbers', bundleId: 'org.example.nativemedianumbers', output }) |
| @@ -1751,11 +1761,11 @@ describe('Craft iOS builder', () => { | ||
| 1751 | 1761 | expect(swift).toContain('field.borderStyle = style["borderWidth"] != nil || style["backgroundColor"] != nil ? .none : .roundedRect') |
| 1752 | 1762 | }) |
| 1753 | 1763 | |
| 1754 | it('keeps a labelled scroll view, picker or group a container, so VoiceOver reads what it holds', () => { | |
| 1764 | it('keeps labelled scroll views, pickers, modals, drawers and groups as containers', () => { | |
| 1755 | 1765 | // A tour pager labelled for VoiceOver became one element and hid its pages. |
| 1756 | 1766 | const swift = readFileSync(join(import.meta.dir, '..', 'templates', 'CraftNativeScreen.swift'), 'utf8') |
| 1757 | 1767 | const a11y = swift.slice(swift.indexOf('private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView)')) |
| 1758 | expect(a11y).toContain('let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)') | |
| 1768 | expect(a11y).toContain('let container = ["ScrollView", "FlatList", "SectionList", "Picker", "Modal", "Drawer"].contains(type)') | |
| 1759 | 1769 | expect(a11y).toContain('view.isAccessibilityElement = !container && role != "none"') |
| 1760 | 1770 | expect(a11y).toContain('view.accessibilityContainerType = container && (view.accessibilityLabel != nil || controlledVideo) ? .semanticGroup : .none') |
| 1761 | 1771 | expect(a11y).toContain('case "tablist": traits.insert(.tabBar)') |
| @@ -4155,6 +4155,40 @@ final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate { | ||
| 4155 | 4155 | } |
| 4156 | 4156 | } |
| 4157 | 4157 | |
| 4158 | /// A pressable wrapper must yield to an actionable descendant. Without this | |
| 4159 | /// ordering UIKit may let the ancestor's tap recognizer win the same touch, | |
| 4160 | /// so a nested Button never receives its physical action. A descendant | |
| 4161 | /// control or tap recognizer is the source of the event; STX handles the | |
| 4162 | /// logical bubbling from there. | |
| 4163 | final class CraftPressGestureDelegate: NSObject, UIGestureRecognizerDelegate { | |
| 4164 | static let shared = CraftPressGestureDelegate() | |
| 4165 | ||
| 4166 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { | |
| 4167 | guard let parent = gestureRecognizer.view, | |
| 4168 | let child = otherGestureRecognizer.view, | |
| 4169 | child !== parent else { return false } | |
| 4170 | return child.isDescendant(of: parent) | |
| 4171 | } | |
| 4172 | ||
| 4173 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { | |
| 4174 | guard let container = gestureRecognizer.view else { return true } | |
| 4175 | // UIKit can report an internal UIButton label as `touch.view` while | |
| 4176 | // the recognizer is attached to an ancestor. Resolve the hit-test | |
| 4177 | // path from the container as well so that the ancestor never claims | |
| 4178 | // a control's physical tap (the child action will bubble in STX). | |
| 4179 | let hitView = container.hitTest(touch.location(in: container), with: nil) | |
| 4180 | guard var candidate = hitView ?? touch.view else { return true } | |
| 4181 | while candidate !== container { | |
| 4182 | if candidate is UIControl { return false } | |
| 4183 | if candidate.gestureRecognizers?.contains(where: { $0 !== gestureRecognizer && $0 is UITapGestureRecognizer }) == true { | |
| 4184 | return false | |
| 4185 | } | |
| 4186 | candidate = candidate.superview ?? container | |
| 4187 | } | |
| 4188 | return true | |
| 4189 | } | |
| 4190 | } | |
| 4191 | ||
| 4158 | 4192 | /// The delegate of a Pressable's press-in/out feedback. That recognizer is |
| 4159 | 4193 | /// a long press of no duration, so it begins the moment a finger lands; on its |
| 4160 | 4194 | /// own it would claim the touch, and the tap that is the Pressable's onPress |
| @@ -6562,6 +6596,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 6562 | 6596 | keep('navigation', { |
| 6563 | 6597 | setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); } |
| 6564 | 6598 | }); |
| 6599 | // Dynamic home-screen quick actions belong to the app shell, | |
| 6600 | // not an individual JavaScriptCore screen. Keep the typed | |
| 6601 | // module present so STX can answer `unsupported` honestly | |
| 6602 | // instead of falling through to an undefined namespace. | |
| 6603 | keep('shortcuts', { | |
| 6604 | set: function() { return Promise.resolve(false); }, | |
| 6605 | clear: function() { return Promise.resolve(false); }, | |
| 6606 | onShortcut: function() { return function() {}; } | |
| 6607 | }); | |
| 6565 | 6608 | })(); |
| 6566 | 6609 | """) |
| 6567 | 6610 | } |
| @@ -8965,6 +9008,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 8965 | 9008 | let actionable = handler != nil || linkTargets[id] != nil |
| 8966 | 9009 | if actionable, tapRecognizers[id] == nil { |
| 8967 | 9010 | let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:))) |
| 9011 | recognizer.cancelsTouchesInView = false | |
| 9012 | recognizer.delegate = CraftPressGestureDelegate.shared | |
| 8968 | 9013 | tapRecognizers[id] = recognizer |
| 8969 | 9014 | view.addGestureRecognizer(recognizer) |
| 8970 | 9015 | } else if !actionable, let recognizer = tapRecognizers.removeValue(forKey: id) { |
| @@ -9836,7 +9881,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 9836 | 9881 | let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true |
| 9837 | 9882 | // UIPickerView exposes adjustable component wheels. Making the |
| 9838 | 9883 | // picker itself a leaf hides those wheels and their selected values. |
| 9839 | let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type) | |
| 9884 | let container = ["ScrollView", "FlatList", "SectionList", "Picker", "Modal", "Drawer"].contains(type) | |
| 9840 | 9885 | || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "") |
| 9841 | 9886 | || controlledVideo |
| 9842 | 9887 | view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type)) |
| @@ -10240,6 +10285,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 10240 | 10285 | |
| 10241 | 10286 | @objc private func viewPressed(_ sender: UITapGestureRecognizer) { |
| 10242 | 10287 | guard let view = sender.view else { return } |
| 10288 | // UIKit may still deliver an ancestor recognizer after the delegate | |
| 10289 | // has negotiated with a control's touch recognizer. The child control | |
| 10290 | // owns that physical tap; STX will perform logical bubbling from its | |
| 10291 | // event, so do not send the ancestor handler a second time here. | |
| 10292 | if let hit = view.hitTest(sender.location(in: view), with: nil), hit !== view { | |
| 10293 | var candidate: UIView? = hit | |
| 10294 | while let current = candidate, current !== view { | |
| 10295 | if current is UIControl { return } | |
| 10296 | if current.gestureRecognizers?.contains(where: { $0 !== sender && $0 is UITapGestureRecognizer }) == true { return } | |
| 10297 | candidate = current.superview | |
| 10298 | } | |
| 10299 | } | |
| 10243 | 10300 | let id = ObjectIdentifier(view) |
| 10244 | 10301 | if let target = linkTargets[id] { |
| 10245 | 10302 | hybridEvents?.nativeScreen(self, open: target, props: [:]) |