diff --git a/build.zig b/build.zig
index 49785b4d..a76a72e7 100644
--- a/build.zig
+++ b/build.zig
@@ -1458,6 +1458,13 @@ pub fn build(b: *std.Build) void {
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkTextNavigationNeedsRawKeyEvent(event)" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSEventModifierFlagCommand | NSEventModifierFlagOption" },
});
+ addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-widget-accessibility-hierarchy", "Verify AppKit preserves retained-widget accessibility parentage", &.{
+ .{ .path = "src/platform/macos/appkit_host.h", .pattern = "uint64_t parent_id;" },
+ .{ .path = "src/platform/macos/root.zig", .pattern = ".parent_id = node.parent_id orelse 0" },
+ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "element.accessibilityParent = parent;" },
+ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "parent.accessibilityChildren = [childrenByParentId objectForKey:parentId];" },
+ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "return self.widgetAccessibilityRootElements ?: @[];" },
+ });
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-appearance-bridge", "Verify AppKit reports system light and dark appearance changes", &.{
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "effectiveAppearance" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "accessibilityDisplayShouldReduceMotion" },
@@ -1813,13 +1820,20 @@ pub fn build(b: *std.Build) void {
});
addFileContainsCheckStep(b, file_contains_checker, mobile_examples_step, "test-example-mobile-widget-abi", "Verify mobile examples use stable widget ABI lookups", &.{
.{ .path = "examples/ios/NativeSdkIOSExample/native_sdk.h", .pattern = "native_sdk_viewport_state_t" },
+ .{ .path = "examples/ios/NativeSdkIOSExample/native_sdk.h", .pattern = "NATIVE_SDK_WIDGET_ROLE_RADIOGROUP = 21" },
.{ .path = "examples/ios/NativeSdkIOSExample/native_sdk.h", .pattern = "native_sdk_app_scroll" },
.{ .path = "examples/ios/NativeSdkIOSExample/native_sdk.h", .pattern = "native_sdk_app_set_text_measure" },
+ .{ .path = "examples/android/app/src/main/cpp/native_sdk.h", .pattern = "NATIVE_SDK_WIDGET_ROLE_RADIOGROUP = 21" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk.h", .pattern = "native_sdk_app_set_text_measure" },
.{ .path = "examples/mobile-canvas/ios/native_sdk_app.h", .pattern = "native_sdk_app_set_text_measure" },
.{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "native_sdk_app_widget_semantics_by_id" },
+ .{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "NATIVE_SDK_WIDGET_ROLE_RADIO" },
+ .{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "childrenByParentId[node.parentId, default: []].append(element)" },
+ .{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "parent.accessibilityContainerType = .semanticGroup" },
+ .{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "parent.isAccessibilityElement = false" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk.h", .pattern = "native_sdk_app_widget_semantics_by_id" },
.{ .path = "examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt", .pattern = "nativeScroll(nativeApp" },
+ .{ .path = "examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt", .pattern = "WIDGET_ROLE_RADIOGROUP -> \"android.widget.RadioGroup\"" },
.{ .path = "examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt", .pattern = "nativeWidgetSemanticsByIdFields" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk_jni.c", .pattern = "native_sdk_app_widget_semantics_by_id" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk_jni.c", .pattern = "native_sdk_app_scroll" },
diff --git a/docs/src/app/docs/components/radio/page.mdx b/docs/src/app/docs/components/radio/page.mdx
index e7b9a881..952ec2cb 100644
--- a/docs/src/app/docs/components/radio/page.mdx
+++ b/docs/src/app/docs/components/radio/page.mdx
@@ -4,16 +4,18 @@ import { CodeToggle } from "@/components/code-toggle";
# Radio
-The single-choice value control, grouped by a `radio-group` row container. Like [checkbox](/docs/components/checkbox), the label rides the `text` attribute — radio is not a text-bearing element, so text content between the tags is rejected with a teaching error. One model field holds the group's selection: render it with `{a == b}` equalities on each radio's `checked`, and let each radio's `on-toggle` dispatch the Msg that sets the field — the engine never flips state on its own.
+The single-choice value control, grouped by a `radio-group`. Give the group an accessible `label` that names the shared choice. Like [checkbox](/docs/components/checkbox), each radio's label rides the `text` attribute — radio is not a text-bearing element, so text content between the tags is rejected with a teaching error. Descendant radios at any nesting depth form one logical group: one Tab stop, arrows wrap through the choices while Home/End move to the edges, focus and selection move together, and selecting one clears the rest. Bind the model's choice through `checked`; an actual selection transition dispatches `on-change` when bound, then falls back to `on-toggle` and `on-press` for compatibility. Activating the already-checked radio has no new `on-change` edge (a legacy fallback handler still receives the activation).
## Markup
```html
-
-
-
+
+
+
+
+
```
@@ -40,9 +42,11 @@ case "set_comfortable":
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
-ui.el(.radio_group, .{ .gap = 12 }, .{
- ui.el(.radio, .{ .text = "Default", .checked = model.density == .default, .on_toggle = .set_default }, .{}),
- ui.el(.radio, .{ .text = "Comfortable", .checked = model.density == .comfortable, .on_toggle = .set_comfortable }, .{}),
+ui.el(.radio_group, .{ .gap = 12, .semantics = .{ .label = "Density" } }, .{
+ ui.el(.radio, .{ .text = "Default", .checked = model.density == .default, .on_change = .set_default }, .{}),
+ ui.row(.{}, .{
+ ui.el(.radio, .{ .text = "Comfortable", .checked = model.density == .comfortable, .on_change = .set_comfortable }, .{}),
+ }),
ui.el(.radio, .{ .text = "Compact", .checked = model.density == .compact, .disabled = true }, .{}),
})
```
@@ -54,6 +58,8 @@ ui.el(.radio_group, .{ .gap = 12 }, .{
"text",
"checked",
"disabled",
+ "on-change",
"on-toggle",
+ "on-press",
]}
/>
diff --git a/docs/src/lib/component-vocab.json b/docs/src/lib/component-vocab.json
index cac0024d..2c33e9d3 100644
--- a/docs/src/lib/component-vocab.json
+++ b/docs/src/lib/component-vocab.json
@@ -58,7 +58,7 @@
},
{
"name": "radio",
- "doc": "Value control; bind checked or selected, dispatch with on-toggle."
+ "doc": "Single-choice value control; bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility."
},
{
"name": "toggle",
@@ -118,7 +118,7 @@
},
{
"name": "radio-group",
- "doc": "Row container grouping radio controls; children flow horizontally."
+ "doc": "Logical radiogroup: give it an accessible label; descendant radios at any nesting depth share one Tab stop and selection, arrows wrap, and Home/End move to the scope edges."
},
{
"name": "tabs",
diff --git a/examples/android/app/src/main/cpp/native_sdk.h b/examples/android/app/src/main/cpp/native_sdk.h
index 99d95852..86f79832 100644
--- a/examples/android/app/src/main/cpp/native_sdk.h
+++ b/examples/android/app/src/main/cpp/native_sdk.h
@@ -24,6 +24,8 @@ enum {
NATIVE_SDK_WIDGET_ROLE_SWITCH = 17,
NATIVE_SDK_WIDGET_ROLE_SLIDER = 18,
NATIVE_SDK_WIDGET_ROLE_PROGRESSBAR = 19,
+ NATIVE_SDK_WIDGET_ROLE_RADIO = 20,
+ NATIVE_SDK_WIDGET_ROLE_RADIOGROUP = 21,
};
enum {
diff --git a/examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt b/examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt
index a15bdd18..bdc56537 100644
--- a/examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt
+++ b/examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt
@@ -248,7 +248,7 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
info.stateDescription = widgetStateDescription(node)
}
- info.isCheckable = node.role == WIDGET_ROLE_CHECKBOX || node.role == WIDGET_ROLE_SWITCH
+ info.isCheckable = node.role == WIDGET_ROLE_CHECKBOX || node.role == WIDGET_ROLE_RADIO || node.role == WIDGET_ROLE_SWITCH
info.isChecked = info.isCheckable && widgetValueSelected(node)
info.isClickable = widgetSupportsAnyAction(node, WIDGET_ACTION_PRESS or WIDGET_ACTION_TOGGLE or WIDGET_ACTION_SELECT)
info.isEditable = node.role == WIDGET_ROLE_TEXTBOX && (node.flags and WIDGET_FLAG_READ_ONLY) == 0
@@ -347,6 +347,8 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
WIDGET_ROLE_BUTTON, WIDGET_ROLE_MENUITEM -> "android.widget.Button"
WIDGET_ROLE_TEXTBOX -> "android.widget.EditText"
WIDGET_ROLE_CHECKBOX -> "android.widget.CheckBox"
+ WIDGET_ROLE_RADIO -> "android.widget.RadioButton"
+ WIDGET_ROLE_RADIOGROUP -> "android.widget.RadioGroup"
WIDGET_ROLE_SWITCH -> "android.widget.Switch"
WIDGET_ROLE_SLIDER -> "android.widget.SeekBar"
WIDGET_ROLE_PROGRESSBAR -> "android.widget.ProgressBar"
@@ -784,6 +786,8 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
private const val WIDGET_ROLE_SWITCH = 17
private const val WIDGET_ROLE_SLIDER = 18
private const val WIDGET_ROLE_PROGRESSBAR = 19
+ private const val WIDGET_ROLE_RADIO = 20
+ private const val WIDGET_ROLE_RADIOGROUP = 21
private const val WIDGET_FLAG_FOCUSED = 1 shl 0
private const val WIDGET_FLAG_SELECTED = 1 shl 3
private const val WIDGET_FLAG_DISABLED = 1 shl 4
diff --git a/examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift b/examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift
index 7aa8c3ec..9829f5eb 100644
--- a/examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift
+++ b/examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift
@@ -359,16 +359,54 @@ final class NativeSdkHostViewController: UIViewController {
private func refreshWidgetAccessibility() {
let semantics = widgetSemanticsSnapshot()
statusLabel.accessibilityValue = "Accessible items: \(semantics.count)"
- widgetAccessibilityElements = semantics.map { node in
+ var elementsById: [UInt64: WidgetAccessibilityElement] = [:]
+ var nodesById: [UInt64: WidgetSemantics] = [:]
+ var elements: [WidgetAccessibilityElement] = []
+ elements.reserveCapacity(semantics.count)
+ for node in semantics {
let element = WidgetAccessibilityElement(accessibilityContainer: webView, owner: self, node: node)
+ element.isAccessibilityElement = true
element.accessibilityIdentifier = "native-sdk-widget-\(node.id)"
element.accessibilityLabel = node.label.isEmpty ? node.text : node.label
element.accessibilityValue = widgetAccessibilityValue(node)
element.accessibilityFrameInContainerSpace = node.bounds
element.accessibilityTraits = widgetAccessibilityTraits(node)
- return element
+ elements.append(element)
+ elementsById[node.id] = element
+ nodesById[node.id] = node
}
- webView.accessibilityElements = widgetAccessibilityElements.isEmpty ? nil : widgetAccessibilityElements as [Any]
+
+ var roots: [WidgetAccessibilityElement] = []
+ var childrenByParentId: [UInt64: [WidgetAccessibilityElement]] = [:]
+ for (node, element) in zip(semantics, elements) {
+ guard node.parentId != 0,
+ let parent = elementsById[node.parentId],
+ let parentNode = nodesById[node.parentId] else {
+ roots.append(element)
+ continue
+ }
+ element.accessibilityContainer = parent
+ element.accessibilityFrameInContainerSpace = CGRect(
+ x: node.bounds.minX - parentNode.bounds.minX,
+ y: node.bounds.minY - parentNode.bounds.minY,
+ width: node.bounds.width,
+ height: node.bounds.height
+ )
+ childrenByParentId[node.parentId, default: []].append(element)
+ }
+ for (parentId, children) in childrenByParentId {
+ guard let parent = elementsById[parentId] else { continue }
+ parent.accessibilityElements = children as [Any]
+ parent.accessibilityContainerType = .semanticGroup
+ // A radiogroup is context for its descendants, not a separate
+ // stop that hides them. VoiceOver can now announce the group's
+ // label while navigating its individual radio buttons.
+ if nodesById[parentId]?.role == Int32(NATIVE_SDK_WIDGET_ROLE_RADIOGROUP) {
+ parent.isAccessibilityElement = false
+ }
+ }
+ widgetAccessibilityElements = elements.map { $0 as UIAccessibilityElement }
+ webView.accessibilityElements = roots.isEmpty ? nil : roots as [Any]
}
private func widgetAccessibilityValue(_ node: WidgetSemantics) -> String? {
@@ -439,7 +477,7 @@ final class NativeSdkHostViewController: UIViewController {
switch node.role {
case Int32(NATIVE_SDK_WIDGET_ROLE_BUTTON), Int32(NATIVE_SDK_WIDGET_ROLE_MENUITEM):
traits.insert(.button)
- case Int32(NATIVE_SDK_WIDGET_ROLE_CHECKBOX), Int32(NATIVE_SDK_WIDGET_ROLE_SWITCH), Int32(NATIVE_SDK_WIDGET_ROLE_TAB):
+ case Int32(NATIVE_SDK_WIDGET_ROLE_CHECKBOX), Int32(NATIVE_SDK_WIDGET_ROLE_RADIO), Int32(NATIVE_SDK_WIDGET_ROLE_SWITCH), Int32(NATIVE_SDK_WIDGET_ROLE_TAB):
traits.insert(.button)
case Int32(NATIVE_SDK_WIDGET_ROLE_SLIDER):
traits.insert(.adjustable)
diff --git a/examples/ios/NativeSdkIOSExample/native_sdk.h b/examples/ios/NativeSdkIOSExample/native_sdk.h
index 9b8f428b..fb0242f4 100644
--- a/examples/ios/NativeSdkIOSExample/native_sdk.h
+++ b/examples/ios/NativeSdkIOSExample/native_sdk.h
@@ -24,6 +24,8 @@ enum {
NATIVE_SDK_WIDGET_ROLE_SWITCH = 17,
NATIVE_SDK_WIDGET_ROLE_SLIDER = 18,
NATIVE_SDK_WIDGET_ROLE_PROGRESSBAR = 19,
+ NATIVE_SDK_WIDGET_ROLE_RADIO = 20,
+ NATIVE_SDK_WIDGET_ROLE_RADIOGROUP = 21,
};
enum {
diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md
index a8db9dd2..4b319812 100644
--- a/skill-data/native-ui/SKILL.md
+++ b/skill-data/native-ui/SKILL.md
@@ -196,7 +196,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
| `stack`, `panel`, `card` | overlay containers | children stack on top of each other — `gap` can never space them and is a validation error (put a `column`/`row` inside for flow) |
| `scroll` | scroll_view | wrap multiple children in a `column` inside it |
| `list`, `grid` | list, grid | vertical stack / cell grid |
-| `tabs`, `toggle-group`, `button-group`, `radio-group`, `breadcrumb`, `pagination` | row containers | children flow horizontally (tab buttons, toggle-buttons, radios, ...) |
+| `tabs`, `toggle-group`, `button-group`, `radio-group`, `breadcrumb`, `pagination` | row containers | children flow horizontally. Give every `radio-group` an accessible `label`; it is one logical `radiogroup`: descendant radios at any nesting depth share one Tab stop; arrows wrap while Home/End move to the edges, focus and selection move together, and selecting one clears the rest of the nearest group scope |
| `table` > `table-row` > `table-cell` | table, data_row, data_cell | rows only inside a table, cells only inside a row (for/if wrappers are fine); cells are text leaves, dispatch with `on-press` |
| `dropdown-menu` | dropdown_menu | vertical menu surface; children are `menu-item`s. `anchor="below\|above"` floats it against its PARENT's frame (see Pickers): late z-pass above the whole tree, window-clipped, auto-flipping at the window edges, zero flow space. Pair with `on-dismiss` |
| `accordion` | accordion | header via `text` attr; children show while `selected`, dispatch `on-toggle` |
@@ -208,7 +208,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
| `text`, `badge`, `tooltip` | text leaves | text content, `{}` interpolation allowed; `text` line policy via `wrap` (`"true"` word-wraps; `"false"`/unset paint one honest line, overflow eliding by default — `overflow="clip"` opts out), and `text` alone takes the typography rungs `size="heading"`/`size="display"` (themable token steps above title — section headings, hero stats, timer numerals). `tooltip` with `anchor="above\|below"` floats against its parent (the stack wrapping trigger + tooltip, the dropdown pattern) and the RUNTIME owns its visibility — hover intent on the trigger: shows after `tooltip-delay` ms (default 600; `"0"` = instant) and immediately on keyboard focus; hides on leave, focus departure, Escape, or a press of the trigger (a press also closes the warm window), and a shared 400ms warm window after a pointer-hovered tooltip hides on leave (the only hide that warms) shows the next trigger's tooltip instantly; the model never hears hover. These are shadcn/ui's defaults (Base UI). Without `anchor` it stays a static leaf that paints whenever the view renders it |
| `text` > `span` | inline styled runs | mixed-style text in ONE wrapped paragraph: span children style runs with `weight="regular\|medium\|bold"`, `mono`, `italic`, `scale` (a positive multiplier on the paragraph's base size — inline headings, hero stats), `underline`, `foreground` (token name); `{bindings}` interpolate inside spans; whitespace between runs collapses to a single space (none = the runs abut); spans do not nest, take no events, and the paragraph announces as one text run — see "Rich text" |
| `button`, `toggle-button`, `list-item`, `menu-item`, `toggle`, `switch`, `select`, `avatar` | text-bearing controls | label is the text content; `button`, `toggle-button`, `list-item`, and `menu-item` also take `icon="save"` — a vector icon drawn inline (buttons/toggle-buttons before the label, icon-only when the content is empty: add a `label`; list/menu items as a leading slot), ONE hit target whose icon follows the element's enabled/disabled tint (no overlay stacking, no duplicated `on-press`); tab strips are `toggle-button` children, so tabs get icons this way; `select` shows `placeholder` while empty and dispatches `on-press`; `avatar` renders initials, or a runtime image via `image="{binding}"` (see the Images section) |
-| `checkbox`, `radio`, `slider`, `progress` | value controls | `checked`, `value` (a 0..1 fraction on slider and progress; progress clamps out-of-range values at render, never an error); the checkbox/radio label rides `text="..."` — these are not text-bearing elements, so text content is a teaching error (`label=` alone names one for accessibility without a visible label); a slider's `value` follows the source when it MOVES (model-driven progress renders every rebuild) and keeps the user's drag while the source replays the same value — use `slider` for seek bars, `progress` for display-only; a markup slider's `on-change` dispatches a PLAIN Msg with no value payload — mirror the applied value into the model with `Options.sync` (the Zig builder's `on_value = Ui.valueMsg(.tag)` does deliver the applied f32) |
+| `checkbox`, `radio`, `slider`, `progress` | value controls | `checked`, `value` (a 0..1 fraction on slider and progress; progress clamps out-of-range values at render, never an error); the checkbox/radio label rides `text="..."` — these are not text-bearing elements, so text content is a teaching error (`label=` alone names one for accessibility without a visible label); a radio selection TRANSITION (pointer, Space/Enter, grouped navigation, or accessibility selection) dispatches `on-change` when bound, then `on-toggle`, then `on-press` for compatibility — reactivating the already-checked radio has no new `on-change` edge, though a legacy fallback still receives the activation; a slider's `value` follows the source when it MOVES (model-driven progress renders every rebuild) and keeps the user's drag while the source replays the same value — use `slider` for seek bars, `progress` for display-only; a markup slider's `on-change` dispatches a PLAIN Msg with no value payload — mirror the applied value into the model with `Options.sync` (the Zig builder's `on_value = Ui.valueMsg(.tag)` does deliver the applied f32) |
| `text-field`, `input`, `search-field`, `combobox`, `textarea` | text entry | `placeholder`; edits via `on-input`, enter via `on-submit` on single-line kinds; in a default `textarea`, Enter (and Shift+Enter) inserts a newline and `on-submit` dispatches on primary+Enter (cmd on macOS, ctrl elsewhere). A chat composer opts into `submit-on-enter="true"`: plain Enter submits, Shift+Enter remains a newline, and the primary chord still submits. `search-field` carries a built-in trailing clear affordance whenever it holds text (press the x, or Escape while focused — both clear through the text-edit path, so `on-input` hears it; no attribute, no external Clear button needed) |
| `status-bar` | status bar | text leaf: content only, no children |
| `separator`, `spacer` | separator, flexible space | `separator` is axis-aware: a horizontal rule in a `column`, a thin vertical divider in a `row`; give `spacer` a `grow` |
diff --git a/src/embed/conversions.zig b/src/embed/conversions.zig
index 7d045c01..8fec26c2 100644
--- a/src/embed/conversions.zig
+++ b/src/embed/conversions.zig
@@ -283,6 +283,7 @@ pub fn mobileWidgetRole(role: canvas.WidgetRole) MobileWidgetRole {
.tab => .tab,
.checkbox => .checkbox,
.radio => .radio,
+ .radiogroup => .radiogroup,
.switch_control => .switch_control,
.slider => .slider,
.progressbar => .progressbar,
diff --git a/src/embed/tests.zig b/src/embed/tests.zig
index 65c38f3a..e730b134 100644
--- a/src/embed/tests.zig
+++ b/src/embed/tests.zig
@@ -651,6 +651,12 @@ test "mobile C ABI exposes GPU widget accessibility semantics" {
.layout = .{ .gap = 2 },
.children = &grid_rows,
},
+ .{
+ .id = 14,
+ .kind = .radio_group,
+ .frame = geometry.RectF.init(12, 148, 120, 28),
+ .semantics = .{ .label = "Density" },
+ },
};
var nodes: [16]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(.{
@@ -661,7 +667,7 @@ test "mobile C ABI exposes GPU widget accessibility semantics" {
}, geometry.RectF.init(0, 0, 320, 180), &nodes);
_ = try self.embedded.runtime.setCanvasWidgetLayout(1, mobile_gpu_surface_label, layout);
- try std.testing.expectEqual(@as(usize, 13), native_sdk_app_widget_semantics_count(app));
+ try std.testing.expectEqual(@as(usize, 14), native_sdk_app_widget_semantics_count(app));
var root_node: MobileWidgetSemantics = .{};
try std.testing.expectEqual(@as(c_int, 1), native_sdk_app_widget_semantics_at(app, 0, &root_node));
@@ -737,6 +743,10 @@ test "mobile C ABI exposes GPU widget accessibility semantics" {
try std.testing.expectEqual(@as(isize, 2), status_cell.grid_column_count);
try std.testing.expect((status_cell.actions & @intFromEnum(MobileWidgetAction.select)) != 0);
+ const radio_group_node = try mobileWidgetSemanticsByIdForTest(app, 14);
+ try std.testing.expectEqual(@intFromEnum(MobileWidgetRole.radiogroup), radio_group_node.role);
+ try std.testing.expectEqualStrings("Density", radio_group_node.label.?[0..radio_group_node.label_len]);
+
var text_geometry: MobileWidgetTextGeometry = .{};
try std.testing.expectEqual(@as(c_int, 1), native_sdk_app_widget_text_geometry(app, 3, &text_geometry));
try std.testing.expectEqual(@as(u64, 3), text_geometry.id);
diff --git a/src/embed/types.zig b/src/embed/types.zig
index ebdb5be4..ab014ee2 100644
--- a/src/embed/types.zig
+++ b/src/embed/types.zig
@@ -28,6 +28,7 @@ pub const MobileWidgetRole = enum(c_int) {
slider = 18,
progressbar = 19,
radio = 20,
+ radiogroup = 21,
};
pub const MobileWidgetFlag = enum(u32) {
diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h
index 57c5ecbe..f20e41a2 100644
--- a/src/platform/macos/appkit_host.h
+++ b/src/platform/macos/appkit_host.h
@@ -161,6 +161,7 @@ typedef enum {
NATIVE_SDK_APPKIT_WIDGET_ROLE_SLIDER = 18,
NATIVE_SDK_APPKIT_WIDGET_ROLE_PROGRESSBAR = 19,
NATIVE_SDK_APPKIT_WIDGET_ROLE_RADIO = 20,
+ NATIVE_SDK_APPKIT_WIDGET_ROLE_RADIOGROUP = 21,
} native_sdk_appkit_widget_role_t;
enum {
@@ -207,6 +208,7 @@ typedef enum {
typedef struct {
uint64_t id;
+ uint64_t parent_id;
int role;
const char *label;
size_t label_len;
diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m
index 0a51f03e..3648b6c1 100644
--- a/src/platform/macos/appkit_host.m
+++ b/src/platform/macos/appkit_host.m
@@ -267,6 +267,8 @@ static NSAccessibilityRole NativeSdkAccessibilityRoleForWidgetRole(NSInteger rol
return NSAccessibilityCheckBoxRole;
case NATIVE_SDK_APPKIT_WIDGET_ROLE_RADIO:
return NSAccessibilityRadioButtonRole;
+ case NATIVE_SDK_APPKIT_WIDGET_ROLE_RADIOGROUP:
+ return NSAccessibilityRadioGroupRole;
case NATIVE_SDK_APPKIT_WIDGET_ROLE_MENU:
return NSAccessibilityMenuRole;
case NATIVE_SDK_APPKIT_WIDGET_ROLE_MENUITEM:
@@ -470,6 +472,7 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
@property(nonatomic, assign) uint32_t actionFlags;
@property(nonatomic, assign) BOOL canUndo;
@property(nonatomic, assign) BOOL canRedo;
+@property(nonatomic, assign) NSRect surfaceFrame;
- (BOOL)emitSetTextAccessibilityValue:(id)value;
- (BOOL)emitSetSelectionAccessibilityValue:(id)value;
@end
@@ -665,6 +668,7 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
@property(nonatomic, assign) NSRange selectedTextRange;
@property(nonatomic, assign) BOOL interpretedKeyEventEmittedInput;
@property(nonatomic, strong) NSArray *widgetAccessibilityElements;
+@property(nonatomic, strong) NSArray *widgetAccessibilityRootElements;
@property(nonatomic, strong) NSMutableArray *scrollDrivers;
@property(nonatomic, assign) NSPoint wheelGesturePoint;
@property(nonatomic, assign) BOOL wheelGestureActive;
@@ -743,7 +747,7 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
- (void)updateSurfaceTrackingArea;
- (void)emitSelectAllTextInputCommand;
- (void)emitTextInputEventWithKind:(NSInteger)kind text:(NSString *)text compositionCursor:(NSInteger)compositionCursor;
-- (NSAccessibilityElement *)focusedTextAccessibilityElement;
+- (NativeSdkWidgetAccessibilityElement *)focusedTextAccessibilityElement;
- (BOOL)emitWidgetAccessibilityActionWithId:(uint64_t)widgetId action:(NSInteger)action;
- (BOOL)emitWidgetAccessibilityActionWithId:(uint64_t)widgetId action:(NSInteger)action text:(NSString *)text selectedRange:(NSRange)selectedRange hasSelectedRange:(BOOL)hasSelectedRange;
- (void)setSurfaceCursor:(NSCursor *)cursor;
@@ -3752,7 +3756,7 @@ static void NativeSdkPremultiplyStraightRgba8(const uint8_t *source, uint8_t *de
}
- (NSArray *)accessibilityChildren {
- return self.widgetAccessibilityElements ?: @[];
+ return self.widgetAccessibilityRootElements ?: @[];
}
- (BOOL)isAvailable {
@@ -5612,11 +5616,14 @@ static BOOL NativeSdkCompositeBlurWriteRegion(NSDictionary *command, CGFloat sca
- (void)updateWidgetAccessibilityWithNodes:(const native_sdk_appkit_widget_accessibility_node_t *)nodes count:(NSUInteger)count {
if (!nodes || count == 0) {
self.widgetAccessibilityElements = @[];
+ self.widgetAccessibilityRootElements = @[];
NSAccessibilityPostNotification(self, NSAccessibilityLayoutChangedNotification);
return;
}
- NSMutableArray *elements = [NSMutableArray arrayWithCapacity:count];
+ NSMutableArray *elements = [NSMutableArray arrayWithCapacity:count];
+ NSMutableArray *parentIds = [NSMutableArray arrayWithCapacity:count];
+ NSMutableDictionary *elementsById = [NSMutableDictionary dictionaryWithCapacity:count];
for (NSUInteger index = 0; index < count; index++) {
const native_sdk_appkit_widget_accessibility_node_t node = nodes[index];
NSString *label = NativeSdkStringFromBytes(node.label, node.label_len) ?: @"";
@@ -5627,7 +5634,6 @@ static BOOL NativeSdkCompositeBlurWriteRegion(NSDictionary *command, CGFloat sca
element.surfaceView = self;
element.widgetId = node.id;
element.actionFlags = node.action_flags;
- element.accessibilityParent = self;
element.accessibilityRole = NativeSdkAccessibilityRoleForWidgetRole(node.role);
element.accessibilityIdentifier = [NSString stringWithFormat:@"native-sdk-widget-%llu", node.id];
element.accessibilityLabel = name;
@@ -5704,10 +5710,46 @@ static BOOL NativeSdkCompositeBlurWriteRegion(NSDictionary *command, CGFloat sca
element.accessibilityValueDescription = [stateDescriptions componentsJoinedByString:@", "];
}
CGFloat nativeY = self.bounds.size.height - node.y - node.height;
- element.accessibilityFrameInParentSpace = NSMakeRect(node.x, nativeY, node.width, node.height);
+ element.surfaceFrame = NSMakeRect(node.x, nativeY, node.width, node.height);
+ element.accessibilityFrameInParentSpace = element.surfaceFrame;
[elements addObject:element];
+ [parentIds addObject:@(node.parent_id)];
+ [elementsById setObject:element forKey:@(node.id)];
+ }
+
+ NSMutableArray *rootElements = [NSMutableArray arrayWithCapacity:count];
+ NSMutableDictionary *> *childrenByParentId = [NSMutableDictionary dictionaryWithCapacity:count];
+ for (NSUInteger index = 0; index < elements.count; index++) {
+ NativeSdkWidgetAccessibilityElement *element = elements[index];
+ NSNumber *parentId = parentIds[index];
+ NativeSdkWidgetAccessibilityElement *parent = parentId.unsignedLongLongValue == 0 ? nil : [elementsById objectForKey:parentId];
+ if (parent && parent != element) {
+ element.accessibilityParent = parent;
+ NSRect parentFrame = parent.surfaceFrame;
+ NSRect childFrame = element.surfaceFrame;
+ element.accessibilityFrameInParentSpace = NSMakeRect(
+ childFrame.origin.x - parentFrame.origin.x,
+ childFrame.origin.y - parentFrame.origin.y,
+ childFrame.size.width,
+ childFrame.size.height
+ );
+ NSMutableArray *children = [childrenByParentId objectForKey:parentId];
+ if (!children) {
+ children = [NSMutableArray array];
+ [childrenByParentId setObject:children forKey:parentId];
+ }
+ [children addObject:element];
+ } else {
+ element.accessibilityParent = self;
+ [rootElements addObject:element];
+ }
+ }
+ for (NSNumber *parentId in childrenByParentId) {
+ NativeSdkWidgetAccessibilityElement *parent = [elementsById objectForKey:parentId];
+ parent.accessibilityChildren = [childrenByParentId objectForKey:parentId];
}
self.widgetAccessibilityElements = elements;
+ self.widgetAccessibilityRootElements = rootElements;
NSAccessibilityPostNotification(self, NSAccessibilityLayoutChangedNotification);
}
@@ -7082,10 +7124,10 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
}
- (NSUInteger)characterIndexForPoint:(NSPoint)point {
- NSAccessibilityElement *element = [self focusedTextAccessibilityElement];
+ NativeSdkWidgetAccessibilityElement *element = [self focusedTextAccessibilityElement];
if (!element || !self.window) return 0;
- NSRect frame = element.accessibilityFrameInParentSpace;
+ NSRect frame = element.surfaceFrame;
if (NSIsEmptyRect(frame)) return 0;
NSPoint windowPoint = [self.window convertPointFromScreen:point];
@@ -7100,10 +7142,10 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
}
- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange {
- NSAccessibilityElement *element = [self focusedTextAccessibilityElement];
+ NativeSdkWidgetAccessibilityElement *element = [self focusedTextAccessibilityElement];
NSRect localRect = NSZeroRect;
if (element) {
- NSRect frame = element.accessibilityFrameInParentSpace;
+ NSRect frame = element.surfaceFrame;
NSInteger characterCount = MAX(0, element.accessibilityNumberOfCharacters);
NSUInteger location = range.location == NSNotFound ? 0 : MIN(range.location, (NSUInteger)characterCount);
NSUInteger length = range.location == NSNotFound ? 0 : MIN(range.length, (NSUInteger)characterCount - location);
@@ -7126,8 +7168,8 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
return self.window ? [self.window convertRectToScreen:windowRect] : windowRect;
}
-- (NSAccessibilityElement *)focusedTextAccessibilityElement {
- for (NSAccessibilityElement *element in self.widgetAccessibilityElements ?: @[]) {
+- (NativeSdkWidgetAccessibilityElement *)focusedTextAccessibilityElement {
+ for (NativeSdkWidgetAccessibilityElement *element in self.widgetAccessibilityElements ?: @[]) {
if (!element.accessibilityFocused) continue;
if ([element.accessibilityRole isEqualToString:NSAccessibilityTextFieldRole]) return element;
}
diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig
index 651015dc..64cfce47 100644
--- a/src/platform/macos/root.zig
+++ b/src/platform/macos/root.zig
@@ -337,6 +337,7 @@ const AppKitMessageDialogOpts = extern struct {
const AppKitWidgetAccessibilityNode = extern struct {
id: u64,
+ parent_id: u64,
role: c_int,
label: [*]const u8,
label_len: usize,
@@ -1984,6 +1985,7 @@ fn updateWidgetAccessibility(context: ?*anyopaque, snapshot: platform_mod.Widget
for (snapshot.nodes, 0..) |node, index| {
nodes[index] = .{
.id = node.id,
+ .parent_id = node.parent_id orelse 0,
.role = @intFromEnum(node.role),
.label = node.label.ptr,
.label_len = node.label.len,
diff --git a/src/platform/types.zig b/src/platform/types.zig
index 900b116a..abbd68af 100644
--- a/src/platform/types.zig
+++ b/src/platform/types.zig
@@ -2353,6 +2353,7 @@ pub const WidgetAccessibilityRole = enum(c_int) {
slider = 18,
progressbar = 19,
radio = 20,
+ radiogroup = 21,
};
pub const WidgetAccessibilityActions = struct {
diff --git a/src/primitives/canvas/a11y_audit.zig b/src/primitives/canvas/a11y_audit.zig
index 880b8ce7..1a1b0573 100644
--- a/src/primitives/canvas/a11y_audit.zig
+++ b/src/primitives/canvas/a11y_audit.zig
@@ -140,12 +140,14 @@ fn frameHasArea(frame: geometry.RectF) bool {
// ---------------------------------------------------------- missing label
/// Roles whose announcement is useless without a name: the control set a
-/// screen reader user OPERATES. Text/status/group/image roles either
-/// carry their name as content or degrade without blocking (images are
-/// the markup lint's warning); progressbars are display-only.
+/// screen reader user OPERATES, plus a radiogroup whose name supplies the
+/// shared question for its otherwise individually named choices. Other
+/// text/status/group/image roles either carry their name as content or
+/// degrade without blocking (images are the markup lint's warning);
+/// progressbars are display-only.
fn roleNeedsName(role: WidgetRole) bool {
return switch (role) {
- .button, .textbox, .checkbox, .radio, .switch_control, .slider, .menuitem, .tab, .link, .treeitem, .listitem => true,
+ .button, .textbox, .checkbox, .radio, .radiogroup, .switch_control, .slider, .menuitem, .tab, .link, .treeitem, .listitem => true,
else => false,
};
}
diff --git a/src/primitives/canvas/a11y_audit_tests.zig b/src/primitives/canvas/a11y_audit_tests.zig
index 8d019d9b..9d21c05d 100644
--- a/src/primitives/canvas/a11y_audit_tests.zig
+++ b/src/primitives/canvas/a11y_audit_tests.zig
@@ -40,6 +40,30 @@ test "an unlabeled button is a missing-label finding; text or a label clears it"
try std.testing.expectEqual(@as(usize, 0), clean.total);
}
+test "a radio group needs its own accessible name" {
+ var nodes: [16]canvas.WidgetLayoutNode = undefined;
+ var storage: [8]a11y_audit.A11yAuditFinding = undefined;
+
+ const unnamed = Widget{ .kind = .column, .children = &.{
+ .{ .id = 2, .kind = .radio_group, .children = &.{
+ .{ .id = 3, .kind = .radio, .text = "Default" },
+ .{ .id = 4, .kind = .radio, .text = "Compact" },
+ } },
+ } };
+ const issues = try auditTree(unnamed, window, &nodes, &storage);
+ try std.testing.expectEqual(@as(usize, 1), issues.total);
+ try std.testing.expectEqual(a11y_audit.A11yAuditRuleKind.missing_label, issues.findings[0].rule);
+
+ const named = Widget{ .kind = .column, .children = &.{
+ .{ .id = 2, .kind = .radio_group, .semantics = .{ .label = "Density" }, .children = &.{
+ .{ .id = 3, .kind = .radio, .text = "Default" },
+ .{ .id = 4, .kind = .radio, .text = "Compact" },
+ } },
+ } };
+ const clean = try auditTree(named, window, &nodes, &storage);
+ try std.testing.expectEqual(@as(usize, 0), clean.total);
+}
+
test "a text field's value is not its name; a placeholder or label is" {
var nodes: [16]canvas.WidgetLayoutNode = undefined;
var storage: [8]a11y_audit.A11yAuditFinding = undefined;
diff --git a/src/primitives/canvas/events.zig b/src/primitives/canvas/events.zig
index 79e8dd25..eaf55e52 100644
--- a/src/primitives/canvas/events.zig
+++ b/src/primitives/canvas/events.zig
@@ -71,6 +71,13 @@ pub const WidgetPointerEvent = struct {
/// Shift on pointer-down to extend from the existing selection
/// anchor instead of replacing it with a collapsed caret.
modifiers: WidgetKeyboardModifiers = .{},
+ /// Runtime-stamped outcome for a release that selected a radio:
+ /// true when retained selection actually changed, false when the
+ /// already-selected radio was activated again, null when this event
+ /// was not a radio selection (or never crossed the runtime seam).
+ /// Typed dispatch uses the stamp to keep `on_change` edge-triggered
+ /// while preserving the legacy toggle/press activation fallbacks.
+ radio_selection_changed: ?bool = null,
};
pub const WidgetKeyboardPhase = enum {
@@ -105,6 +112,24 @@ pub const WidgetKeyboardEvent = struct {
/// it to tell "selection followed focus onto me" (dispatch select)
/// from "an arrow landed on me in place" (collapse/expand intent).
focus_moved: bool = false,
+ /// True when the nearest `radio_group` scope owns this
+ /// Arrow/Home/End key. Unlike `focus_moved`, this stays true when the
+ /// target is already at the requested edge or is the group's only
+ /// focusable radio, so the key cannot leak to an app-level fallback.
+ /// Bare radios deliberately leave this false: they retain their
+ /// legacy focus-only spatial navigation.
+ radio_group_navigation: bool = false,
+ /// Whether this radio-group navigation should select the routed
+ /// target. A real focus move always selects; an in-place move selects
+ /// only when the current radio was unchecked, avoiding duplicate
+ /// change dispatches for Home-on-first / End-on-last.
+ radio_group_selection: bool = false,
+ /// Runtime-stamped outcome for a radio select intent. Space/Enter and
+ /// radio-group navigation set this to the retained mutation result;
+ /// null means the event was not a radio selection (or was routed by a
+ /// direct Tree consumer). This keeps `on_change` tied to a transition,
+ /// not merely to an activation key.
+ radio_selection_changed: ?bool = null,
edit: ?TextInputEvent = null,
/// True when the runtime clamped a clipboard paste to fit capacity
/// before building `edit`; apps that care about lost bytes must check
@@ -563,6 +588,9 @@ pub fn widgetKeyboardControlIntent(widget: Widget, keyboard: WidgetKeyboardEvent
if (widget.semantics.role == .treeitem) {
if (widgetTreeItemKeyboardControlIntent(widget, keyboard)) |intent| return intent;
}
+ if (widget.kind == .radio) {
+ if (widgetRadioKeyboardControlIntent(widget, keyboard)) |intent| return intent;
+ }
return switch (widget.kind) {
.button, .icon_button => if (isWidgetActivationKey(keyboard.key))
.{ .kind = .press, .actions = .{ .press = true } }
@@ -778,6 +806,28 @@ fn widgetTreeItemKeyboardControlIntent(widget: Widget, keyboard: WidgetKeyboardE
return null;
}
+/// A radio inside a `radio_group` follows focus for the group's
+/// Arrow/Home/End keymap. Space/Enter continue through the ordinary
+/// activation arm below; radios outside a group never receive the
+/// `radio_group_selection` stamp and keep their old behavior.
+fn widgetRadioKeyboardControlIntent(widget: Widget, keyboard: WidgetKeyboardEvent) ?WidgetControlIntent {
+ if (!keyboard.radio_group_selection) return null;
+ const navigation_key = std.ascii.eqlIgnoreCase(keyboard.key, "arrowup") or
+ std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown") or
+ std.ascii.eqlIgnoreCase(keyboard.key, "arrowleft") or
+ std.ascii.eqlIgnoreCase(keyboard.key, "arrowright") or
+ std.ascii.eqlIgnoreCase(keyboard.key, "home") or
+ std.ascii.eqlIgnoreCase(keyboard.key, "end");
+ if (!navigation_key) return null;
+ return .{
+ .kind = .select,
+ .actions = .{
+ .select = true,
+ .press = widget.command.len > 0,
+ },
+ };
+}
+
pub fn widgetScrollKeyboardIntent(widget: Widget, keyboard: WidgetKeyboardEvent) ?WidgetControlIntent {
if (keyboard.phase != .key_down or keyboard.modifiers.hasNavigationModifier()) return null;
if (widget.state.disabled) return null;
diff --git a/src/primitives/canvas/text_tests.zig b/src/primitives/canvas/text_tests.zig
index 406384b9..93fcf97b 100644
--- a/src/primitives/canvas/text_tests.zig
+++ b/src/primitives/canvas/text_tests.zig
@@ -475,6 +475,26 @@ test "widget keyboard control intents map activation keys" {
try std.testing.expect(radio.actions.press);
try std.testing.expect(!radio.actions.toggle);
+ const grouped_radio_move = widgetKeyboardControlIntent(.{ .kind = .radio, .text = "Annual" }, .{
+ .phase = .key_down,
+ .key = "arrowright",
+ .focus_moved = true,
+ .radio_group_navigation = true,
+ .radio_group_selection = true,
+ }).?;
+ try std.testing.expectEqual(WidgetControlIntentKind.select, grouped_radio_move.kind);
+ try std.testing.expect(grouped_radio_move.actions.select);
+ try std.testing.expect(widgetKeyboardControlIntent(.{ .kind = .radio, .text = "Annual", .state = .{ .selected = true } }, .{
+ .phase = .key_down,
+ .key = "home",
+ .radio_group_navigation = true,
+ }) == null);
+ try std.testing.expect(widgetKeyboardControlIntent(.{ .kind = .radio, .text = "Bare" }, .{
+ .phase = .key_down,
+ .key = "arrowright",
+ .focus_moved = true,
+ }) == null);
+
try std.testing.expect(widgetKeyboardControlIntent(.{ .kind = .button, .text = "Save" }, .{ .phase = .key_down, .key = "enter", .modifiers = .{ .super = true } }) == null);
try std.testing.expect(widgetKeyboardControlIntent(.{ .kind = .button, .text = "Save", .state = .{ .disabled = true } }, .{ .phase = .key_down, .key = "enter" }) == null);
try std.testing.expect(widgetKeyboardControlIntent(.{ .kind = .button, .text = "Save" }, .{ .phase = .key_up, .key = "enter" }) == null);
diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig
index fd958c0c..2bdf6394 100644
--- a/src/primitives/canvas/ui.zig
+++ b/src/primitives/canvas/ui.zig
@@ -1409,8 +1409,22 @@ pub fn Ui(comptime Msg: type) type {
/// widget resolves through the engine's semantic intent model
/// (press, then toggle, then select) to the matching handler.
pub fn msgForPointer(self: Tree, target_id: ObjectId, phase: canvas.WidgetPointerPhase) ?Msg {
+ return self.msgForPointerSelection(target_id, phase, null);
+ }
+
+ fn msgForPointerSelection(self: Tree, target_id: ObjectId, phase: canvas.WidgetPointerPhase, radio_selection_changed: ?bool) ?Msg {
if (phase != .up) return null;
const widget = self.findWidget(target_id) orelse return null;
+ // A radio press is selection, regardless of which legacy
+ // handlers are also bound. Resolve it through the same
+ // canonical order as keyboard and a11y selection before
+ // the generic press/toggle/select action walk can choose
+ // an explicitly stamped on_toggle action first.
+ if (widget.kind == .radio) {
+ const intent = canvas.widgetSemanticControlIntent(widget, .select) orelse return null;
+ _ = intent;
+ return self.msgForRadioSelection(target_id, radio_selection_changed);
+ }
const semantic_actions = [_]canvas.WidgetSemanticAction{ .press, .toggle, .select };
for (semantic_actions) |action| {
const intent = canvas.widgetSemanticControlIntent(widget, action) orelse continue;
@@ -1430,7 +1444,18 @@ pub fn Ui(comptime Msg: type) type {
if (phase == .up and click_count == 2) {
if (self.msgFor(target_id, .double_press)) |msg| return msg;
}
- return self.msgForPointer(target_id, phase);
+ return self.msgForPointerSelection(target_id, phase, null);
+ }
+
+ /// Runtime pointer dispatch with the retained radio-selection
+ /// outcome preserved. Direct tests and consumers can keep using
+ /// `msgForPointerClick`; the runtime uses this form so reselecting
+ /// an already-checked radio does not synthesize `on_change`.
+ pub fn msgForPointerEvent(self: Tree, target_id: ObjectId, pointer: canvas.WidgetPointerEvent) ?Msg {
+ if (pointer.phase == .up and pointer.click_count == 2) {
+ if (self.msgFor(target_id, .double_press)) |msg| return msg;
+ }
+ return self.msgForPointerSelection(target_id, pointer.phase, pointer.radio_selection_changed);
}
/// Typed dispatch for keyboard events: engine control intents
@@ -1456,6 +1481,10 @@ pub fn Ui(comptime Msg: type) type {
if (self.msgFor(target_id, .submit)) |msg| return msg;
}
if (canvas.widgetKeyboardControlIntent(widget, keyboard)) |intent| {
+ if (widget.kind == .radio and intent.kind == .select) {
+ if (self.msgForRadioSelection(target_id, keyboard.radio_selection_changed)) |msg| return msg;
+ return null;
+ }
if (self.msgForIntent(target_id, intent)) |msg| return msg;
}
if (isSubmitKeyboard(widget, keyboard)) {
@@ -1498,7 +1527,18 @@ pub fn Ui(comptime Msg: type) type {
return switch (intent.kind) {
.press => self.msgFor(id, .press),
.toggle => self.msgFor(id, .toggle),
- .select => self.msgFor(id, .press),
+ // Radio selection has one canonical handler order on
+ // every input path: on_change, then the historical
+ // on_toggle markup convention, then on_press for
+ // backwards compatibility. Pointer, Space/Enter, and
+ // radio-group focus arrivals all resolve here.
+ .select => if (self.findWidget(id)) |widget|
+ if (widget.kind == .radio)
+ self.msgForRadioSelection(id, null)
+ else
+ self.msgFor(id, .press)
+ else
+ null,
.set_value => blk: {
if (intent.value) |value| {
if (self.msgForValue(id, value)) |msg| break :blk msg;
@@ -1508,6 +1548,19 @@ pub fn Ui(comptime Msg: type) type {
.scroll_by, .scroll_to_start, .scroll_to_end => null,
};
}
+
+ fn msgForRadioSelection(self: Tree, id: ObjectId, stamped_changed: ?bool) ?Msg {
+ const widget = self.findWidget(id) orelse return null;
+ if (widget.kind != .radio) return null;
+ // Runtime input carries the exact retained mutation. A
+ // direct Tree consumer has no retained mirror, so derive
+ // the same ordinary case from the source snapshot.
+ const changed = stamped_changed orelse !(widget.state.selected or widget.value >= 0.5);
+ if (changed) {
+ if (self.msgFor(id, .change)) |msg| return msg;
+ }
+ return self.msgFor(id, .toggle) orelse self.msgFor(id, .press);
+ }
};
pub fn init(arena: std.mem.Allocator) Self {
diff --git a/src/primitives/canvas/ui_markup.zig b/src/primitives/canvas/ui_markup.zig
index a6de837f..21982b74 100644
--- a/src/primitives/canvas/ui_markup.zig
+++ b/src/primitives/canvas/ui_markup.zig
@@ -1419,7 +1419,7 @@ pub fn dismissEventElement(name: []const u8) bool {
// operated blind; a role that cannot mean what it says lies to the
// bridge), and a WARNING when the experience degrades but remains
// navigable (an unnamed image, a label duplicating the text it shadows).
-// Which elements are controls/editables/images is registry data
+// Which elements are controls/editables/radiogroups/images is registry data
// (`schema.ElementInfo.a11y_name`); the judgment about name sources and
// severities lives here. Both engines and the validator call the same
// predicates, so the lint cannot drift between check time and build time.
@@ -1430,6 +1430,8 @@ pub const a11y_icon_only_message = "icon-only control: the icon name is a drawin
pub const a11y_unlabeled_editable_message = "this text control has no accessible name - a screen reader user cannot tell what to type; add label=\"...\" (or placeholder=\"...\", which the accessibility bridges announce as the fallback name)";
+pub const a11y_unlabeled_radiogroup_message = "this radiogroup has no accessible name - a screen reader announces the choices without their shared question; add label=\"...\" naming the shared choice";
+
pub const a11y_unknown_role_message = "unknown role: role takes a canvas.WidgetRole name (button, link, tree, treeitem, list, listitem, tab, checkbox, ...)";
pub const a11y_container_role_message = "this role promises child structure (rows, items, cells) that this element can never hold - put the role on the container element around it, or drop it";
@@ -1445,6 +1447,13 @@ pub const a11y_redundant_label_message = "this label duplicates the element's te
/// the validator and both engines; comptime-callable.
pub fn a11yNameError(node: MarkupNode) ?[]const u8 {
const entry = schema.elementByName(node.name) orelse return null;
+ // A literal role override can create a radiogroup on any container;
+ // enforce the role's name contract in addition to the element-kind
+ // registry. Dynamic roles resolve at runtime, where the tree audit
+ // applies the same requirement to the effective semantic role.
+ if (nodeHasLiteralRole(node, "radiogroup") and !attrNonBlank(node, "label")) {
+ return a11y_unlabeled_radiogroup_message;
+ }
switch (entry.a11y_name) {
.none, .image => return null,
.control => {
@@ -1462,9 +1471,22 @@ pub fn a11yNameError(node: MarkupNode) ?[]const u8 {
if (entry.takes_text and a11yNodeHasName(node)) return null;
return a11y_unlabeled_editable_message;
},
+ .radiogroup => {
+ if (attrNonBlank(node, "label")) return null;
+ return a11y_unlabeled_radiogroup_message;
+ },
}
}
+fn nodeHasLiteralRole(node: MarkupNode, role: []const u8) bool {
+ const value = node.attr("role") orelse return false;
+ const expression = parseAttrExpression(value) orelse return false;
+ return switch (expression) {
+ .literal => |literal| std.mem.eql(u8, literal, role),
+ else => false,
+ };
+}
+
/// The role-misuse ERROR for an element node: an unknown literal role, or
/// a container role on an element that provably cannot hold the children
/// the role promises. Dynamic role values (`role="{binding}"`) resolve at
@@ -1645,7 +1667,7 @@ fn collectNodeA11yWarnings(node: MarkupNode, storage: []MarkupErrorInfo, len: *u
/// The a11y ERRORS for a document, all of them: the same findings
/// `validate` fails on one at a time (unnamed controls, icon-only
-/// controls, unnamed text entry, and role misuse), collected per node so
+/// controls, unnamed text entry/radiogroups, and role misuse), collected per node so
/// a checker can report every offender in one pass instead of one per
/// re-run. Positions match `validate`'s emission exactly: the element
/// for name errors, the role attribute for role errors.
diff --git a/src/primitives/canvas/ui_markup_tests.zig b/src/primitives/canvas/ui_markup_tests.zig
index 189cf8e6..d854e971 100644
--- a/src/primitives/canvas/ui_markup_tests.zig
+++ b/src/primitives/canvas/ui_markup_tests.zig
@@ -474,7 +474,7 @@ test "a dead handler on a non-hit-target element reports the attribute position"
try testing.expectEqual(@as(?markup.MarkupErrorInfo, null), markup.validate(try fixed_parser.parse()));
}
-test "the a11y lint: unnamed controls, icon-only controls, and unnamed text entry are errors" {
+test "the a11y lint: unnamed controls, radiogroups, and text entry are errors" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
@@ -490,6 +490,11 @@ test "the a11y lint: unnamed controls, icon-only controls, and unnamed text entr
// not a name (hearing the content does not say what to type).
.{ .source = "\n \n
", .message = markup.a11y_unlabeled_editable_message },
.{ .source = "\n \n
", .message = markup.a11y_unlabeled_editable_message },
+ // A radio group's individually named choices do not name their
+ // shared question. The built-in element and a literal role
+ // override both require their own label.
+ .{ .source = "\n \n", .message = markup.a11y_unlabeled_radiogroup_message },
+ .{ .source = "\n \n
", .message = markup.a11y_unlabeled_radiogroup_message },
// A blank label is not a name on a control (unlike an image,
// where the empty label is the decorative opt-out).
.{ .source = "\n \n
", .message = markup.a11y_unlabeled_control_message },
@@ -514,6 +519,8 @@ test "the a11y lint: unnamed controls, icon-only controls, and unnamed text entr
"\n \n
",
"\n \n
",
"\n
",
+ "\n \n",
+ "\n \n
",
};
for (clean) |source| {
var parser = markup.Parser.init(arena, source);
diff --git a/src/primitives/canvas/ui_markup_view_tests.zig b/src/primitives/canvas/ui_markup_view_tests.zig
index 29ffc657..eb7d2756 100644
--- a/src/primitives/canvas/ui_markup_view_tests.zig
+++ b/src/primitives/canvas/ui_markup_view_tests.zig
@@ -1418,6 +1418,12 @@ test "the registry's a11y name classes match the engine's control predicates" {
if (entry.a11y_name == .image) {
try testing.expect(kind == .avatar or kind == .media_surface or kind == .image);
}
+ // Radio-group is the one named container class: it is not a hit
+ // target, but its label supplies the shared question announced
+ // around the descendant radio choices.
+ if (entry.a11y_name == .radiogroup) {
+ try testing.expectEqual(canvas.WidgetKind.radio_group, kind);
+ }
}
}
@@ -2171,8 +2177,8 @@ pub const catalog_markup_source =
\\
\\
\\
- \\
- \\
+ \\
+ \\
\\
\\
\\ More info
@@ -2301,8 +2307,8 @@ pub fn handCatalogView(ui: *CatalogUi, model: *const CatalogModel) CatalogUi.Nod
ui.el(.input, .{ .text = model.query, .placeholder = "Name", .autofocus = true, .on_input = CatalogUi.inputMsg(.query_edit), .on_submit = .submit_query, .grow = 1 }, .{}),
ui.el(.combobox, .{ .text = model.query, .placeholder = "Search fruit", .on_input = CatalogUi.inputMsg(.query_edit) }, .{}),
}),
- ui.el(.radio_group, .{ .gap = 4 }, .{
- ui.el(.radio, .{ .checked = model.bold, .on_toggle = .toggle_bold }, .{}),
+ ui.el(.radio_group, .{ .gap = 4, .semantics = .{ .label = "Formatting" } }, .{
+ ui.el(.radio, .{ .checked = model.bold, .on_change = .toggle_bold }, .{}),
}),
ui.el(.accordion, .{ .text = "Details", .selected = model.details_open, .on_toggle = .toggle_details, .padding = 8 }, .{
ui.text(.{}, "More info"),
diff --git a/src/primitives/canvas/ui_schema.zig b/src/primitives/canvas/ui_schema.zig
index ad52682f..439e7008 100644
--- a/src/primitives/canvas/ui_schema.zig
+++ b/src/primitives/canvas/ui_schema.zig
@@ -145,12 +145,16 @@ pub const EventInfo = struct {
/// - `image`: pictorial content. An unnamed image degrades (announced as
/// an unnamed image) but does not block, so a missing alt-equivalent
/// label is a WARNING; an explicit `label=""` marks it decorative.
+/// - `radiogroup`: a single-choice container whose label supplies the
+/// shared question for its individually named choices. Only a nonblank
+/// `label` names the group; a missing one is an ERROR.
/// - `none`: layout, decoration, and content whose name IS its text.
pub const A11yNameRule = enum {
none,
control,
editable,
image,
+ radiogroup,
};
pub const ElementInfo = struct {
@@ -221,7 +225,7 @@ pub const elements = [_]ElementInfo{
.{ .code = 11, .name = "breadcrumb", .widget_kind = "breadcrumb", .hit_target = false },
.{ .code = 12, .name = "button-group", .widget_kind = "button_group", .hit_target = false },
.{ .code = 13, .name = "pagination", .widget_kind = "pagination", .hit_target = false },
- .{ .code = 14, .name = "radio-group", .widget_kind = "radio_group", .hit_target = false },
+ .{ .code = 14, .name = "radio-group", .widget_kind = "radio_group", .hit_target = false, .a11y_name = .radiogroup },
.{ .code = 15, .name = "tabs", .widget_kind = "tabs", .hit_target = false },
.{ .code = 16, .name = "toggle-group", .widget_kind = "toggle_group", .hit_target = false },
// Vertical containers.
@@ -683,12 +687,12 @@ pub const icon_names = [_][]const u8{
/// std-only) with a lockstep test in ui_markup_view_tests.zig holding the
/// mirror equal to the live enum.
pub const role_names = [_][]const u8{
- "none", "group", "text", "link", "image",
- "button", "textbox", "tooltip", "dialog", "menu",
- "menuitem", "list", "listitem", "row", "grid",
- "gridcell", "tab", "checkbox", "radio", "switch_control",
- "slider", "progressbar", "chart", "tree", "treeitem",
- "separator",
+ "none", "group", "text", "link", "image",
+ "button", "textbox", "tooltip", "dialog", "menu",
+ "menuitem", "list", "listitem", "row", "grid",
+ "gridcell", "tab", "checkbox", "radio", "radiogroup",
+ "switch_control", "slider", "progressbar", "chart", "tree",
+ "treeitem", "separator",
};
/// Roles that promise CHILD STRUCTURE to assistive tech (rows, items,
@@ -696,7 +700,7 @@ pub const role_names = [_][]const u8{
/// cannot hold element children (see `elementHoldsChildren`) is role
/// misuse the registry can see: the promise can never be kept.
pub const container_role_names = [_][]const u8{
- "tree", "list", "menu", "grid", "row", "dialog",
+ "tree", "list", "menu", "grid", "row", "dialog", "radiogroup",
};
/// Whether markup can put element children inside this element: text
diff --git a/src/primitives/canvas/ui_tests.zig b/src/primitives/canvas/ui_tests.zig
index b035fa6a..63f97e05 100644
--- a/src/primitives/canvas/ui_tests.zig
+++ b/src/primitives/canvas/ui_tests.zig
@@ -375,6 +375,53 @@ test "tree keyboard navigation can select without dispatching pointer activation
}).?);
}
+test "radio selection dispatches change then toggle then press on every input path" {
+ var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
+ defer arena_state.deinit();
+
+ var ui = InboxUi.init(arena_state.allocator());
+ const tree = try ui.finalize(ui.el(.radio_group, .{}, .{
+ ui.el(.radio, .{ .text = "All", .on_change = .add, .on_toggle = .load_more }, .{}),
+ ui.el(.radio, .{ .text = "Toggle fallback", .on_toggle = .load_more, .on_press = .add }, .{}),
+ ui.el(.radio, .{ .text = "Press fallback", .on_press = .add }, .{}),
+ ui.el(.radio, .{ .text = "Selected", .checked = true, .on_change = .add, .on_press = .load_more }, .{}),
+ }));
+ const change_radio = tree.root.children[0];
+ const toggle_radio = tree.root.children[1];
+ const press_radio = tree.root.children[2];
+ const selected_radio = tree.root.children[3];
+
+ try testing.expectEqual(Msg.add, tree.msgForPointer(change_radio.id, .up).?);
+ try testing.expectEqual(Msg.add, tree.msgForKeyboard(change_radio.id, .{ .phase = .key_down, .key = "space" }).?);
+ try testing.expectEqual(Msg.add, tree.msgForKeyboard(change_radio.id, .{ .phase = .key_down, .key = "enter" }).?);
+ try testing.expectEqual(Msg.add, tree.msgForKeyboard(change_radio.id, .{
+ .phase = .key_down,
+ .key = "arrowright",
+ .focus_moved = true,
+ .radio_group_navigation = true,
+ .radio_group_selection = true,
+ }).?);
+ try testing.expectEqual(Msg.load_more, tree.msgForPointer(toggle_radio.id, .up).?);
+ try testing.expectEqual(Msg.add, tree.msgForPointer(press_radio.id, .up).?);
+
+ // `on_change` is an edge, not an activation alias. Direct tree
+ // consumers derive the result from source state; runtime events carry
+ // the retained mutation explicitly. Reselecting falls through to the
+ // historical activation handler when one exists.
+ try testing.expectEqual(Msg.load_more, tree.msgForPointer(selected_radio.id, .up).?);
+ try testing.expectEqual(Msg.load_more, tree.msgForKeyboard(selected_radio.id, .{ .phase = .key_down, .key = "space" }).?);
+ try testing.expectEqual(Msg.load_more, tree.msgForPointerEvent(change_radio.id, .{
+ .phase = .up,
+ .point = .{},
+ .radio_selection_changed = false,
+ }).?);
+ try testing.expectEqual(Msg.add, tree.msgForKeyboard(selected_radio.id, .{
+ .phase = .key_down,
+ .key = "space",
+ .radio_selection_changed = true,
+ }).?);
+}
+
test "textarea keyboard: the default and chat-composer Enter policies stay distinct" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
diff --git a/src/primitives/canvas/widget_builtin_tests.zig b/src/primitives/canvas/widget_builtin_tests.zig
index 8115cb99..d1b09e21 100644
--- a/src/primitives/canvas/widget_builtin_tests.zig
+++ b/src/primitives/canvas/widget_builtin_tests.zig
@@ -2201,7 +2201,7 @@ test "built-in component factory applies house composite defaults" {
try std.testing.expectEqual(widget_kind, component.kind);
try std.testing.expectEqual(gap, component.layout.gap);
try std.testing.expectEqual(WidgetCrossAlignment.center, component.layout.cross_alignment);
- try std.testing.expectEqual(WidgetRole.group, component.semantics.role);
+ try std.testing.expectEqual(if (kind == .radio_group) WidgetRole.radiogroup else WidgetRole.group, component.semantics.role);
}
// The house TabsList: a muted rounded container hugging its
diff --git a/src/primitives/canvas/widget_semantics.zig b/src/primitives/canvas/widget_semantics.zig
index 2fc0b282..5bbbb513 100644
--- a/src/primitives/canvas/widget_semantics.zig
+++ b/src/primitives/canvas/widget_semantics.zig
@@ -114,7 +114,8 @@ fn nearestSemanticParent(stack: []const ?usize) ?usize {
pub fn semanticRole(widget: Widget) WidgetRole {
if (widget.semantics.role != .none) return widget.semantics.role;
return switch (widget.kind) {
- .stack, .row, .column, .grid, .scroll_view, .breadcrumb, .button_group, .pagination, .radio_group, .tabs, .toggle_group, .accordion, .bubble, .resizable, .alert, .card, .panel => .group,
+ .stack, .row, .column, .grid, .scroll_view, .breadcrumb, .button_group, .pagination, .tabs, .toggle_group, .accordion, .bubble, .resizable, .alert, .card, .panel => .group,
+ .radio_group => .radiogroup,
.data_grid, .table => .grid,
.data_row => .row,
.dialog, .drawer, .sheet, .popover => .dialog,
diff --git a/src/primitives/canvas/widgets.zig b/src/primitives/canvas/widgets.zig
index 75ab4437..cdb4e63b 100644
--- a/src/primitives/canvas/widgets.zig
+++ b/src/primitives/canvas/widgets.zig
@@ -577,6 +577,8 @@ pub const WidgetRole = enum {
tab,
checkbox,
radio,
+ /// A single-choice group containing descendant radio controls.
+ radiogroup,
switch_control,
slider,
progressbar,
@@ -743,7 +745,7 @@ pub fn builtinComponentDescriptor(kind: BuiltinComponentKind) BuiltinComponentDe
.input => builtinComponent(.input, .input, .textbox, false),
.pagination => builtinComponent(.pagination, .pagination, .group, true),
.progress => builtinComponent(.progress, .progress, .progressbar, false),
- .radio_group => builtinComponent(.radio_group, .radio_group, .group, true),
+ .radio_group => builtinComponent(.radio_group, .radio_group, .radiogroup, true),
.resizable => builtinComponent(.resizable, .resizable, .group, true),
.select => builtinComponent(.select, .select, .button, true),
.separator => builtinComponent(.separator, .separator, .none, false),
diff --git a/src/runtime/canvas_widget_accessibility_tests.zig b/src/runtime/canvas_widget_accessibility_tests.zig
index cd499131..ba10f8e6 100644
--- a/src/runtime/canvas_widget_accessibility_tests.zig
+++ b/src/runtime/canvas_widget_accessibility_tests.zig
@@ -663,8 +663,32 @@ test "runtime moves focused grouped canvas controls with arrow keys" {
test "runtime moves focus within house grouped component controls" {
const TestApp = struct {
+ radio_group_navigation: bool = false,
+ radio_group_selection: bool = false,
+
fn app(self: *@This()) App {
- return .{ .context = self, .name = "gpu-widget-house-group-navigation", .source = platform.WebViewSource.html("Hello
") };
+ return .{
+ .context = self,
+ .name = "gpu-widget-house-group-navigation",
+ .source = platform.WebViewSource.html("Hello
"),
+ .event_fn = event,
+ };
+ }
+
+ fn event(context: *anyopaque, runtime: *Runtime, event_value: Event) anyerror!void {
+ _ = runtime;
+ const self: *@This() = @ptrCast(@alignCast(context));
+ switch (event_value) {
+ .canvas_widget_keyboard => |keyboard_event| {
+ if (keyboard_event.target) |target| {
+ if (target.kind == .radio) {
+ self.radio_group_navigation = keyboard_event.keyboard.radio_group_navigation;
+ self.radio_group_selection = keyboard_event.keyboard.radio_group_selection;
+ }
+ }
+ },
+ else => {},
+ }
}
};
@@ -699,16 +723,24 @@ test "runtime moves focus within house grouped component controls" {
.{ .id = 41, .kind = .segmented_control, .text = "Open" },
.{ .id = 42, .kind = .segmented_control, .text = "Closed" },
};
- const radio_buttons = [_]canvas.Widget{
- .{ .id = 51, .kind = .radio, .text = "Card" },
+ const first_radio = [_]canvas.Widget{
+ .{ .id = 51, .kind = .radio, .text = "Card", .state = .{ .selected = true } },
+ };
+ const second_radio = [_]canvas.Widget{
.{ .id = 52, .kind = .radio, .text = "List" },
};
+ // The rows are deliberate: radios need not be direct children of
+ // the radio group to share navigation, selection, or Tab behavior.
+ const radio_rows = [_]canvas.Widget{
+ .{ .kind = .row, .children = &first_radio },
+ .{ .kind = .column, .children = &second_radio },
+ };
const top_children = [_]canvas.Widget{
.{ .id = 10, .kind = .button_group, .frame = geometry.RectF.init(12, 12, 180, 34), .layout = builtinShadcnGroupLayout(), .children = &button_group_buttons },
.{ .id = 20, .kind = .pagination, .frame = geometry.RectF.init(12, 56, 220, 34), .layout = builtinShadcnGroupLayout(), .children = &pagination_buttons },
.{ .id = 30, .kind = .toggle_group, .frame = geometry.RectF.init(12, 100, 160, 34), .layout = builtinShadcnGroupLayout(), .children = &toggle_buttons },
.{ .id = 40, .kind = .tabs, .frame = geometry.RectF.init(12, 144, 180, 34), .layout = builtinShadcnGroupLayout(), .children = &tab_buttons },
- .{ .id = 50, .kind = .radio_group, .frame = geometry.RectF.init(12, 188, 180, 34), .layout = builtinShadcnGroupLayout(), .children = &radio_buttons },
+ .{ .id = 50, .kind = .radio_group, .frame = geometry.RectF.init(12, 188, 180, 34), .layout = builtinShadcnGroupLayout(), .semantics = .{ .label = "View" }, .children = &radio_rows },
.{ .id = 90, .kind = .button, .frame = geometry.RectF.init(248, 12, 84, 34), .text = "Alone" },
};
var nodes: [24]canvas.WidgetLayoutNode = undefined;
@@ -735,15 +767,258 @@ test "runtime moves focus within house grouped component controls" {
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowright" } });
try std.testing.expectEqual(@as(canvas.ObjectId, 42), harness.runtime.views[0].canvas_widget_focused_id);
+ // An unchecked radio already at Home is still selected, even though
+ // focus stays in place. A second Home is owned by the group but does
+ // not request selection again (and therefore cannot re-fire change).
+ _ = try runtimeViewSetCanvasWidgetSelected(&harness.runtime.views[0], 51, false);
harness.runtime.views[0].canvas_widget_focused_id = 51;
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "home" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 51), harness.runtime.views[0].canvas_widget_focused_id);
+ var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
+ try std.testing.expect(retained.findById(51).?.widget.state.selected);
+ try std.testing.expect(app_state.radio_group_navigation);
+ try std.testing.expect(app_state.radio_group_selection);
+
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "home" } });
+ try std.testing.expect(app_state.radio_group_navigation);
+ try std.testing.expect(!app_state.radio_group_selection);
+
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowright" } });
try std.testing.expectEqual(@as(canvas.ObjectId, 52), harness.runtime.views[0].canvas_widget_focused_id);
+ retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
+ try std.testing.expect(!retained.findById(51).?.widget.state.selected);
+ try std.testing.expect(retained.findById(52).?.widget.state.selected);
+ const group_semantics = runtimeViewWidgetSemantics(&harness.runtime.views[0]);
+ try std.testing.expectEqual(canvas.WidgetRole.radiogroup, canvasWidgetSemanticsById(group_semantics, 50).?.role);
+ const a11y_snapshot = harness.runtime.automationSnapshot("Widgets");
+ var a11y_buffer: [4096]u8 = undefined;
+ var a11y_writer = std.Io.Writer.fixed(&a11y_buffer);
+ try automation.snapshot.writeA11yText(a11y_snapshot, &a11y_writer);
+ try std.testing.expect(std.mem.indexOf(u8, a11y_writer.buffered(), "@w1/canvas#50 role=radiogroup") != null);
+
+ // Home/End walk the whole nearest group scope and selection follows.
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "home" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 51), harness.runtime.views[0].canvas_widget_focused_id);
+ retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
+ try std.testing.expect(retained.findById(51).?.widget.state.selected);
+ try std.testing.expect(!retained.findById(52).?.widget.state.selected);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "end" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 52), harness.runtime.views[0].canvas_widget_focused_id);
+ retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
+ try std.testing.expect(!retained.findById(51).?.widget.state.selected);
+ try std.testing.expect(retained.findById(52).?.widget.state.selected);
+
+ // Radio arrows wrap at both scope edges, and selection follows the
+ // wrapped focus just as it does for an in-range move.
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowright" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 51), harness.runtime.views[0].canvas_widget_focused_id);
+ retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
+ try std.testing.expect(retained.findById(51).?.widget.state.selected);
+ try std.testing.expect(!retained.findById(52).?.widget.state.selected);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowleft" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 52), harness.runtime.views[0].canvas_widget_focused_id);
+ retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
+ try std.testing.expect(!retained.findById(51).?.widget.state.selected);
+ try std.testing.expect(retained.findById(52).?.widget.state.selected);
+
+ // One Tab stop: entering lands on the selected radio, leaving skips
+ // the rest, and backward entry from below returns to that selection.
+ harness.runtime.views[0].canvas_widget_focused_id = 42;
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 52), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 90), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab", .modifiers = .{ .shift = true } } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 52), harness.runtime.views[0].canvas_widget_focused_id);
+
+ // With no selected radio, group entry deterministically chooses the
+ // first focusable descendant instead.
+ _ = try runtimeViewSetCanvasWidgetSelected(&harness.runtime.views[0], 52, false);
+ harness.runtime.views[0].canvas_widget_focused_id = 42;
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 51), harness.runtime.views[0].canvas_widget_focused_id);
harness.runtime.views[0].canvas_widget_focused_id = 90;
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowleft" } });
try std.testing.expectEqual(@as(canvas.ObjectId, 90), harness.runtime.views[0].canvas_widget_focused_id);
}
+test "radio group Tab entry falls back when its selection is fixed-clipped" {
+ const TestApp = struct {
+ fn app(self: *@This()) App {
+ return .{ .context = self, .name = "gpu-widget-radio-fixed-clip-tab", .source = platform.WebViewSource.html("Hello
") };
+ }
+ };
+
+ const harness = try TestHarness().create(std.testing.allocator, .{});
+ defer harness.destroy(std.testing.allocator);
+ harness.null_platform.gpu_surfaces = true;
+ var app_state: TestApp = .{};
+ const app = app_state.app();
+ try harness.start(app);
+
+ _ = try harness.runtime.createView(.{
+ .window_id = 1,
+ .label = "canvas",
+ .kind = .gpu_surface,
+ .frame = geometry.RectF.init(0, 0, 220, 80),
+ });
+
+ const radios = [_]canvas.Widget{
+ .{ .id = 3, .kind = .radio, .frame = geometry.RectF.init(0, 0, 48, 32), .text = "Visible" },
+ .{ .id = 4, .kind = .radio, .frame = geometry.RectF.init(0, 0, 48, 32), .text = "Clipped", .state = .{ .selected = true } },
+ };
+ const children = [_]canvas.Widget{
+ .{ .id = 2, .kind = .button, .frame = geometry.RectF.init(0, 0, 40, 32), .text = "Before" },
+ .{ .id = 10, .kind = .radio_group, .frame = geometry.RectF.init(52, 0, 48, 32), .layout = .{ .clip_content = true }, .semantics = .{ .label = "View" }, .children = &radios },
+ .{ .id = 5, .kind = .button, .frame = geometry.RectF.init(112, 0, 40, 32), .text = "After" },
+ };
+ var nodes: [6]canvas.WidgetLayoutNode = undefined;
+ const layout = try canvas.layoutWidgetTree(.{ .id = 1, .kind = .stack, .children = &children }, geometry.RectF.init(0, 0, 220, 80), &nodes);
+ _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
+
+ // The selected radio is the logical entry, but the fixed clip cannot
+ // scroll it into view. Tab therefore uses the visible group member
+ // and the next Tab still leaves the composite in one step.
+ harness.runtime.views[0].canvas_widget_focused_id = 2;
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 3), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 5), harness.runtime.views[0].canvas_widget_focused_id);
+}
+
+test "nested radio groups keep independent ordered Tab stops" {
+ const TestApp = struct {
+ fn app(self: *@This()) App {
+ return .{ .context = self, .name = "gpu-widget-nested-radio-tab", .source = platform.WebViewSource.html("Hello
") };
+ }
+ };
+
+ const harness = try TestHarness().create(std.testing.allocator, .{});
+ defer harness.destroy(std.testing.allocator);
+ harness.null_platform.gpu_surfaces = true;
+ var app_state: TestApp = .{};
+ const app = app_state.app();
+ try harness.start(app);
+
+ _ = try harness.runtime.createView(.{
+ .window_id = 1,
+ .label = "canvas",
+ .kind = .gpu_surface,
+ .frame = geometry.RectF.init(0, 0, 280, 80),
+ });
+
+ const inner_radios = [_]canvas.Widget{
+ .{ .id = 21, .kind = .radio, .frame = geometry.RectF.init(0, 0, 40, 32), .text = "Inner", .state = .{ .selected = true } },
+ };
+ const outer_children = [_]canvas.Widget{
+ .{ .id = 11, .kind = .radio, .frame = geometry.RectF.init(0, 0, 40, 32), .text = "Outer one", .state = .{ .selected = true } },
+ .{ .id = 20, .kind = .radio_group, .frame = geometry.RectF.init(0, 0, 40, 32), .semantics = .{ .label = "Inner choice" }, .children = &inner_radios },
+ .{ .id = 12, .kind = .radio, .frame = geometry.RectF.init(0, 0, 40, 32), .text = "Outer two" },
+ };
+ const children = [_]canvas.Widget{
+ .{ .id = 2, .kind = .button, .frame = geometry.RectF.init(0, 0, 40, 32), .text = "Before" },
+ .{ .id = 10, .kind = .radio_group, .frame = geometry.RectF.init(52, 0, 144, 32), .layout = .{ .gap = 4 }, .semantics = .{ .label = "Outer choice" }, .children = &outer_children },
+ .{ .id = 3, .kind = .button, .frame = geometry.RectF.init(208, 0, 40, 32), .text = "After" },
+ };
+ var nodes: [8]canvas.WidgetLayoutNode = undefined;
+ const layout = try canvas.layoutWidgetTree(.{ .id = 1, .kind = .stack, .children = &children }, geometry.RectF.init(0, 0, 280, 80), &nodes);
+ _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
+
+ // Each nearest scope owns one slot at its first radio. The trailing
+ // outer radio must not retarget backward to #11 after focus visits
+ // the nested group, which used to cycle #11 -> #21 -> #11 forever.
+ harness.runtime.views[0].canvas_widget_focused_id = 2;
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 11), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 21), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 3), harness.runtime.views[0].canvas_widget_focused_id);
+
+ // Reverse traversal visits the same logical slots in reverse order.
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab", .modifiers = .{ .shift = true } } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 21), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab", .modifiers = .{ .shift = true } } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 11), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab", .modifiers = .{ .shift = true } } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_focused_id);
+
+ // The group's selected entry may sit after the nested scope, but its
+ // logical Tab slot stays at #11: entry lands on #12, then traversal
+ // resumes from the slot and still reaches the inner group.
+ _ = try runtimeViewSetCanvasWidgetSelected(&harness.runtime.views[0], 11, false);
+ _ = try runtimeViewSetCanvasWidgetSelected(&harness.runtime.views[0], 12, true);
+ harness.runtime.views[0].canvas_widget_focused_id = 2;
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 12), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 21), harness.runtime.views[0].canvas_widget_focused_id);
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "tab" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 3), harness.runtime.views[0].canvas_widget_focused_id);
+}
+
+test "radio navigation skips fixed-clipped candidates" {
+ const TestApp = struct {
+ fn app(self: *@This()) App {
+ return .{ .context = self, .name = "gpu-widget-radio-fixed-clip-navigation", .source = platform.WebViewSource.html("Hello
") };
+ }
+ };
+
+ const harness = try TestHarness().create(std.testing.allocator, .{});
+ defer harness.destroy(std.testing.allocator);
+ harness.null_platform.gpu_surfaces = true;
+ var app_state: TestApp = .{};
+ const app = app_state.app();
+ try harness.start(app);
+
+ _ = try harness.runtime.createView(.{
+ .window_id = 1,
+ .label = "canvas",
+ .kind = .gpu_surface,
+ .frame = geometry.RectF.init(0, 0, 280, 80),
+ });
+
+ const radios = [_]canvas.Widget{
+ .{ .id = 3, .kind = .radio, .frame = geometry.RectF.init(0, 0, 28, 32), .text = "Hidden first" },
+ .{ .id = 4, .kind = .radio, .frame = geometry.RectF.init(0, 0, 28, 32), .text = "Visible one", .state = .{ .selected = true } },
+ .{ .id = 5, .kind = .radio, .frame = geometry.RectF.init(0, 0, 28, 32), .text = "Hidden middle" },
+ .{ .id = 6, .kind = .radio, .frame = geometry.RectF.init(0, 0, 28, 32), .text = "Visible two" },
+ .{ .id = 7, .kind = .radio, .frame = geometry.RectF.init(0, 0, 28, 32), .text = "Hidden last" },
+ };
+ const children = [_]canvas.Widget{
+ .{ .id = 10, .kind = .radio_group, .frame = geometry.RectF.init(20, 0, 160, 32), .layout = .{ .clip_content = true, .gap = 4 }, .semantics = .{ .label = "View" }, .children = &radios },
+ };
+ var nodes: [7]canvas.WidgetLayoutNode = undefined;
+ const layout = try canvas.layoutWidgetTree(.{ .id = 1, .kind = .stack, .children = &children }, geometry.RectF.init(0, 0, 280, 80), &nodes);
+ for (nodes[0..layout.nodes.len]) |*node| {
+ if (node.widget.id == 3 or node.widget.id == 5 or node.widget.id == 7) {
+ node.frame.x = 220;
+ }
+ }
+ _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
+
+ // Logical order remains 3,4,5,6,7 so scroll viewports can reveal
+ // candidates. Fixed clipping cannot reveal 3/5/7; traversal must
+ // continue until the next visible member accepts focus.
+ harness.runtime.views[0].canvas_widget_focused_id = 4;
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowright" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 6), harness.runtime.views[0].canvas_widget_focused_id);
+ const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
+ try std.testing.expect(!retained.findById(4).?.widget.state.selected);
+ try std.testing.expect(retained.findById(6).?.widget.state.selected);
+
+ harness.runtime.views[0].canvas_widget_focused_id = 4;
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "end" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 6), harness.runtime.views[0].canvas_widget_focused_id);
+
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "home" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 4), harness.runtime.views[0].canvas_widget_focused_id);
+
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowleft" } });
+ try std.testing.expectEqual(@as(canvas.ObjectId, 6), harness.runtime.views[0].canvas_widget_focused_id);
+}
+
fn builtinShadcnGroupLayout() canvas.WidgetLayoutStyle {
return .{ .gap = 4, .cross_alignment = .center };
}
diff --git a/src/runtime/canvas_widget_control_tests.zig b/src/runtime/canvas_widget_control_tests.zig
index 6721c6bc..c6fe0cd0 100644
--- a/src/runtime/canvas_widget_control_tests.zig
+++ b/src/runtime/canvas_widget_control_tests.zig
@@ -649,6 +649,21 @@ test "runtime reconciles canvas control state across layout replacement" {
.text = "Annual",
},
};
+ const nested_radio_a = [_]canvas.Widget{.{
+ .id = 18,
+ .kind = .radio,
+ .text = "Email",
+ .state = .{ .selected = true },
+ }};
+ const nested_radio_b = [_]canvas.Widget{.{
+ .id = 19,
+ .kind = .radio,
+ .text = "SMS",
+ }};
+ const nested_radio_rows = [_]canvas.Widget{
+ .{ .kind = .row, .children = &nested_radio_a },
+ .{ .kind = .column, .children = &nested_radio_b },
+ };
const controls = [_]canvas.Widget{
.{
.id = 2,
@@ -696,8 +711,14 @@ test "runtime reconciles canvas control state across layout replacement" {
.frame = geometry.RectF.init(150, 178, 160, 30),
.children = &radio_items,
},
+ .{
+ .id = 20,
+ .kind = .radio_group,
+ .frame = geometry.RectF.init(150, 140, 120, 30),
+ .children = &nested_radio_rows,
+ },
};
- var nodes: [20]canvas.WidgetLayoutNode = undefined;
+ var nodes: [24]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &controls }, geometry.RectF.init(0, 0, 280, 220), &nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
@@ -709,6 +730,7 @@ test "runtime reconciles canvas control state across layout replacement" {
try dispatchAutomationWidgetAction(&harness.runtime, app, .{ .view_label = "canvas", .id = 12, .action = .select });
try dispatchAutomationWidgetAction(&harness.runtime, app, .{ .view_label = "canvas", .id = 14, .action = .select });
try dispatchAutomationWidgetAction(&harness.runtime, app, .{ .view_label = "canvas", .id = 17, .action = .select });
+ try dispatchAutomationWidgetAction(&harness.runtime, app, .{ .view_label = "canvas", .id = 19, .action = .select });
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expect(retained.findById(2).?.widget.state.selected);
@@ -726,6 +748,8 @@ test "runtime reconciles canvas control state across layout replacement" {
try std.testing.expect(retained.findById(14).?.widget.state.selected);
try std.testing.expect(!retained.findById(16).?.widget.state.selected);
try std.testing.expect(retained.findById(17).?.widget.state.selected);
+ try std.testing.expect(!retained.findById(18).?.widget.state.selected);
+ try std.testing.expect(retained.findById(19).?.widget.state.selected);
harness.runtime.invalidated = false;
harness.runtime.dirty_region_count = 0;
@@ -757,6 +781,12 @@ test "runtime reconciles canvas control state across layout replacement" {
try std.testing.expectEqual(@as(f32, 0), retained.findById(16).?.widget.value);
try std.testing.expect(retained.findById(17).?.widget.state.selected);
try std.testing.expectEqual(@as(f32, 1), retained.findById(17).?.widget.value);
+ // The unchanged source still declares 18 selected. Reconcile keeps
+ // the runtime selection on nested 19 until the source actually moves.
+ try std.testing.expect(!retained.findById(18).?.widget.state.selected);
+ try std.testing.expectEqual(@as(f32, 0), retained.findById(18).?.widget.value);
+ try std.testing.expect(retained.findById(19).?.widget.state.selected);
+ try std.testing.expectEqual(@as(f32, 1), retained.findById(19).?.widget.value);
try std.testing.expect(!harness.runtime.invalidated);
try std.testing.expectEqual(@as(usize, 0), harness.runtime.pendingDirtyRegions().len);
diff --git a/src/runtime/canvas_widget_events.zig b/src/runtime/canvas_widget_events.zig
index e2543177..b0145b38 100644
--- a/src/runtime/canvas_widget_events.zig
+++ b/src/runtime/canvas_widget_events.zig
@@ -2450,7 +2450,7 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
return true;
}
- pub fn updateCanvasWidgetControlFromPointer(self: *Runtime, pointer_event: CanvasWidgetPointerEvent) anyerror!void {
+ pub fn updateCanvasWidgetControlFromPointer(self: *Runtime, pointer_event: *CanvasWidgetPointerEvent) anyerror!void {
const index = runtimeFindViewIndex(self, pointer_event.window_id, pointer_event.view_label) orelse return;
if (self.views[index].kind != .gpu_surface) return;
@@ -2461,6 +2461,12 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
// one gesture. For controls hit directly (checkbox, slider,
// chip) both resolve to themselves — behavior is unchanged.
const resolved_pressed_id = canvasWidgetResolvedPressedId(self, index, self.views[index].canvas_widget_pressed_id);
+ const radio_selection = pointer_event.pointer.phase == .up and resolved_pressed_id != 0 and radio: {
+ const hit = pointer_event.press_target orelse break :radio false;
+ break :radio hit.kind == .radio and
+ hit.id == resolved_pressed_id and
+ hit.bounds.normalized().containsPoint(pointer_event.pointer.point);
+ };
const toggle_animation = self.views[index].canvasWidgetToggleAnimationForPointer(
pointer_event.pointer,
pointer_event.press_target,
@@ -2470,9 +2476,11 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
pointer_event.pointer,
pointer_event.press_target,
resolved_pressed_id,
- ) orelse return;
+ );
+ if (radio_selection) pointer_event.pointer.radio_selection_changed = dirty != null;
+ const dirty_bounds = dirty orelse return;
if (toggle_animation) |animation| try runtime_canvas_widget_display.RuntimeCanvasWidgetDisplay(Runtime).scheduleCanvasWidgetToggleAnimation(self, index, animation);
- if (canvasDirtyRegionForView(self.views[index].frame, dirty)) |dirty_region| {
+ if (canvasDirtyRegionForView(self.views[index].frame, dirty_bounds)) |dirty_region| {
self.invalidateFor(.state, dirty_region);
} else {
self.invalidateFor(.state, self.views[index].frame);
@@ -2480,18 +2488,27 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
_ = try runtime_canvas_widget_display.RuntimeCanvasWidgetDisplay(Runtime).refreshCanvasWidgetDisplayListIfOwned(self, index);
}
- pub fn updateCanvasWidgetControlFromKeyboard(self: *Runtime, keyboard_event: CanvasWidgetKeyboardEvent) anyerror!void {
+ pub fn updateCanvasWidgetControlFromKeyboard(self: *Runtime, keyboard_event: *CanvasWidgetKeyboardEvent) anyerror!void {
const index = runtimeFindViewIndex(self, keyboard_event.window_id, keyboard_event.view_label) orelse return;
if (self.views[index].kind != .gpu_surface) return;
const target = keyboard_event.target orelse return;
+ const radio_selection = if (target.kind == .radio)
+ if (canvas.widgetKeyboardControlIntent(self.views[index].widget_layout_nodes[target.index].widget, keyboard_event.keyboard)) |intent|
+ intent.kind == .select
+ else
+ false
+ else
+ false;
const toggle_animation = self.views[index].canvasWidgetToggleAnimationForKeyboard(target.id, keyboard_event.keyboard);
- const dirty = try self.views[index].applyCanvasWidgetControlKeyboard(target.id, keyboard_event.keyboard) orelse return;
+ const dirty = try self.views[index].applyCanvasWidgetControlKeyboard(target.id, keyboard_event.keyboard);
+ if (radio_selection) keyboard_event.keyboard.radio_selection_changed = dirty != null;
+ const dirty_bounds = dirty orelse return;
if (toggle_animation) |animation| try runtime_canvas_widget_display.RuntimeCanvasWidgetDisplay(Runtime).scheduleCanvasWidgetToggleAnimation(self, index, animation);
const previous_cursor = self.views[index].canvas_widget_cursor;
if (target.kind == .scroll_view) try reconcileCanvasWidgetRenderStateAfterScrollWithTooltipIntent(self, index, null);
if (previous_cursor != self.views[index].canvas_widget_cursor) try syncCanvasWidgetCursorForView(self, index);
- if (canvasDirtyRegionForView(self.views[index].frame, dirty)) |dirty_region| {
+ if (canvasDirtyRegionForView(self.views[index].frame, dirty_bounds)) |dirty_region| {
self.invalidateFor(.state, dirty_region);
} else {
self.invalidateFor(.state, self.views[index].frame);
@@ -2701,11 +2718,33 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
}
}
const direction: canvas.WidgetFocusDirection = if (input_event.modifiers.shift) .backward else .forward;
- const target = if (current_id) |id|
- self.views[index].canvasWidgetScopedFocusTarget(id, direction) orelse layout.focusTarget(current_id, direction) orelse return false
- else
- layout.focusTarget(current_id, direction) orelse return false;
- const moved = try setCanvasWidgetFocusFromKeyboardMoved(self, index, current_id, target.id, true);
+ var target = self.views[index].canvasWidgetRovingTabTarget(current_id, direction) orelse return false;
+ var moved = try setCanvasWidgetFocusFromKeyboardMoved(self, index, current_id, target.id, true);
+ if (!moved and target.id != (current_id orelse 0)) {
+ // A selected radio may be a valid LOGICAL group entry
+ // while fully hidden by a fixed clip. The focus setter
+ // first gave every runtime scroll ancestor a chance to
+ // reveal it; if it is still unreachable, keep the group
+ // in the Tab order through a visible radio. When the
+ // entire group is clipped, continue the one-stop walk
+ // from the failed entry to the next visible control.
+ if (self.views[index].canvasWidgetNodeIndexById(target.id)) |target_index| {
+ if (canvas_widget_runtime.canvasWidgetRovingTabScope(layout, target_index)) |scope| {
+ if (canvas_widget_runtime.canvasWidgetRovingTabVisibleEntryTarget(layout, scope)) |visible| {
+ if (visible.id != target.id) {
+ target = visible;
+ moved = try setCanvasWidgetFocusFromKeyboardMoved(self, index, current_id, target.id, true);
+ }
+ }
+ }
+ }
+ if (!moved) {
+ const fallback = self.views[index].canvasWidgetRovingTabTarget(target.id, direction) orelse return false;
+ if (fallback.id == target.id) return false;
+ target = fallback;
+ moved = try setCanvasWidgetFocusFromKeyboardMoved(self, index, current_id, target.id, true);
+ }
+ }
if (moved and
(canvasWidgetTerminalOwnsTabInput(layout, target) or
canvasWidgetCodeEditorOwnsTabInput(layout, target)))
@@ -2743,6 +2782,20 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
return try setCanvasWidgetFocusFromKeyboardMoved(self, index, current_id, target.id, preserve_focus_visible);
}
const target = canvasWidgetGroupFocusEdgeTarget(layout, focused, edge) orelse return false;
+ if (focused.kind == .radio and canvas_widget_runtime.canvasWidgetRadioGroupScopeIndex(layout, focused.index) != null) {
+ const retry_direction: canvas.WidgetFocusDirection = switch (edge) {
+ .first => .right,
+ .last => .left,
+ };
+ return try setCanvasWidgetRadioGroupFocusFromKeyboardMoved(
+ self,
+ index,
+ current_id,
+ target,
+ retry_direction,
+ preserve_focus_visible,
+ );
+ }
return try setCanvasWidgetFocusFromKeyboardMoved(self, index, current_id, target.id, preserve_focus_visible);
}
const direction = canvasWidgetSpatialFocusDirection(input_event) orelse return false;
@@ -2768,6 +2821,16 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
return try setCanvasWidgetFocusFromKeyboardMoved(self, index, current_id, target.id, preserve_focus_visible);
}
if (canvasWidgetGroupDirectionalFocusTarget(layout, focused, direction)) |target| {
+ if (focused.kind == .radio and canvas_widget_runtime.canvasWidgetRadioGroupScopeIndex(layout, focused.index) != null) {
+ return try setCanvasWidgetRadioGroupFocusFromKeyboardMoved(
+ self,
+ index,
+ current_id,
+ target,
+ direction,
+ preserve_focus_visible,
+ );
+ }
return try setCanvasWidgetFocusFromKeyboardMoved(self, index, current_id, target.id, preserve_focus_visible);
}
const target = layout.focusTarget(focused_id, direction) orelse return false;
@@ -2781,6 +2844,37 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
return target_id != 0 and target_id != previous and self.views[view_index].canvas_widget_focused_id == target_id;
}
+ /// Logical radio traversal admits scroll-clipped targets so the
+ /// focus setter can reveal them. A fixed clip cannot be scrolled;
+ /// after such a candidate stays unreachable, continue around the
+ /// same nearest group until a visible radio accepts focus or the
+ /// bounded walk returns to its starting point.
+ fn setCanvasWidgetRadioGroupFocusFromKeyboardMoved(
+ self: *Runtime,
+ view_index: usize,
+ previous_id: ?canvas.ObjectId,
+ initial_target: canvas.WidgetFocusTarget,
+ direction: canvas.WidgetFocusDirection,
+ focus_visible: bool,
+ ) anyerror!bool {
+ const previous = previous_id orelse 0;
+ var target = initial_target;
+ var attempts: usize = 0;
+ while (attempts < self.views[view_index].widget_layout_node_count) : (attempts += 1) {
+ if (target.id == 0 or target.id == previous) return false;
+ if (try setCanvasWidgetFocusFromKeyboardMoved(self, view_index, previous_id, target.id, focus_visible)) return true;
+
+ const next = canvas_widget_runtime.canvasWidgetRadioGroupDirectionalFocusTarget(
+ self.views[view_index].widgetLayoutTree(),
+ target,
+ direction,
+ ) orelse return false;
+ if (next.id == target.id or next.id == initial_target.id) return false;
+ target = next;
+ }
+ return false;
+ }
+
pub fn setCanvasWidgetFocusFromKeyboard(self: *Runtime, view_index: usize, target_id: canvas.ObjectId) anyerror!void {
try setCanvasWidgetFocusFromKeyboardWithVisibility(self, view_index, target_id, true);
}
diff --git a/src/runtime/canvas_widget_runtime.zig b/src/runtime/canvas_widget_runtime.zig
index be424e00..e2ab0699 100644
--- a/src/runtime/canvas_widget_runtime.zig
+++ b/src/runtime/canvas_widget_runtime.zig
@@ -1472,7 +1472,14 @@ pub fn canvasWidgetSpatialFocusAllowed(layout: canvas.WidgetLayoutTree, focused:
.data_cell => true,
.list_item, .menu_item => same_parent and (direction == .up or direction == .down),
.segmented_control => same_parent and (direction == .left or direction == .right),
- .radio => same_parent,
+ .radio => blk: {
+ const focused_scope = canvasWidgetRadioGroupScopeIndex(layout, focused.index);
+ const target_scope = canvasWidgetRadioGroupScopeIndex(layout, target.index);
+ if (focused_scope != null or target_scope != null) {
+ break :blk focused_scope != null and focused_scope == target_scope;
+ }
+ break :blk same_parent;
+ },
.button, .icon_button => same_parent and canvasWidgetParentAllowsHorizontalButtonFocus(canvasWidgetFocusParentKind(layout, focused)) and (direction == .left or direction == .right),
.toggle_button => same_parent and canvasWidgetParentAllowsHorizontalToggleFocus(canvasWidgetFocusParentKind(layout, focused)) and (direction == .left or direction == .right),
else => false,
@@ -1521,6 +1528,7 @@ pub const CanvasWidgetGroupDirection = enum {
};
pub fn canvasWidgetGroupDirectionalFocusTarget(layout: canvas.WidgetLayoutTree, focused: canvas.WidgetFocusTarget, direction: canvas.WidgetFocusDirection) ?canvas.WidgetFocusTarget {
+ if (canvasWidgetRadioGroupDirectionalFocusTarget(layout, focused, direction)) |target| return target;
if (focused.index >= layout.nodes.len) return null;
const parent_index = layout.nodes[focused.index].parent_index orelse return null;
if (parent_index >= layout.nodes.len) return null;
@@ -1543,10 +1551,6 @@ pub fn canvasWidgetGroupDirectionForFocus(parent_kind: canvas.WidgetKind, child_
canvasWidgetHorizontalGroupDirection(direction)
else
null,
- .radio_group => if (child_kind == .radio)
- canvasWidgetAnyAxisGroupDirection(direction)
- else
- null,
.list => if (child_kind == .list_item)
canvasWidgetVerticalGroupDirection(direction)
else
@@ -1559,6 +1563,163 @@ pub fn canvasWidgetGroupDirectionForFocus(parent_kind: canvas.WidgetKind, child_
};
}
+// --------------------------------------------------- radio-group focus
+//
+// Radios at ANY depth under their nearest `radio_group` ancestor form
+// one logical, roving-focus set. Node order is the authored/DFS order;
+// logical targets intentionally omit only scroll clipping so keyboard
+// focus can reveal an offscreen radio before committing to it.
+
+/// Index of the nearest `.radio_group` ancestor, or null for a bare
+/// radio (and for any other node outside a radio group).
+pub fn canvasWidgetRadioGroupScopeIndex(layout: canvas.WidgetLayoutTree, node_index: usize) ?usize {
+ if (node_index >= layout.nodes.len) return null;
+ var current = layout.nodes[node_index].parent_index;
+ while (current) |index| {
+ if (index >= layout.nodes.len) return null;
+ if (layout.nodes[index].widget.kind == .radio_group) return index;
+ current = layout.nodes[index].parent_index;
+ }
+ return null;
+}
+
+/// The radio-scoped form of a future-general roving Tab scope. Keeping
+/// the scope identity separate from traversal lets tabs/toolbars adopt
+/// the same entry/exit contract later without changing radio behavior.
+pub const CanvasWidgetRovingTabScope = struct {
+ kind: enum { radio_group },
+ index: usize,
+};
+
+pub fn canvasWidgetRovingTabScope(layout: canvas.WidgetLayoutTree, node_index: usize) ?CanvasWidgetRovingTabScope {
+ if (node_index >= layout.nodes.len or layout.nodes[node_index].widget.kind != .radio) return null;
+ return .{ .kind = .radio_group, .index = canvasWidgetRadioGroupScopeIndex(layout, node_index) orelse return null };
+}
+
+fn canvasWidgetRadioGroupFocusTarget(
+ layout: canvas.WidgetLayoutTree,
+ radio_group_index: usize,
+ node_index: usize,
+) ?canvas.WidgetFocusTarget {
+ if (node_index >= layout.nodes.len or layout.nodes[node_index].widget.kind != .radio) return null;
+ if (canvasWidgetRadioGroupScopeIndex(layout, node_index) != radio_group_index) return null;
+ return canvasWidgetLogicalFocusTarget(layout, node_index);
+}
+
+/// The group's one Tab entry: selected focusable radio, else first
+/// focusable radio. Selection may be represented by state or value.
+pub fn canvasWidgetRovingTabEntryTarget(layout: canvas.WidgetLayoutTree, scope: CanvasWidgetRovingTabScope) ?canvas.WidgetFocusTarget {
+ if (scope.index >= layout.nodes.len or layout.nodes[scope.index].widget.kind != .radio_group) return null;
+ const scope_depth = layout.nodes[scope.index].depth;
+ var first: ?canvas.WidgetFocusTarget = null;
+ var index = scope.index + 1;
+ while (index < layout.nodes.len and layout.nodes[index].depth > scope_depth) : (index += 1) {
+ const target = canvasWidgetRadioGroupFocusTarget(layout, scope.index, index) orelse continue;
+ if (first == null) first = target;
+ if (canvasWidgetSelectableSelected(layout.nodes[index].widget)) return target;
+ }
+ return first;
+}
+
+/// The flat Tab order position occupied by a radio-group scope. It is
+/// deliberately the first CURRENTLY VISIBLE radio in authored order,
+/// rather than the selected entry: selection may live after another
+/// nested radio group (or an ordinary control) and must not move this
+/// composite's position around those intervening Tab stops.
+pub fn canvasWidgetRovingTabStopTarget(layout: canvas.WidgetLayoutTree, scope: CanvasWidgetRovingTabScope) ?canvas.WidgetFocusTarget {
+ if (scope.index >= layout.nodes.len or layout.nodes[scope.index].widget.kind != .radio_group) return null;
+ const scope_depth = layout.nodes[scope.index].depth;
+ var index = scope.index + 1;
+ while (index < layout.nodes.len and layout.nodes[index].depth > scope_depth) : (index += 1) {
+ if (layout.nodes[index].widget.kind != .radio) continue;
+ if (canvasWidgetRadioGroupScopeIndex(layout, index) != scope.index) continue;
+ if (layout.focusTargetById(layout.nodes[index].widget.id)) |target| return target;
+ }
+ return null;
+}
+
+/// The group's best CURRENTLY VISIBLE Tab entry. The ordinary entry
+/// resolver above intentionally admits scroll-clipped logical targets so
+/// focus can reveal them. Callers use this narrower fallback only after a
+/// logical target could not be revealed (for example under a fixed
+/// `clip_content` card), keeping the group reachable without committing
+/// an invisible focus id.
+pub fn canvasWidgetRovingTabVisibleEntryTarget(layout: canvas.WidgetLayoutTree, scope: CanvasWidgetRovingTabScope) ?canvas.WidgetFocusTarget {
+ if (scope.index >= layout.nodes.len or layout.nodes[scope.index].widget.kind != .radio_group) return null;
+ const scope_depth = layout.nodes[scope.index].depth;
+ var first: ?canvas.WidgetFocusTarget = null;
+ var index = scope.index + 1;
+ while (index < layout.nodes.len and layout.nodes[index].depth > scope_depth) : (index += 1) {
+ if (layout.nodes[index].widget.kind != .radio) continue;
+ if (canvasWidgetRadioGroupScopeIndex(layout, index) != scope.index) continue;
+ const target = layout.focusTargetById(layout.nodes[index].widget.id) orelse continue;
+ if (first == null) first = target;
+ if (canvasWidgetSelectableSelected(layout.nodes[index].widget)) return target;
+ }
+ return first;
+}
+
+pub fn canvasWidgetRadioGroupDirectionalFocusTarget(
+ layout: canvas.WidgetLayoutTree,
+ focused: canvas.WidgetFocusTarget,
+ direction: canvas.WidgetFocusDirection,
+) ?canvas.WidgetFocusTarget {
+ if (focused.kind != .radio or focused.index >= layout.nodes.len) return null;
+ const scope_index = canvasWidgetRadioGroupScopeIndex(layout, focused.index) orelse return null;
+ const group_direction = canvasWidgetAnyAxisGroupDirection(direction) orelse return null;
+ return canvasWidgetRadioGroupAdjacentRadio(layout, scope_index, focused.index, group_direction) orelse focused;
+}
+
+pub fn canvasWidgetRadioGroupFocusEdgeTarget(
+ layout: canvas.WidgetLayoutTree,
+ focused: canvas.WidgetFocusTarget,
+ edge: CanvasWidgetGroupFocusEdge,
+) ?canvas.WidgetFocusTarget {
+ if (focused.kind != .radio or focused.index >= layout.nodes.len) return null;
+ const scope_index = canvasWidgetRadioGroupScopeIndex(layout, focused.index) orelse return null;
+ if (scope_index >= layout.nodes.len) return null;
+ const scope_depth = layout.nodes[scope_index].depth;
+ var last: ?canvas.WidgetFocusTarget = null;
+ var index = scope_index + 1;
+ while (index < layout.nodes.len and layout.nodes[index].depth > scope_depth) : (index += 1) {
+ const target = canvasWidgetRadioGroupFocusTarget(layout, scope_index, index) orelse continue;
+ if (edge == .first) return target;
+ last = target;
+ }
+ return last;
+}
+
+fn canvasWidgetRadioGroupAdjacentRadio(
+ layout: canvas.WidgetLayoutTree,
+ scope_index: usize,
+ focused_index: usize,
+ direction: CanvasWidgetGroupDirection,
+) ?canvas.WidgetFocusTarget {
+ if (scope_index >= layout.nodes.len) return null;
+ const scope_depth = layout.nodes[scope_index].depth;
+ var first: ?canvas.WidgetFocusTarget = null;
+ var last: ?canvas.WidgetFocusTarget = null;
+ var previous: ?canvas.WidgetFocusTarget = null;
+ var saw_focused = false;
+ var index = scope_index + 1;
+ while (index < layout.nodes.len and layout.nodes[index].depth > scope_depth) : (index += 1) {
+ if (index == focused_index) {
+ if (direction == .previous and previous != null) return previous;
+ saw_focused = true;
+ continue;
+ }
+ const target = canvasWidgetRadioGroupFocusTarget(layout, scope_index, index) orelse continue;
+ if (first == null) first = target;
+ last = target;
+ if (direction == .next and saw_focused) return target;
+ if (!saw_focused) previous = target;
+ }
+ return switch (direction) {
+ .previous => last,
+ .next => first,
+ };
+}
+
pub fn canvasWidgetHorizontalGroupDirection(direction: canvas.WidgetFocusDirection) ?CanvasWidgetGroupDirection {
return switch (direction) {
.left => .previous,
@@ -1796,6 +1957,7 @@ fn canvasWidgetTreeFirstChildRow(layout: canvas.WidgetLayoutTree, tree_index: us
}
pub fn canvasWidgetGroupFocusEdgeTarget(layout: canvas.WidgetLayoutTree, focused: canvas.WidgetFocusTarget, edge: CanvasWidgetGroupFocusEdge) ?canvas.WidgetFocusTarget {
+ if (canvasWidgetRadioGroupFocusEdgeTarget(layout, focused, edge)) |target| return target;
if (!canvasWidgetGroupHomeEndFocusKind(layout, focused)) return null;
if (focused.index >= layout.nodes.len) return null;
const parent_index = layout.nodes[focused.index].parent_index;
diff --git a/src/runtime/canvas_widget_split_tree_tests.zig b/src/runtime/canvas_widget_split_tree_tests.zig
index 8b3c3c5c..ae540239 100644
--- a/src/runtime/canvas_widget_split_tree_tests.zig
+++ b/src/runtime/canvas_widget_split_tree_tests.zig
@@ -651,6 +651,61 @@ test "tree arrow navigation reveals and focuses rows below a scroll viewport" {
try std.testing.expect(scrolled.findById(10).?.widget.value > 0);
}
+test "radio-group arrow navigation reveals selects and focuses an offscreen nested radio" {
+ const harness = try TestHarness().create(std.testing.allocator, .{});
+ defer harness.destroy(std.testing.allocator);
+ harness.null_platform.gpu_surfaces = true;
+ var app_state: ObservingApp = .{};
+ const app = app_state.app();
+ try harness.start(app);
+
+ _ = try harness.runtime.createView(.{
+ .window_id = 1,
+ .label = "canvas",
+ .kind = .gpu_surface,
+ .frame = geometry.RectF.init(0, 0, 240, 64),
+ });
+
+ const radios = [_]canvas.Widget{
+ .{ .id = 31, .kind = .radio, .frame = geometry.RectF.init(0, 0, 0, 28), .text = "One", .state = .{ .selected = true } },
+ .{ .id = 32, .kind = .radio, .frame = geometry.RectF.init(0, 0, 0, 28), .text = "Two" },
+ .{ .id = 33, .kind = .radio, .frame = geometry.RectF.init(0, 0, 0, 28), .text = "Three" },
+ .{ .id = 34, .kind = .radio, .frame = geometry.RectF.init(0, 0, 0, 28), .text = "Four" },
+ };
+ const nested = [_]canvas.Widget{.{
+ .kind = .column,
+ .layout = .{ .gap = 2 },
+ .children = &radios,
+ }};
+ const group = canvas.Widget{
+ .id = 30,
+ .kind = .radio_group,
+ .frame = geometry.RectF.init(0, 0, 0, 118),
+ .children = &nested,
+ };
+ const root = canvas.Widget{ .id = 20, .kind = .scroll_view, .children = &.{group} };
+ var nodes: [10]canvas.WidgetLayoutNode = undefined;
+ const layout = try canvas.layoutWidgetTree(root, geometry.RectF.init(0, 0, 240, 64), &nodes);
+ _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
+ const view = &harness.runtime.views[0];
+ view.canvas_widget_focused_id = 31;
+
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowdown" } });
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowdown" } });
+ try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = "canvas", .kind = .key_down, .key = "arrowdown" } });
+
+ try std.testing.expectEqual(@as(canvas.ObjectId, 34), view.canvas_widget_focused_id);
+ try std.testing.expectEqual(@as(canvas.ObjectId, 34), app_state.last_keyboard_target_id);
+ try std.testing.expect(app_state.last_keyboard_focus_moved);
+ const scrolled = try harness.runtime.canvasWidgetLayout(1, "canvas");
+ const viewport = scrolled.findById(20).?.frame.normalized();
+ const focused = scrolled.findById(34).?.frame.normalized();
+ try std.testing.expect(focused.y >= viewport.y);
+ try std.testing.expect(focused.maxY() <= viewport.maxY());
+ try std.testing.expect(!scrolled.findById(31).?.widget.state.selected);
+ try std.testing.expect(scrolled.findById(34).?.widget.state.selected);
+}
+
test "list arrow navigation reveals and focuses rows below a scroll viewport" {
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
diff --git a/src/runtime/canvas_widget_state.zig b/src/runtime/canvas_widget_state.zig
index e4ad09f1..4ec9cbc8 100644
--- a/src/runtime/canvas_widget_state.zig
+++ b/src/runtime/canvas_widget_state.zig
@@ -330,7 +330,18 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type {
.set_composition => try AutomationWidgetMethods(Runtime).composeAutomationCanvasWidgetText(self, app, index, action.id, .ime_set_composition, action.text),
.commit_composition => try AutomationWidgetMethods(Runtime).composeAutomationCanvasWidgetText(self, app, index, action.id, .ime_commit_composition, ""),
.cancel_composition => try AutomationWidgetMethods(Runtime).composeAutomationCanvasWidgetText(self, app, index, action.id, .ime_cancel_composition, ""),
- .select => try AutomationWidgetMethods(Runtime).selectAutomationCanvasWidget(self, index, action.id),
+ .select => {
+ const node_index = self.views[index].canvasWidgetNodeIndexById(action.id) orelse return error.InvalidCommand;
+ if (self.views[index].widget_layout_nodes[node_index].widget.kind == .radio) {
+ // Radio selection is activation, not an echo-only
+ // retained-state write: drive the same Space path
+ // as keyboard and pointer input so a documented
+ // `on_change` handler updates the app model too.
+ try AutomationWidgetMethods(Runtime).dispatchAutomationWidgetKey(self, app, index, action.id, "space");
+ } else {
+ try AutomationWidgetMethods(Runtime).selectAutomationCanvasWidget(self, index, action.id);
+ }
+ },
.drag => try AutomationWidgetMethods(Runtime).dispatchAutomationCanvasWidgetDrag(self, app, index, action.id, action.text),
.drop_files => try AutomationWidgetMethods(Runtime).dispatchAutomationCanvasWidgetFileDrop(self, app, index, action.id, action.text),
.dismiss => try AutomationWidgetMethods(Runtime).dismissAutomationCanvasWidget(self, app, index, action.id),
diff --git a/src/runtime/gpu_surface_events.zig b/src/runtime/gpu_surface_events.zig
index 1d869f4f..5ebdffdf 100644
--- a/src/runtime/gpu_surface_events.zig
+++ b/src/runtime/gpu_surface_events.zig
@@ -9,6 +9,7 @@ const runtime_canvas_widget_context_menu = @import("canvas_widget_context_menu.z
const runtime_canvas_widget_display = @import("canvas_widget_display.zig");
const runtime_canvas_widget_events = @import("canvas_widget_events.zig");
const runtime_canvas_widget_scroll_drivers = @import("canvas_widget_scroll_drivers.zig");
+const canvas_widget_runtime = @import("canvas_widget_runtime.zig");
const canvasWidgetInputBatchesDisplayListRefresh = canvas_frame_helpers.canvasWidgetInputBatchesDisplayListRefresh;
const gpuSurfaceFrameEventFromGpuFrame = canvas_frame_helpers.gpuSurfaceFrameEventFromGpuFrame;
@@ -361,7 +362,7 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type {
// activation (checkbox/toggle state), not only app Msgs and
// commands. Geometry controls applied their live resize on
// move; a terminal drag owes no release mutation.
- if (!widget_drag_terminal) try CanvasWidgetEventMethods().updateCanvasWidgetControlFromPointer(self, pointer_event.*);
+ if (!widget_drag_terminal) try CanvasWidgetEventMethods().updateCanvasWidgetControlFromPointer(self, pointer_event);
try CanvasWidgetEventMethods().updateCanvasWidgetInteractionFromPointer(self, pointer_event.*);
// The text pass may stamp a caret/selection or clear
// edit onto the event for the app dispatch below.
@@ -448,6 +449,28 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type {
// from "an arrow landed here in place".
if (widget_keyboard_event) |*keyboard_event| {
keyboard_event.keyboard.focus_moved = widget_focus_moved;
+ // Nearest-radio-group navigation owns its key even when
+ // Home/End names the current edge or a one-member group
+ // wraps in place. Selection is a separate stamp: a real
+ // focus move selects the landed radio, and an in-place
+ // move selects only an unchecked current radio. Bare
+ // radios preserve their legacy focus-only spatial
+ // behavior, and Tab entry never synthesizes a selection.
+ const view_index = runtimeFindViewIndex(self, input_event.window_id, input_event.label).?;
+ const layout = self.views[view_index].widgetLayoutTree();
+ const radio_group_navigation = navigation: {
+ const target = keyboard_event.target orelse break :navigation false;
+ if (target.kind != .radio) break :navigation false;
+ if (canvas_widget_runtime.canvasWidgetRadioGroupScopeIndex(layout, target.index) == null) break :navigation false;
+ break :navigation canvas_widget_runtime.canvasWidgetGroupFocusEdgeFromInput(input_event) != null or
+ canvas_widget_runtime.canvasWidgetSpatialFocusDirection(input_event) != null;
+ };
+ keyboard_event.keyboard.radio_group_navigation = radio_group_navigation;
+ if (radio_group_navigation) {
+ const target = keyboard_event.target.?;
+ keyboard_event.keyboard.radio_group_selection = widget_focus_moved or
+ !canvas_widget_runtime.canvasWidgetSelectableSelected(layout.nodes[target.index].widget);
+ }
}
// Clipboard shortcuts resolve against the raw input (copy has
// no routed target when a static text selection is live) and
@@ -471,7 +494,7 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type {
// armed/shown tooltip before the control mutation and
// app dispatch observe the input.
try CanvasWidgetEventMethods().updateCanvasTooltipIntentForKeyboardActivation(self, keyboard_event.*);
- try CanvasWidgetEventMethods().updateCanvasWidgetControlFromKeyboard(self, keyboard_event.*);
+ try CanvasWidgetEventMethods().updateCanvasWidgetControlFromKeyboard(self, keyboard_event);
try CanvasWidgetEventMethods().updateCanvasWidgetTextFromKeyboard(self, keyboard_event);
}
// An IME sequence belongs to whoever it STARTED over: a
diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig
index 70b2e9fb..c6fb2e44 100644
--- a/src/runtime/ui_app.zig
+++ b/src/runtime/ui_app.zig
@@ -4983,7 +4983,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
// `on_double_press` handler (falling back to the ordinary
// press), while its first release already dispatched the
// single press — select-then-act, the list convention.
- if (tree.msgForPointerClick(target.id, pointer_event.pointer.phase, pointer_event.pointer.click_count)) |msg| {
+ if (tree.msgForPointerEvent(target.id, pointer_event.pointer)) |msg| {
try self.dispatch(runtime, pointer_event.window_id, msg);
}
}
@@ -5829,6 +5829,12 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
}
return;
}
+ // A radio group owns Arrow/Home/End even when the
+ // requested target is already focused and selected.
+ // That in-place case deliberately has no selection
+ // intent (and therefore no duplicate on-change), but
+ // it must still stop before the app-level key map.
+ if (keyboard_event.keyboard.radio_group_navigation) return;
}
}
}
diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig
index 8f750992..82844532 100644
--- a/src/runtime/ui_app_tests.zig
+++ b/src/runtime/ui_app_tests.zig
@@ -70,6 +70,43 @@ fn counterOptions() CounterApp.Options {
};
}
+const RadioModel = struct {
+ choice: u8 = 0,
+ change_count: u32 = 0,
+};
+
+const RadioMsg = union(enum) {
+ choose_first,
+ choose_second,
+};
+
+const RadioApp = ui_app_model.UiApp(RadioModel, RadioMsg);
+
+fn radioUpdate(model: *RadioModel, msg: RadioMsg) void {
+ model.choice = switch (msg) {
+ .choose_first => 0,
+ .choose_second => 1,
+ };
+ model.change_count += 1;
+}
+
+fn radioView(ui: *RadioApp.Ui, model: *const RadioModel) RadioApp.Ui.Node {
+ return ui.el(.radio_group, .{ .semantics = .{ .label = "Plan" } }, .{
+ ui.el(.radio, .{ .text = "Free", .checked = model.choice == 0, .on_change = .choose_first }, .{}),
+ ui.el(.radio, .{ .text = "Pro", .checked = model.choice == 1, .on_change = .choose_second }, .{}),
+ });
+}
+
+fn radioOptions() RadioApp.Options {
+ return .{
+ .name = "ui-app-radio",
+ .scene = counter_scene,
+ .canvas_label = canvas_label,
+ .update = radioUpdate,
+ .view = radioView,
+ };
+}
+
const software_counter_views = [_]app_manifest.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .software },
};
@@ -2370,6 +2407,41 @@ fn installCounterApp(harness: anytype, app: core.App) !void {
} });
}
+test "radio accessibility selection dispatches change once per retained transition" {
+ const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
+ defer harness.destroy(std.testing.allocator);
+ harness.null_platform.gpu_surfaces = true;
+
+ const app_state = try std.testing.allocator.create(RadioApp);
+ defer std.testing.allocator.destroy(app_state);
+ app_state.* = RadioApp.init(std.heap.page_allocator, .{}, radioOptions());
+ defer app_state.deinit();
+ const app = app_state.app();
+ try installCounterApp(harness, app);
+
+ const pro_id = findWidgetIdByText(app_state.tree.?, .radio, "Pro").?;
+ _ = try harness.runtime.dispatchCanvasWidgetAccessibilityAction(app, 1, canvas_label, .{
+ .id = pro_id,
+ .action = .select,
+ });
+ try std.testing.expectEqual(@as(u8, 1), app_state.model.choice);
+ try std.testing.expectEqual(@as(u32, 1), app_state.model.change_count);
+ try std.testing.expect((try harness.runtime.canvasWidgetLayout(1, canvas_label)).findById(pro_id).?.widget.state.selected);
+
+ // AX selection, pointer activation, and Space all still activate the
+ // radio, but an already-selected control has no new `on_change` edge.
+ _ = try harness.runtime.dispatchCanvasWidgetAccessibilityAction(app, 1, canvas_label, .{
+ .id = pro_id,
+ .action = .select,
+ });
+ var command_buffer: [96]u8 = undefined;
+ const click = try std.fmt.bufPrint(&command_buffer, "widget-click {s} {d}", .{ canvas_label, pro_id });
+ try harness.runtime.dispatchAutomationCommand(app, click);
+ try harness.runtime.dispatchAutomationCommand(app, "widget-key counter-canvas space");
+ try std.testing.expectEqual(@as(u8, 1), app_state.model.choice);
+ try std.testing.expectEqual(@as(u32, 1), app_state.model.change_count);
+}
+
test "the fragment watch reloads a compiled fragment embedded in a Zig view" {
const io = std.testing.io;
const cwd = std.Io.Dir.cwd();
diff --git a/src/runtime/view.zig b/src/runtime/view.zig
index 6a6d753b..f76bb2de 100644
--- a/src/runtime/view.zig
+++ b/src/runtime/view.zig
@@ -981,6 +981,7 @@ pub const RuntimeView = struct {
pub const canvasWidgetTopmostAnchoredDismissibleIndex = CanvasWidgetTreeMethods.canvasWidgetTopmostAnchoredDismissibleIndex;
pub const canvasWidgetRouteDescendsFromIndex = CanvasWidgetTreeMethods.canvasWidgetRouteDescendsFromIndex;
pub const canvasWidgetScopedFocusTarget = CanvasWidgetTreeMethods.canvasWidgetScopedFocusTarget;
+ pub const canvasWidgetRovingTabTarget = CanvasWidgetTreeMethods.canvasWidgetRovingTabTarget;
pub const canvasWidgetFocusTargetInScope = CanvasWidgetTreeMethods.canvasWidgetFocusTargetInScope;
pub const canvasWidgetForwardFocusTargetInScope = CanvasWidgetTreeMethods.canvasWidgetForwardFocusTargetInScope;
pub const canvasWidgetBackwardFocusTargetInScope = CanvasWidgetTreeMethods.canvasWidgetBackwardFocusTargetInScope;
diff --git a/src/runtime/view_widget_control.zig b/src/runtime/view_widget_control.zig
index 9c365e2e..1da3d9c5 100644
--- a/src/runtime/view_widget_control.zig
+++ b/src/runtime/view_widget_control.zig
@@ -379,6 +379,24 @@ pub fn RuntimeViewCanvasWidgetControl(comptime RuntimeView: type) type {
dirty = unionRects(dirty, self.canvasWidgetDirtyBounds(row_index, node.frame));
changed = true;
}
+ } else if (selected and widget.kind == .radio) {
+ // A radio group is one logical selection scope even when
+ // layout containers wrap its radios. Nearest-ancestor
+ // resolution also isolates nested radio groups. A bare
+ // radio deliberately falls back to its direct parent.
+ const scope = canvas_widget_runtime.canvasWidgetRadioGroupScopeIndex(self.widgetLayoutTree(), index);
+ const parent_index = self.widget_layout_nodes[index].parent_index;
+ for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |*node, radio_index| {
+ if (radio_index == index or node.widget.kind != .radio) continue;
+ if (scope) |radio_group_index| {
+ if (canvas_widget_runtime.canvasWidgetRadioGroupScopeIndex(self.widgetLayoutTree(), radio_index) != radio_group_index) continue;
+ } else if (node.parent_index != parent_index or canvas_widget_runtime.canvasWidgetRadioGroupScopeIndex(self.widgetLayoutTree(), radio_index) != null) continue;
+ if (!canvasWidgetSelectableSelected(node.widget)) continue;
+ node.widget.state.selected = false;
+ node.widget.value = 0;
+ dirty = unionRects(dirty, self.canvasWidgetDirtyBounds(radio_index, node.frame));
+ changed = true;
+ }
} else if (selected and canvasWidgetSelectionClearsSiblings(widget.kind)) {
const parent_index = self.widget_layout_nodes[index].parent_index;
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |*node, sibling_index| {
diff --git a/src/runtime/view_widget_tree.zig b/src/runtime/view_widget_tree.zig
index c2b631d4..844d9a46 100644
--- a/src/runtime/view_widget_tree.zig
+++ b/src/runtime/view_widget_tree.zig
@@ -991,6 +991,70 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
return self.canvasWidgetFocusTargetInScope(surface_index, current_index, direction);
}
+ /// Radio groups contribute one stop to the flat Tab order. The
+ /// first currently reachable radio fixes that stop's authored
+ /// position; entering retargets to the selected radio (or first
+ /// focusable radio), while leaving resumes from the fixed stop.
+ /// That distinction keeps nested/interleaved scopes ordered even
+ /// when an outer group's selected radio appears after an inner
+ /// group. Existing anchored-surface focus traps remain authoritative.
+ pub fn canvasWidgetRovingTabTarget(
+ self: *const RuntimeView,
+ current_id: ?canvas.ObjectId,
+ direction: canvas.WidgetFocusDirection,
+ ) ?canvas.WidgetFocusTarget {
+ const layout = self.widgetLayoutTree();
+ const current_scope = if (current_id) |id|
+ if (self.canvasWidgetNodeIndexById(id)) |index|
+ canvas_widget_runtime.canvasWidgetRovingTabScope(layout, index)
+ else
+ null
+ else
+ null;
+
+ var walk_id = current_id;
+ if (current_scope) |scope| {
+ if (canvas_widget_runtime.canvasWidgetRovingTabStopTarget(layout, scope)) |stop| {
+ walk_id = stop.id;
+ }
+ }
+ var attempts: usize = 0;
+ while (attempts <= self.widget_layout_node_count) : (attempts += 1) {
+ const target = if (walk_id) |id|
+ self.canvasWidgetScopedFocusTarget(id, direction) orelse layout.focusTarget(walk_id, direction) orelse return null
+ else
+ layout.focusTarget(null, direction) orelse return null;
+
+ if (canvas_widget_runtime.canvasWidgetRovingTabScope(layout, target.index)) |target_scope| {
+ // Radios after the first visible member do not create
+ // extra flat-order stops. This also prevents a later
+ // outer-group radio from retargeting backward across a
+ // nested group's stop and forming a Tab cycle.
+ const target_stop = canvas_widget_runtime.canvasWidgetRovingTabStopTarget(layout, target_scope) orelse {
+ walk_id = target.id;
+ continue;
+ };
+ if (target.id != target_stop.id) {
+ walk_id = target.id;
+ continue;
+ }
+ if (current_scope) |scope| {
+ if (target_scope.kind == scope.kind and target_scope.index == scope.index) {
+ walk_id = target.id;
+ continue;
+ }
+ }
+ return canvas_widget_runtime.canvasWidgetRovingTabEntryTarget(layout, target_scope) orelse target;
+ }
+ return target;
+ }
+
+ // A trapped surface whose only focusable composite is this
+ // radio group wraps onto the group's one entry stop.
+ if (current_scope) |scope| return canvas_widget_runtime.canvasWidgetRovingTabEntryTarget(layout, scope);
+ return null;
+ }
+
pub fn canvasWidgetFocusTargetInScope(
self: *const RuntimeView,
surface_index: usize,
diff --git a/src/runtime/widget_bridge.zig b/src/runtime/widget_bridge.zig
index 3a4446ca..82bcc5df 100644
--- a/src/runtime/widget_bridge.zig
+++ b/src/runtime/widget_bridge.zig
@@ -57,6 +57,7 @@ pub fn widgetRoleName(role: canvas.WidgetRole) []const u8 {
.tab => "tab",
.checkbox => "checkbox",
.radio => "radio",
+ .radiogroup => "radiogroup",
.switch_control => "switch",
.slider => "slider",
.progressbar => "progressbar",
@@ -91,6 +92,7 @@ pub fn platformWidgetAccessibilityRole(role: canvas.WidgetRole) platform.WidgetA
.tab => .tab,
.checkbox => .checkbox,
.radio => .radio,
+ .radiogroup => .radiogroup,
.switch_control => .switch_control,
.slider => .slider,
.progressbar => .progressbar,
diff --git a/tools/native-sdk/markup.zig b/tools/native-sdk/markup.zig
index 17427fe7..12d2991e 100644
--- a/tools/native-sdk/markup.zig
+++ b/tools/native-sdk/markup.zig
@@ -449,6 +449,7 @@ fn isA11yErrorMessage(message: []const u8) bool {
ui_markup.a11y_unlabeled_control_message,
ui_markup.a11y_icon_only_message,
ui_markup.a11y_unlabeled_editable_message,
+ ui_markup.a11y_unlabeled_radiogroup_message,
ui_markup.a11y_unknown_role_message,
ui_markup.a11y_container_role_message,
};
@@ -634,8 +635,8 @@ fn usage() void {
\\bundled face renders as tofu boxes on reference paths - the error
\\names the character; register a covering font and bind the text
\\from the model, or use icons or plain words), and accessibility
- \\(unnamed interactive controls, icon-only controls without labels,
- \\and role misuse are errors - a screen reader user is blocked;
+ \\(unnamed interactive controls or radiogroups, icon-only controls
+ \\without labels, and role misuse are errors - a screen reader user is blocked;
\\unnamed images and redundant labels are warnings).
\\
\\Inside an app directory with a fresh zig-out/model-contract.zon
diff --git a/tools/native-sdk/markup_docs.zig b/tools/native-sdk/markup_docs.zig
index 317a5195..a46ab673 100644
--- a/tools/native-sdk/markup_docs.zig
+++ b/tools/native-sdk/markup_docs.zig
@@ -32,7 +32,7 @@ pub const element_docs = [_]Doc{
.{ .name = "badge", .doc = "Text leaf badge; content supports {} interpolation." },
.{ .name = "button", .doc = "Text-bearing control; the label is the text content. Dispatch with on-press. icon draws a vector icon inline before the label (icon-only when the content is empty; give it a label) — one hit target, one enabled/disabled tint." },
.{ .name = "checkbox", .doc = "Value control; bind checked, dispatch with on-toggle." },
- .{ .name = "radio", .doc = "Value control; bind checked or selected, dispatch with on-toggle." },
+ .{ .name = "radio", .doc = "Single-choice value control; bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility." },
.{ .name = "toggle", .doc = "Text-bearing toggle control; the label is the text content." },
.{ .name = "slider", .doc = "Value control; bind value, dispatch with on-change." },
.{ .name = "progress", .doc = "Value control; bind value." },
@@ -47,7 +47,7 @@ pub const element_docs = [_]Doc{
.{ .name = "breadcrumb", .doc = "Row container for a breadcrumb trail; children flow horizontally." },
.{ .name = "button-group", .doc = "Row container grouping buttons; children flow horizontally." },
.{ .name = "pagination", .doc = "Row container for pagination controls; children flow horizontally." },
- .{ .name = "radio-group", .doc = "Row container grouping radio controls; children flow horizontally." },
+ .{ .name = "radio-group", .doc = "Logical radiogroup: descendant radios at any nesting depth share one Tab stop and selection; arrows plus Home/End move focus and selection across the scope." },
.{ .name = "tabs", .doc = "Row container for a tab strip; children (buttons with selected) flow horizontally." },
.{ .name = "toggle-group", .doc = "Row container grouping toggle-buttons; children flow horizontally." },
.{ .name = "table", .doc = "Vertical table container; children are table-row elements." },