diff --git a/ios/MobNode.h b/ios/MobNode.h index 388196f0..ec830e07 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -235,8 +235,12 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, strong, nullable) NSDictionary *nativeViewProps; // full props dict forwarded to the factory -// Accessibility — set from the tap tag atom name; read by XCTest / ui_describe_all +// Accessibility — identifiers support test addressing; labels and disabled +// state describe composite controls such as tappable boxes. @property(nonatomic, copy, nullable) NSString *accessibilityId; +@property(nonatomic, copy, nullable) NSString *accessibilityLabel; +@property(nonatomic, copy, nullable) NSString *accessibilityRole; +@property(nonatomic) BOOL disabled; // Icon — logical name resolved to an SF Symbol on iOS / Material Symbol // on Android. textSize and textColor control glyph sizing + tint. @@ -271,7 +275,7 @@ NS_ASSUME_NONNULL_BEGIN // system-default box in practice), but a sheet's corners are visibly // square-vs-rounded, so `sheetCornerRadius` gets its own -1 sentinel // (unset — use the system default) instead. `sheetDetents` is the raw -// "medium"/"large" string list from Mob.Renderer — mapped to +// built-in strings or content-detent dictionaries from Mob.Renderer — mapped to // PresentationDetent by MobSheetView (see MobRootView.swift), not here, so // this header stays framework-agnostic. Indicator geometry defaults to -1 // (unset — use the system default indicator); Mob.UI.sheet's validation @@ -279,7 +283,7 @@ NS_ASSUME_NONNULL_BEGIN // for >= 0 is enough to know whether a complete custom indicator was // supplied. @property(nonatomic) CGFloat sheetCornerRadius; -@property(nonatomic, strong, nullable) NSArray *sheetDetents; +@property(nonatomic, strong, nullable) NSArray *sheetDetents; @property(nonatomic, strong, nullable) UIColor *dragIndicatorColor; @property(nonatomic) CGFloat dragIndicatorWidth; @property(nonatomic) CGFloat dragIndicatorHeight; diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index f37240d1..26a20068 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -641,6 +641,62 @@ private struct MobFrameTracker: ViewModifier { // The fixed-width path is what makes circular ring cells possible without // a dedicated primitive — set width: N, height: N, corner_radius: N/2, // border_color + border_width and the box renders as a ring. +// Tap wiring and accessibility semantics for a composite Box, kept out of +// MobBox's main modifier chain so the Swift type checker can cope. +private struct MobBoxSemantics: ViewModifier { + let node: MobNode + + /// True when the box stands in for a control rather than plain layout — + /// it carries a label, or the caller asked for a button role. Only then + /// is it right to collapse the subtree into one accessibility element; a + /// passive box with labelled children must keep them visible to VoiceOver. + private var isAccessibilityControl: Bool { + node.accessibilityLabel != nil || node.accessibilityRole == "button" + } + + // Only .isButton is set explicitly. SwiftUI's AccessibilityTraits has no + // .isNotEnabled member — the disabled trait is not something you add, it + // is what `.disabled(true)` below already publishes to VoiceOver. An + // explicit `.accessibilityAddTraits(.isNotEnabled)` does not compile. + private var traits: AccessibilityTraits { + node.accessibilityRole == "button" ? .isButton : [] + } + + func body(content: Content) -> some View { + content + // Branch on `onTap` presence only, never on `disabled`. `ifLet` is + // @ViewBuilder if/else, i.e. _ConditionalContent — flipping the + // branch gives SwiftUI a structurally different view and tears the + // subtree down. `disabled` is routinely toggled, so branching on it + // would drop a wrapped TextField's in-flight text and focus, and + // re-present a wrapped Sheet. The check moves inside the closure, + // where it costs nothing structurally. + .ifLet(node.onTap) { view, tap in + view.contentShape(Rectangle()).onTapGesture { + if !node.disabled { tap() } + } + } + // Collapse to a single accessibility element whenever this box is + // acting as a control. Traits added without collapsing land on + // every descendant instead, so a role-only box would announce each + // nested Text as its own button. + .ifLet(isAccessibilityControl ? () : nil) { view, _ in + view.accessibilityElement(children: .ignore) + } + .ifLet(node.accessibilityLabel) { view, label in + view.accessibilityLabel(label) + } + // One OptionSet, so no _ConditionalContent branch on `disabled`. + .accessibilityAddTraits(traits) + // .disabled, not .allowsHitTesting: allowsHitTesting(false) makes + // the view transparent to touches, so a disabled box used as a + // blocking overlay or dimmed backdrop would pass taps through to + // whatever sits behind it. .disabled blocks interaction in the + // subtree while still consuming the touch. + .disabled(node.disabled) + } +} + private struct MobBox: View { let node: MobNode @@ -683,10 +739,13 @@ private struct MobBox: View { lineWidth: node.borderWidth) .allowsHitTesting(false) ) - .ifLet(node.onTap) { view, tap in - view.contentShape(Rectangle()).onTapGesture { tap() } - } .mobGestures(node) + // Interaction + accessibility live in their own ViewModifier: folding + // them into this chain inline pushed it past SwiftUI's type-inference + // budget and the Swift build failed outright ("unable to type-check + // this expression in reasonable time"). Same reason MobBox itself was + // extracted from MobNodeView. + .modifier(MobBoxSemantics(node: node)) // (offset is applied uniformly by MobNodeView's body; not here) } } @@ -1416,10 +1475,52 @@ private struct MobSlider: View { // both fall out for free: a rerender with the sheet still present reuses // this state; a rerender without it tears the view (and its presentation) // down entirely. +// Measured intrinsic content height plus the bottom safe-area inset that +// applies *inside* the sheet. `.presentationDetents(.height(x))` sets the +// sheet's TOTAL height, but the content region is inset by the home indicator, +// so a detent of exactly the content height leaves the last rows under it and +// makes a sheet that should fit exactly scroll instead. +private struct MobSheetContentMetrics: Equatable { + var height: CGFloat = 0 + var bottomInset: CGFloat = 0 +} + +private struct MobSheetContentHeightKey: PreferenceKey { + static var defaultValue = MobSheetContentMetrics() + + static func reduce(value: inout MobSheetContentMetrics, nextValue: () -> MobSheetContentMetrics) { + let next = nextValue() + value = MobSheetContentMetrics( + height: max(value.height, next.height), + bottomInset: max(value.bottomInset, next.bottomInset) + ) + } +} + +// 0 means "root geometry not read yet". Distinguishing unknown from a real +// measurement matters: defaulting to a small number collapsed the first +// presentation of every content sheet to a hairline. +private struct MobAvailableSheetHeightKey: EnvironmentKey { + static let defaultValue: CGFloat = 0 +} + +private extension EnvironmentValues { + var mobAvailableSheetHeight: CGFloat { + get { self[MobAvailableSheetHeightKey.self] } + set { self[MobAvailableSheetHeightKey.self] = newValue } + } +} + private struct MobSheetView: View { let node: MobNode + @Environment(\.mobAvailableSheetHeight) private var availableHeight @State private var isPresented = true @State private var dismissSent = false + // nil until the content has actually been measured. A numeric sentinel + // here is what produced a 1pt sheet on first presentation: content can + // only be measured after the sheet is up, so the first detent was + // computed from the sentinel. + @State private var contentMetrics: MobSheetContentMetrics? var body: some View { Color.clear @@ -1435,8 +1536,33 @@ private struct MobSheetView: View { node.onDismiss?() } - @ViewBuilder - private var sheetContent: some View { + private var contentDetent: [String: Any]? { + node.sheetDetents?.compactMap { $0 as? [String: Any] } + .first { $0["type"] as? String == "content" } + } + + /// Ceiling for the sheet. `availableHeight` is 0 until the root has + /// reported geometry; treat that as "no ceiling known yet" rather than + /// clamping to it, so an unmeasured root can never collapse the sheet. + private var maximumHeight: CGFloat { + let configured = (contentDetent?["max_height"] as? NSNumber).map { CGFloat(truncating: $0) } + + switch (configured, availableHeight > 0) { + case let (.some(limit), true): return min(limit, availableHeight) + case let (.some(limit), false): return limit + case (.none, true): return availableHeight + case (.none, false): return .greatestFiniteMagnitude + } + } + + /// Total sheet height for the detent: measured content plus the sheet's + /// own bottom safe-area inset, capped. nil while unmeasured. + private var limitedContentHeight: CGFloat? { + guard let metrics = contentMetrics, metrics.height > 0 else { return nil } + return max(1, min(metrics.height + metrics.bottomInset, maximumHeight)) + } + + private var sheetBody: some View { VStack(spacing: 0) { ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) @@ -1444,6 +1570,46 @@ private struct MobSheetView: View { } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(node.paddingEdgeInsets) + } + + @ViewBuilder + private var sheetContent: some View { + Group { + if contentDetent != nil { + ScrollView(.vertical) { + sheetBody + .fixedSize(horizontal: false, vertical: true) + .background { + GeometryReader { geometry in + Color.clear.preference( + key: MobSheetContentHeightKey.self, + value: MobSheetContentMetrics( + height: geometry.size.height, + bottomInset: geometry.safeAreaInsets.bottom + ) + ) + } + } + } + .frame(maxHeight: maximumHeight) + .onPreferenceChange(MobSheetContentHeightKey.self) { measured in + // Ignore sub-point churn so a measurement that feeds the + // detent, which resizes the sheet, which re-measures, + // settles instead of oscillating. + let changed = + contentMetrics.map { + abs(measured.height - $0.height) > 0.5 + || abs(measured.bottomInset - $0.bottomInset) > 0.5 + } ?? true + + if changed, measured.height > 0 { + contentMetrics = measured + } + } + } else { + sheetBody + } + } // Screen readers should treat the sheet as a self-contained modal — // VoiceOver focus stays inside it until dismissed, matching // .presentationDetents/.sheet's own system-modal behavior. @@ -1470,13 +1636,25 @@ private struct MobSheetView: View { } private var detentSet: Set { - let requested = node.sheetDetents ?? ["medium", "large"] + if contentDetent != nil { + // Content can only be measured once the sheet is on screen, so the + // first evaluation has nothing to size against. Present at .medium + // for that one frame and switch to the exact height as soon as the + // measurement lands — the previous sentinel-based version resolved + // to .height(1) and flashed a hairline on every presentation. + guard let height = limitedContentHeight else { return [.medium] } + return [.height(height)] + } + + let builtInDetents = node.sheetDetents?.compactMap { $0 as? String } ?? [] var resolved: Set = [] - if requested.contains("medium") { resolved.insert(.medium) } - if requested.contains("large") { resolved.insert(.large) } - // Mob.UI.sheet/2 already validates :detents is a nonempty subset of - // [:medium, :large] — this fallback only matters for a hand-built - // node map that skipped that validation (e.g. `~MOB` sigil literal). + if builtInDetents.contains("medium") { resolved.insert(.medium) } + if builtInDetents.contains("large") { resolved.insert(.large) } + // Mob.Renderer normalizes :detents through Mob.UI.normalize_sheet_detents!/1 + // at the encode boundary, so an invalid list now raises before it ever + // reaches here rather than arriving as an unknown string. This fallback + // is therefore only reachable for a node whose detents key is absent + // entirely. return resolved.isEmpty ? [.medium, .large] : resolved } @@ -1555,6 +1733,12 @@ public struct MobRootView: View { // SwiftUI observation, which doesn't carry the animation context and // produces a default crossfade instead of the .move transition). @State private var currentNavVersion: Int = 0 + @State private var availableSheetHeight: CGFloat = 1 + + /// Share of the root's height a content-detent sheet may occupy at most. + /// Keeps a tall sheet visibly a sheet — parent still showing behind it — + /// rather than an unrecognisable full-screen cover. + private static let sheetHeightCeilingFraction: CGFloat = 0.9 public init() {} @@ -1601,6 +1785,24 @@ public struct MobRootView: View { .transition(.opacity) } } + // Live root height, published so a content-detent sheet can cap itself + // against real geometry rather than a guess. Read in a `.background` + // so it costs no layout, and `initial: true` so the first value lands + // without waiting for a resize. Re-fires on rotation, split view and + // Stage Manager resizes, which is what re-clamps a presented sheet. + // + // The ceiling is a fraction of the root rather than the whole thing: + // a content sheet that measures taller than the screen should still + // leave the parent visible behind it, the way .large does, instead of + // becoming an indistinguishable full-screen cover. + .background { + GeometryReader { geometry in + Color.clear.onChange(of: geometry.size.height, initial: true) { _, height in + availableSheetHeight = max(1, height * Self.sheetHeightCeilingFraction) + } + } + } + .environment(\.mobAvailableSheetHeight, availableSheetHeight) .ignoresSafeArea(.container, edges: [.bottom, .horizontal]) .onChange(of: model.rootVersion) { let t = model.transition diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 2cc4f205..9253e4d9 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -1248,6 +1248,21 @@ static void mob_send_change_float(int handle, double value) { if ([accessibilityId isKindOfClass:[NSString class]]) { node.accessibilityId = accessibilityId; } + + id accessibilityLabel = props[@"accessibility_label"]; + if ([accessibilityLabel isKindOfClass:[NSString class]]) { + node.accessibilityLabel = accessibilityLabel; + } + + id accessibilityRole = props[@"accessibility_role"]; + if ([accessibilityRole isKindOfClass:[NSString class]]) { + node.accessibilityRole = accessibilityRole; + } + + id disabled = props[@"disabled"]; + if ([disabled isKindOfClass:[NSNumber class]]) { + node.disabled = [disabled boolValue]; + } } NSArray *children = dict[@"children"]; diff --git a/lib/mob/renderer.ex b/lib/mob/renderer.ex index 20506704..8947c203 100644 --- a/lib/mob/renderer.ex +++ b/lib/mob/renderer.ex @@ -72,7 +72,7 @@ defmodule Mob.Renderer do require Logger - alias Mob.{Style, Theme} + alias Mob.{Style, Theme, UI} @default_nif :mob_nif @@ -370,6 +370,12 @@ defmodule Mob.Renderer do {:on_dismiss, {pid, tag}} when is_pid(pid) -> [{"on_dismiss", nif.register_tap({pid, tag})}] + {:detents, detents} -> + encoded_detents = + detents |> UI.normalize_sheet_detents!() |> Enum.map(&encode_sheet_detent/1) + + [{"detents", encoded_detents}] + # IME composition — fires for languages with multi-stage input (CJK, # Korean, Vietnamese, accent input). Phase atom is :began | :updating # | :committed | :cancelled. Apps that need commit-only behaviour @@ -662,6 +668,9 @@ defmodule Mob.Renderer do defp encode_native_value(v), do: v + defp encode_sheet_detent(%{} = detent), do: encode_native_config(detent) + defp encode_sheet_detent(detent) when is_atom(detent), do: Atom.to_string(detent) + # Encode one Mob.Canvas op map for the wire. The `:op` atom becomes a # string ("line"/"circle"/...) so the native dispatch is a string switch. # `:color` is resolved through the same two-step theme path as top-level diff --git a/lib/mob/screen_case.ex b/lib/mob/screen_case.ex index c4f80064..873a6e20 100644 --- a/lib/mob/screen_case.ex +++ b/lib/mob/screen_case.ex @@ -93,6 +93,11 @@ defmodule Mob.ScreenCase do # PascalCase tag per line, converted to the snake_case `:type` atom the same # way the sigil does). Plus `:native_view`, the runtime-only escape hatch that # plugin / custom components serialize to and which has no template tag. + # Same compile-time-read caveat as Mob.Sigil's @known_tags: declare the + # manifests external resources or a tag addition won't recompile this module. + @external_resource Application.app_dir(:mob, "priv/tags/ios.txt") + @external_resource Application.app_dir(:mob, "priv/tags/android.txt") + @renderable_types ( read = fn name -> path = Application.app_dir(:mob, "priv/tags/#{name}") diff --git a/lib/mob/sigil.ex b/lib/mob/sigil.ex index 9192f903..f10c03fb 100644 --- a/lib/mob/sigil.ex +++ b/lib/mob/sigil.ex @@ -84,6 +84,14 @@ defmodule Mob.Sigil do # ── Whitelist ──────────────────────────────────────────────────────────────── + # Read at compile time into a module attribute, so the manifests have to be + # declared external resources or editing them recompiles nothing. Without + # this, adding a tag leaves anyone with a warm _build — every path-dep + # consumer, which is the standard mob dev setup — with the sigil still + # rejecting the new tag until a manual `mix clean`. + @external_resource Application.app_dir(:mob, "priv/tags/ios.txt") + @external_resource Application.app_dir(:mob, "priv/tags/android.txt") + @known_tags ( ios_file = Application.app_dir(:mob, "priv/tags/ios.txt") android_file = Application.app_dir(:mob, "priv/tags/android.txt") diff --git a/lib/mob/ui.ex b/lib/mob/ui.ex index 0656e05c..92dadca0 100644 --- a/lib/mob/ui.ex +++ b/lib/mob/ui.ex @@ -281,9 +281,27 @@ defmodule Mob.UI do ## Props - * `:detents` — nonempty, duplicate-free subset of `[:medium, :large]`. - Defaults to `[:medium, :large]`. `:medium` alone rejects expansion - to full height; `:large` alone skips the half-height stop. + * `:detents` — nonempty, duplicate-free subset of `[:medium, :large]`, + or the exclusive content-height detent `[:content]` / + `[{:content, max_height: number}]`. Defaults to `[:medium, :large]`. + `:medium` alone rejects expansion to full height; `:large` alone skips + the half-height stop. A content detent wraps intrinsic content and caps + overflow in an internally scrolling body. + + Two things to know about content detents. They size from the content's + *intrinsic* height, so a scrollable child (`scroll`, `lazy_list`) reports + its full content height rather than a viewport height — it will expand + inside the sheet and the sheet's own scroll takes the gesture, instead of + the child scrolling independently. Use `:medium`/`:large` when the sheet's + body is itself scrollable. And the sheet only knows its content height + once it is on screen, so it presents at `:medium` for the first frame and + resizes to the measured height immediately after. + + Invalid `:detents` raise. `Mob.Renderer` re-validates at the encode + boundary through `normalize_sheet_detents!/1`, so a hand-built or `~MOB` + sigil node cannot bypass this by skipping `sheet/2` — such a node now + raises during render rather than silently degrading to + `[:medium, :large]` on the native side. * `:on_dismiss` — `{pid, tag}`, delivered as `handle_info({:dismiss, tag}, socket)` exactly once when the sheet is dismissed (swipe-down, back gesture, or outside tap) — the same `{atom, tag}` wire shape as `on_focus`, @@ -337,8 +355,7 @@ defmodule Mob.UI do def sheet(children, opts) when is_list(opts), do: sheet(children, Map.new(opts)) def sheet(children, %{} = opts) do - detents = Map.get(opts, :detents, @sheet_detents) - validate_detents!(detents) + detents = opts |> Map.get(:detents, @sheet_detents) |> normalize_sheet_detents!() validate_on_dismiss!(Map.get(opts, :on_dismiss)) validate_style!(opts) validate_platform_override!(opts, :ios) @@ -352,24 +369,73 @@ defmodule Mob.UI do } end - defp validate_detents!(detents) do - unless is_list(detents) and detents != [] do - raise ArgumentError, - "Mob.UI.sheet :detents must be a nonempty list, got: #{inspect(detents)}" + @doc false + @spec normalize_sheet_detents!(term()) :: [atom() | map()] + def normalize_sheet_detents!([:content]), do: [%{type: :content}] + + def normalize_sheet_detents!([{:content, options}] = detents) when is_list(options) do + if Keyword.keyword?(options) do + case Keyword.fetch(options, :max_height) do + {:ok, maximum} when is_number(maximum) and maximum > 0 and length(options) == 1 -> + [%{type: :content, max_height: maximum}] + + _other -> + invalid_sheet_detents!( + detents, + "a :content detent takes exactly one option, :max_height, and it must be a positive number" + ) + end + else + invalid_sheet_detents!(detents, "a :content detent's options must be a keyword list") end + end - unless Enum.uniq(detents) == detents do - raise ArgumentError, - "Mob.UI.sheet :detents must not contain duplicates, got: #{inspect(detents)}" - end + def normalize_sheet_detents!([%{type: :content} = detent]) when map_size(detent) == 1, + do: [detent] + + def normalize_sheet_detents!([%{type: :content, max_height: maximum} = detent]) + when map_size(detent) == 2 and is_number(maximum) and maximum > 0, + do: [detent] + + # An already-normalized content map that matched neither valid shape above: + # extra keys, or a non-positive/non-numeric :max_height. Caught here so the + # error names the actual problem instead of falling through to the + # built-in-subset branch and claiming it should have been :medium/:large. + def normalize_sheet_detents!([%{type: :content}] = detents), + do: + invalid_sheet_detents!( + detents, + "a :content detent accepts only :type and an optional positive :max_height" + ) - unless Enum.all?(detents, &(&1 in @sheet_detents)) do - raise ArgumentError, - "Mob.UI.sheet :detents must be a subset of #{inspect(@sheet_detents)}, " <> - "got: #{inspect(detents)}" + def normalize_sheet_detents!(detents) when is_list(detents) and detents != [] do + cond do + not Enum.all?(detents, &(&1 in @sheet_detents)) -> + invalid_sheet_detents!( + detents, + "must be a subset of #{inspect(@sheet_detents)}, or a single exclusive " <> + ":content detent" + ) + + length(detents) != length(Enum.uniq(detents)) -> + invalid_sheet_detents!(detents, "must not contain duplicates") + + true -> + detents end end + def normalize_sheet_detents!(detents), + do: invalid_sheet_detents!(detents, "must be a nonempty list") + + # Each failure names the rule it broke and echoes the offending value. A + # single catch-all message reads fine to whoever wrote the validation and + # badly to whoever tripped it — `[:medium, :medium]` should say + # "duplicates", not restate the whole grammar. + defp invalid_sheet_detents!(detents, reason) do + raise ArgumentError, "Mob.UI.sheet :detents #{reason}, got: #{inspect(detents)}" + end + defp validate_on_dismiss!(nil), do: :ok defp validate_on_dismiss!({pid, tag}) when is_pid(pid) and is_atom(tag), do: :ok diff --git a/priv/tags/android.txt b/priv/tags/android.txt index 06fbf07c..9ad10ffb 100644 --- a/priv/tags/android.txt +++ b/priv/tags/android.txt @@ -14,6 +14,7 @@ List Progress Row Scroll +Sheet Slider Spacer TabBar diff --git a/priv/tags/ios.txt b/priv/tags/ios.txt index 0380a373..5c292af6 100644 --- a/priv/tags/ios.txt +++ b/priv/tags/ios.txt @@ -13,6 +13,7 @@ List Progress Row Scroll +Sheet Slider Spacer TabBar diff --git a/test/mob/native_box_accessibility_test.exs b/test/mob/native_box_accessibility_test.exs new file mode 100644 index 00000000..394d37b2 --- /dev/null +++ b/test/mob/native_box_accessibility_test.exs @@ -0,0 +1,56 @@ +# These source-contract tests guard native SwiftUI behavior that Elixir cannot execute. +# credo:disable-for-this-file Jump.CredoChecks.VacuousTest +defmodule Mob.NativeBoxAccessibilityTest do + use ExUnit.Case, async: true + + @root Path.expand("../..", __DIR__) + + test "iOS parses box accessibility state at the native boundary" do + header = File.read!(Path.join(@root, "ios/MobNode.h")) + nif = File.read!(Path.join(@root, "ios/mob_nif.m")) + + assert header =~ "NSString *accessibilityLabel" + assert header =~ "NSString *accessibilityRole" + assert header =~ "BOOL disabled" + assert nif =~ ~s|props[@"accessibility_label"]| + assert nif =~ "node.accessibilityLabel = accessibilityLabel" + assert nif =~ ~s|props[@"accessibility_role"]| + assert nif =~ "node.accessibilityRole = accessibilityRole" + assert nif =~ ~s|props[@"disabled"]| + assert nif =~ "node.disabled = [disabled boolValue]" + end + + test "iOS box exposes one labeled action and suppresses disabled input" do + source = File.read!(Path.join(@root, "ios/MobRootView.swift")) + + assert source =~ ".accessibilityElement(children: .ignore)" + assert source =~ "view.accessibilityLabel(label)" + + # Collapse to one element for a label OR an explicit button role. Traits + # added without collapsing land on every descendant, so a role-only box + # would announce each nested Text as its own button. + assert source =~ "node.accessibilityLabel != nil || node.accessibilityRole == \"button\"" + + # Kept in its own ViewModifier: inlining these into MobBox's chain pushed + # it past the Swift type-inference budget and failed the native build, + # which no Elixir-side check can catch. + assert source =~ "struct MobBoxSemantics: ViewModifier" + assert source =~ "isAccessibilityControl ? () : nil" + + # Traits go on as one unconditional OptionSet modifier. Branching on + # `disabled` would make it a _ConditionalContent boundary and tear down the + # subtree on every toggle, losing a wrapped TextField's text and focus. + assert source =~ ".accessibilityAddTraits(traits)" + + # .disabled, not .allowsHitTesting: the latter makes the box transparent + # to touches, so a disabled backdrop would pass taps through to the + # content it is meant to be shielding. + assert source =~ ".disabled(node.disabled)" + refute source =~ ".allowsHitTesting(!node.disabled)" + + # Tap wiring branches on handler presence only; the disabled check is + # inside the closure so toggling it is not a structural change. + assert source =~ ".ifLet(node.onTap)" + assert source =~ "if !node.disabled { tap() }" + end +end diff --git a/test/mob/native_sheet_test.exs b/test/mob/native_sheet_test.exs new file mode 100644 index 00000000..32e42d22 --- /dev/null +++ b/test/mob/native_sheet_test.exs @@ -0,0 +1,43 @@ +# These source-contract tests guard native SwiftUI behavior that Elixir cannot execute. +# credo:disable-for-this-file Jump.CredoChecks.VacuousTest +defmodule Mob.NativeSheetTest do + use ExUnit.Case, async: true + + @ios Path.expand("../../ios", __DIR__) + + test "SwiftUI sheet preserves built-ins and measures content detents" do + source = File.read!(Path.join(@ios, "MobRootView.swift")) + + assert source =~ "MobSheetContentHeightKey" + assert source =~ "builtInDetents.contains(\"medium\")" + assert source =~ "builtInDetents.contains(\"large\")" + # Unmeasured content must fall back to a real system detent, never to a + # computed sentinel — .height(1) flashed a hairline on every presentation. + assert source =~ "guard let height = limitedContentHeight else { return [.medium] }" + assert source =~ ".height(height)" + assert source =~ ".onPreferenceChange(MobSheetContentHeightKey.self)" + assert source =~ "ScrollView(.vertical)" + end + + test "content detent reclamps against live sheet geometry" do + source = File.read!(Path.join(@ios, "MobRootView.swift")) + + assert source =~ "GeometryReader { geometry in" + + # Pin the mechanism, not the arithmetic. Asserting the literal + # `max(1, height * 0.9)` made naming the fraction a test failure even + # though behaviour was identical — these assertions should break when the + # re-clamp stops working, not when someone extracts a constant. + assert source =~ "onChange(of: geometry.size.height, initial: true)" + assert source =~ "availableSheetHeight = max(1, height * Self.sheetHeightCeilingFraction)" + assert source =~ "static let sheetHeightCeilingFraction" + assert source =~ ".environment(\\.mobAvailableSheetHeight, availableSheetHeight)" + + # The detent is total sheet height, so the content's own bottom safe-area + # inset has to be part of it or the last rows sit under the home indicator. + assert source =~ "metrics.height + metrics.bottomInset" + assert source =~ "safeAreaInsets.bottom" + assert source =~ "contentMetrics = measured" + refute source =~ "UIScreen.main.bounds.height * 0.9" + end +end diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs index 7a725d6c..a2df69e6 100644 --- a/test/mob/renderer_test.exs +++ b/test/mob/renderer_test.exs @@ -104,6 +104,27 @@ defmodule Mob.RendererTest do assert decoded["props"]["text"] == "Hello" end + test "box accessibility and disabled props survive the renderer boundary" do + tree = %{ + type: :box, + props: %{ + accessibility_label: "Open Acme company", + accessibility_role: :button, + disabled: true + }, + children: [] + } + + Renderer.render(tree, :ios, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + props = :json.decode(json)["props"] + + assert props["accessibility_label"] == "Open Acme company" + assert props["accessibility_role"] == "button" + assert props["disabled"] == true + refute Map.has_key?(props, "on_tap") + end + test "JSON contains nested children" do tree = %{ type: :column, @@ -1284,9 +1305,34 @@ defmodule Mob.RendererTest do assert Enum.at(decoded_children, 1)["props"]["text"] == "b" end - test "detents serialize as a list of strings" do - Renderer.render(sheet_tree(%{detents: [:medium]}), :android, MockNIF) - assert set_root_json()["props"]["detents"] == ["medium"] + test "detents serialize in canonical built-in and content forms" do + for {detents, expected} <- [ + {[:medium], ["medium"]}, + {[:large, :medium], ["large", "medium"]}, + {[:content], [%{"type" => "content"}]}, + {[{:content, max_height: 240}], [%{"type" => "content", "max_height" => 240}]}, + {[%{type: :content}], [%{"type" => "content"}]}, + {[%{type: :content, max_height: 320}], [%{"type" => "content", "max_height" => 320}]} + ] do + MockNIF.reset() + Renderer.render(sheet_tree(%{detents: detents}), :android, MockNIF) + assert set_root_json()["props"]["detents"] == expected + end + end + + test "renderer rejects invalid raw detent nodes" do + for detents <- [ + [:content, :medium], + [%{type: :content, max_height: -1}], + [%{type: :content, unknown: true}], + :content, + [{:content, :not_options}], + [%{type: :bogus}] + ] do + assert_raise ArgumentError, ~r/:detents/, fn -> + Renderer.render(sheet_tree(%{detents: detents}), :android, MockNIF) + end + end end test "on_dismiss registers through the tap registry and serializes as an integer handle" do diff --git a/test/mob/sigil_test.exs b/test/mob/sigil_test.exs index 4fbff475..03526dc5 100644 --- a/test/mob/sigil_test.exs +++ b/test/mob/sigil_test.exs @@ -242,6 +242,16 @@ defmodule Mob.SigilTest do node = ~MOB() assert node.type == :gpu_view end + + test "Sheet preserves a typed content detent expression" do + detents = [{:content, max_height: 480}] + + node = ~MOB( + +) + + assert node.props.detents == detents + end end # ── parity with raw maps ───────────────────────────────────────────────────── diff --git a/test/mob/ui_test.exs b/test/mob/ui_test.exs index 3fa5ed22..90d2a31b 100644 --- a/test/mob/ui_test.exs +++ b/test/mob/ui_test.exs @@ -255,6 +255,17 @@ defmodule Mob.UITest do end describe "sheet/2 detents" do + test "normalizes content detents with an optional maximum height" do + assert UI.sheet(UI.text(text: "hi"), detents: [:content]).props.detents == [ + %{type: :content} + ] + + assert UI.sheet(UI.text(text: "hi"), detents: [{:content, max_height: 480}]).props.detents == + [ + %{type: :content, max_height: 480} + ] + end + test "accepts [:medium]" do assert UI.sheet(UI.text(text: "hi"), detents: [:medium]).props.detents == [:medium] end @@ -270,27 +281,21 @@ defmodule Mob.UITest do ] end - test "rejects an empty list" do - assert_raise ArgumentError, ~r/nonempty/, fn -> - UI.sheet(UI.text(text: "hi"), detents: []) - end - end - - test "rejects duplicates" do - assert_raise ArgumentError, ~r/duplicates/, fn -> - UI.sheet(UI.text(text: "hi"), detents: [:medium, :medium]) - end - end - - test "rejects a detent outside [:medium, :large]" do - assert_raise ArgumentError, ~r/subset/, fn -> - UI.sheet(UI.text(text: "hi"), detents: [:medium, :full]) - end - end - - test "rejects a non-list" do - assert_raise ArgumentError, ~r/nonempty list/, fn -> - UI.sheet(UI.text(text: "hi"), detents: :medium) + test "rejects invalid built-in and content detents" do + for detents <- [ + [], + [:medium, :medium], + [:content, :medium], + [:full], + [{:content, max_height: 0}], + [{:content, max_height: "480"}], + [{:content, max_height: 480, unknown: true}], + [%{type: :content, unknown: true}], + :medium + ] do + assert_raise ArgumentError, ~r/:detents/, fn -> + UI.sheet(UI.text(text: "hi"), detents: detents) + end end end end