Implement logical canvas radio groups (#361)
* Implement logical canvas radio groups - Scope nested radios as one roving-focus, single-selection group. - Align radio keyboard, pointer, and handler dispatch behavior. - Expose radiogroup accessibility semantics and document the contract. * Fix radio group accessibility edge cases * Fix radio group focus traversal edge cases * Fix radio group keyboard and naming semantics * fix: preserve radio selection semantics
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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).
|
||||
|
||||
<ComponentPreview name="radio-group" alt="A radio group rendered by the engine" caption="a radio group with one selected and one disabled option" />
|
||||
|
||||
## Markup
|
||||
|
||||
```html
|
||||
<radio-group gap="12">
|
||||
<radio checked="{density == default}" on-toggle="set_default" text="Default" />
|
||||
<radio checked="{density == comfortable}" on-toggle="set_comfortable" text="Comfortable" />
|
||||
<radio-group gap="12" label="Density">
|
||||
<radio checked="{density == default}" on-change="set_default" text="Default" />
|
||||
<row>
|
||||
<radio checked="{density == comfortable}" on-change="set_comfortable" text="Comfortable" />
|
||||
</row>
|
||||
<radio checked="{density == compact}" disabled="true" text="Compact" />
|
||||
</radio-group>
|
||||
```
|
||||
@@ -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",
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-1
@@ -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);
|
||||
|
||||
@@ -28,6 +28,7 @@ pub const MobileWidgetRole = enum(c_int) {
|
||||
slider = 18,
|
||||
progressbar = 19,
|
||||
radio = 20,
|
||||
radiogroup = 21,
|
||||
};
|
||||
|
||||
pub const MobileWidgetFlag = enum(u32) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<NSAccessibilityElement *> *widgetAccessibilityElements;
|
||||
@property(nonatomic, strong) NSArray<NSAccessibilityElement *> *widgetAccessibilityRootElements;
|
||||
@property(nonatomic, strong) NSMutableArray<NativeSdkScrollDriverView *> *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<NSAccessibilityElement *> *elements = [NSMutableArray arrayWithCapacity:count];
|
||||
NSMutableArray<NativeSdkWidgetAccessibilityElement *> *elements = [NSMutableArray arrayWithCapacity:count];
|
||||
NSMutableArray<NSNumber *> *parentIds = [NSMutableArray arrayWithCapacity:count];
|
||||
NSMutableDictionary<NSNumber *, NativeSdkWidgetAccessibilityElement *> *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<NSAccessibilityElement *> *rootElements = [NSMutableArray arrayWithCapacity:count];
|
||||
NSMutableDictionary<NSNumber *, NSMutableArray<NSAccessibilityElement *> *> *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<NSAccessibilityElement *> *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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2353,6 +2353,7 @@ pub const WidgetAccessibilityRole = enum(c_int) {
|
||||
slider = 18,
|
||||
progressbar = 19,
|
||||
radio = 20,
|
||||
radiogroup = 21,
|
||||
};
|
||||
|
||||
pub const WidgetAccessibilityActions = struct {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "<row>\n <text-field on-input=\"draft\" />\n</row>", .message = markup.a11y_unlabeled_editable_message },
|
||||
.{ .source = "<row>\n <input text=\"{query}\" on-input=\"draft\" />\n</row>", .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 = "<radio-group>\n <radio label=\"Default\" />\n</radio-group>", .message = markup.a11y_unlabeled_radiogroup_message },
|
||||
.{ .source = "<row role=\"radiogroup\">\n <radio label=\"Default\" />\n</row>", .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 = "<row>\n <checkbox label=\" \" on-toggle=\"select\" />\n</row>", .message = markup.a11y_unlabeled_control_message },
|
||||
@@ -514,6 +519,8 @@ test "the a11y lint: unnamed controls, icon-only controls, and unnamed text entr
|
||||
"<row>\n <textarea label=\"Body\" on-input=\"draft\" />\n</row>",
|
||||
"<row>\n <select on-press=\"open\">Newest first</select>\n</row>",
|
||||
"<row>\n <select text=\"{choice}\" on-press=\"open\"/>\n</row>",
|
||||
"<radio-group label=\"Density\">\n <radio label=\"Default\" />\n</radio-group>",
|
||||
"<row role=\"radiogroup\" label=\"{question}\">\n <radio label=\"Default\" />\n</row>",
|
||||
};
|
||||
for (clean) |source| {
|
||||
var parser = markup.Parser.init(arena, source);
|
||||
|
||||
@@ -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 =
|
||||
\\ <input text="{query}" placeholder="Name" autofocus="true" on-input="query_edit" on-submit="submit_query" grow="1" />
|
||||
\\ <combobox text="{query}" placeholder="Search fruit" on-input="query_edit" />
|
||||
\\ </row>
|
||||
\\ <radio-group gap="4">
|
||||
\\ <radio checked="{bold}" on-toggle="toggle_bold" label="Bold" />
|
||||
\\ <radio-group gap="4" label="Formatting">
|
||||
\\ <radio checked="{bold}" on-change="toggle_bold" label="Bold" />
|
||||
\\ </radio-group>
|
||||
\\ <accordion text="Details" selected="{details_open}" on-toggle="toggle_details" padding="8">
|
||||
\\ <text>More info</text>
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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("<h1>Hello</h1>") };
|
||||
return .{
|
||||
.context = self,
|
||||
.name = "gpu-widget-house-group-navigation",
|
||||
.source = platform.WebViewSource.html("<h1>Hello</h1>"),
|
||||
.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("<h1>Hello</h1>") };
|
||||
}
|
||||
};
|
||||
|
||||
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("<h1>Hello</h1>") };
|
||||
}
|
||||
};
|
||||
|
||||
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("<h1>Hello</h1>") };
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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." },
|
||||
|
||||
Reference in New Issue
Block a user