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