diff --git a/api/cpp/include/slint-testing.h b/api/cpp/include/slint-testing.h index 05cb7f5529e..457cb15fc10 100644 --- a/api/cpp/include/slint-testing.h +++ b/api/cpp/include/slint-testing.h @@ -382,6 +382,52 @@ class ElementHandle return std::nullopt; } + /// Returns the accessible-expanded of that element, if any. + std::optional accessible_expanded() const + { + if (auto result = get_accessible_string_property( + cbindgen_private::AccessibleStringProperty::Expanded)) { + if (*result == "true") + return true; + else if (*result == "false") + return false; + } + return std::nullopt; + } + /// Returns the accessible-expandable of that element, if any. + std::optional accessible_expandable() const + { + if (auto result = get_accessible_string_property( + cbindgen_private::AccessibleStringProperty::Expandable)) { + if (*result == "true") + return true; + else if (*result == "false") + return false; + } + return std::nullopt; + } + + /// Invokes the expand accessibility action of that element + /// (`accessible-action-expand`). + void invoke_accessible_expand_action() const + { + if (inner.element_index != 0) + return; + if (auto item = private_api::upgrade_item_weak(inner.item)) { + union ExpandActionHelper { + cbindgen_private::AccessibilityAction action; + ExpandActionHelper() + { + action.tag = cbindgen_private::AccessibilityAction::Tag::Expand; + } + ~ExpandActionHelper() { } + + } action; + item->item_tree.vtable()->accessibility_action(item->item_tree.borrow(), item->index, + &action.action); + } + } + /// Sets the accessible-value of that element. /// /// Setting the value will invoke the `accessible-action-set-value` callback. diff --git a/docs/astro/src/content/docs/reference/common.mdx b/docs/astro/src/content/docs/reference/common.mdx index 0a7a55b58c9..8b142a188c8 100644 --- a/docs/astro/src/content/docs/reference/common.mdx +++ b/docs/astro/src/content/docs/reference/common.mdx @@ -207,6 +207,17 @@ The description for the current element. Whether the element is enabled or not. This maps to the "enabled" state of most widgets. (default value: `true`) +### accessible-expandable + +Whether the element can be expanded or not. + + +### accessible-expanded + +Whether the element is expanded or not. Applies to combo boxes, menu items, +tree view items and other widgets. + + ### accessible-label The label for an interactive element. (default value: empty for most elements, or the value of the `text` property for Text elements) @@ -272,3 +283,6 @@ Invoked when the user requests to increment the value. ### accessible-action-decrement() Invoked when the user requests to decrement the value. + +### accessible-action-expand() +Invoked when the user requests to expand the widget (eg: disclose the list of available choices for a combo box). diff --git a/internal/backends/qt/qt_accessible.rs b/internal/backends/qt/qt_accessible.rs index 752e5911c7e..abb6da0627d 100644 --- a/internal/backends/qt/qt_accessible.rs +++ b/internal/backends/qt/qt_accessible.rs @@ -30,6 +30,8 @@ const VALUE_MINIMUM: u32 = CHECKED + 1; const VALUE_MAXIMUM: u32 = VALUE_MINIMUM + 1; const VALUE_STEP: u32 = VALUE_MAXIMUM + 1; const CHECKABLE: u32 = VALUE_STEP + 1; +const EXPANDABLE: u32 = CHECKABLE + 1; +const EXPANDED: u32 = EXPANDABLE + 1; pub struct AccessibleItemPropertiesTracker { obj: *mut c_void, @@ -208,6 +210,8 @@ impl SlintAccessibleItemData { if let Some(item_rc) = item.upgrade() { item_rc.accessible_string_property(AccessibleStringProperty::Checkable); item_rc.accessible_string_property(AccessibleStringProperty::Checked); + item_rc.accessible_string_property(AccessibleStringProperty::Expandable); + item_rc.accessible_string_property(AccessibleStringProperty::Expanded); } }); } @@ -267,6 +271,8 @@ cpp! {{ const uint32_t VALUE_MAXIMUM { VALUE_MINIMUM + 1 }; const uint32_t VALUE_STEP { VALUE_MAXIMUM + 1 }; const uint32_t CHECKABLE { VALUE_STEP + 1 }; + const uint32_t EXPANDABLE { CHECKABLE + 1 }; + const uint32_t EXPANDED { EXPANDABLE + 1 }; // ------------------------------------------------------------------------------ // Helper: @@ -362,6 +368,8 @@ cpp! {{ VALUE_MAXIMUM => item.accessible_string_property(AccessibleStringProperty::ValueMaximum), VALUE_STEP => item.accessible_string_property(AccessibleStringProperty::ValueStep), CHECKABLE => item.accessible_string_property(AccessibleStringProperty::Checkable), + EXPANDABLE => item.accessible_string_property(AccessibleStringProperty::Expandable), + EXPANDED => item.accessible_string_property(AccessibleStringProperty::Expanded), _ => None, }; if let Some(string) = string { @@ -621,6 +629,14 @@ cpp! {{ state.focused = has_focus_delegation; state.checked = (checked == "true") ? 1 : 0; state.checkable = (item_string_property(m_data, CHECKABLE) == "true") ? 1 : 0; + if (item_string_property(m_data, EXPANDABLE) == "true") { + state.expandable = 1; + if (item_string_property(m_data, EXPANDED) == "true") { + state.expanded = 1; + } else { + state.collapsed = 1; + } + } return state; /* FIXME */ } @@ -669,6 +685,8 @@ cpp! {{ actions << QAccessibleActionInterface::increaseAction(); if (supported & rust!(Slint_accessible_item_an3 [] -> SupportedAccessibilityAction as "uint" { SupportedAccessibilityAction::Decrement })) actions << QAccessibleActionInterface::decreaseAction(); + if (supported & rust!(Slint_accessible_item_an4 [] -> SupportedAccessibilityAction as "uint" { SupportedAccessibilityAction::Expand })) + actions << QAccessibleActionInterface::pressAction(); return actions; } @@ -676,7 +694,12 @@ cpp! {{ if (actionName == QAccessibleActionInterface::pressAction()) { rust!(Slint_accessible_item_do_action1 [m_data: Pin<&SlintAccessibleItemData> as "void*"] { let Some(item) = m_data.item.upgrade() else {return}; - item.accessible_action(&AccessibilityAction::Default); + let supported_actions = item.supported_accessibility_actions(); + if supported_actions.contains(SupportedAccessibilityAction::Expand) { + item.accessible_action(&AccessibilityAction::Expand); + } else { + item.accessible_action(&AccessibilityAction::Default); + } }); } else if (actionName == QAccessibleActionInterface::increaseAction()) { rust!(Slint_accessible_item_do_action2 [m_data: Pin<&SlintAccessibleItemData> as "void*"] { diff --git a/internal/backends/testing/search_api.rs b/internal/backends/testing/search_api.rs index b3240a0b03d..df70ea3bf4d 100644 --- a/internal/backends/testing/search_api.rs +++ b/internal/backends/testing/search_api.rs @@ -647,6 +647,28 @@ impl ElementHandle { }) } + /// Returns the value of the `accessible-expanded` property, if present + pub fn accessible_expanded(&self) -> Option { + if self.element_index != 0 { + return None; + } + self.item + .upgrade() + .and_then(|item| item.accessible_string_property(AccessibleStringProperty::Expanded)) + .and_then(|item| item.parse().ok()) + } + + /// Returns the value of the `accessible-expandable` property, if present + pub fn accessible_expandable(&self) -> Option { + if self.element_index != 0 { + return None; + } + self.item + .upgrade() + .and_then(|item| item.accessible_string_property(AccessibleStringProperty::Expandable)) + .and_then(|item| item.parse().ok()) + } + /// Returns the size of the element in logical pixels. This corresponds to the value of the `width` and /// `height` properties in Slint code. Returns a zero size if the element is not valid. pub fn size(&self) -> i_slint_core::api::LogicalSize { @@ -715,6 +737,17 @@ impl ElementHandle { } } + /// Invokes the element's `accessible-action-expand` callback, if declared. On widgets such as combo boxes, this + /// typically discloses the list of available choices. + pub fn invoke_accessible_expand_action(&self) { + if self.element_index != 0 { + return; + } + if let Some(item) = self.item.upgrade() { + item.accessible_action(&AccessibilityAction::Expand) + } + } + /// Simulates a single click (or touch tap) on the element at its center point with the /// specified button. pub async fn single_click(&self, button: i_slint_core::platform::PointerEventButton) { diff --git a/internal/backends/testing/slint_systest.proto b/internal/backends/testing/slint_systest.proto index 0145fb1e82f..54726b63c02 100644 --- a/internal/backends/testing/slint_systest.proto +++ b/internal/backends/testing/slint_systest.proto @@ -16,6 +16,7 @@ enum ElementAccessibilityAction { Default_ = 0; Increment = 1; Decrement = 2; + Expand = 3; } enum PointerEventButton { diff --git a/internal/backends/testing/systest.rs b/internal/backends/testing/systest.rs index fcedb91389e..b1bffef7e0d 100644 --- a/internal/backends/testing/systest.rs +++ b/internal/backends/testing/systest.rs @@ -380,6 +380,7 @@ impl TestingClient { proto::ElementAccessibilityAction::Decrement => { element.invoke_accessible_decrement_action() } + proto::ElementAccessibilityAction::Expand => element.invoke_accessible_expand_action(), } Ok(()) } diff --git a/internal/backends/winit/accesskit.rs b/internal/backends/winit/accesskit.rs index 724302383e7..11c4df56820 100644 --- a/internal/backends/winit/accesskit.rs +++ b/internal/backends/winit/accesskit.rs @@ -149,6 +149,7 @@ impl AccessKitAdapter { } _ => return None, }, + Action::Expand => AccessibilityAction::Expand, _ => return None, }; self.nodes @@ -520,6 +521,16 @@ impl NodeCollection { node.set_description(description.to_string()); } + if item + .accessible_string_property(AccessibleStringProperty::Expandable) + .is_some_and(|x| x == "true") + { + node.set_expanded( + item.accessible_string_property(AccessibleStringProperty::Expanded) + .is_some_and(|x| x == "true"), + ); + } + if matches!( role, Role::Button @@ -614,6 +625,9 @@ impl NodeCollection { if supported.contains(SupportedAccessibilityAction::ReplaceSelectedText) { node.add_action(accesskit::Action::ReplaceSelectedText); } + if supported.contains(SupportedAccessibilityAction::Expand) { + node.add_action(accesskit::Action::Expand); + } node } diff --git a/internal/compiler/typeregister.rs b/internal/compiler/typeregister.rs index cb23ada63a0..c169080af95 100644 --- a/internal/compiler/typeregister.rs +++ b/internal/compiler/typeregister.rs @@ -197,6 +197,8 @@ pub fn reserved_accessibility_properties() -> impl Iterator impl Iterator root.current-value; + accessible-action-expand => { base.show-popup(); } states [ disabled when !root.enabled : { @@ -63,6 +67,7 @@ export component ComboBox { font-weight: CosmicFontSettings.body.font-weight; color: CosmicPalette.control-foreground; text: root.current-value; + accessible-role: none; } Image { diff --git a/internal/compiler/widgets/cupertino/combobox.slint b/internal/compiler/widgets/cupertino/combobox.slint index 6057e6df33c..8d4f737ea86 100644 --- a/internal/compiler/widgets/cupertino/combobox.slint +++ b/internal/compiler/widgets/cupertino/combobox.slint @@ -26,6 +26,10 @@ export component ComboBox { forward-focus: base; accessible-role: combobox; accessible-enabled: root.enabled; + accessible-expandable: true; + accessible-expanded: base.popup-has-focus; + accessible-value <=> root.current-value; + accessible-action-expand => { base.show-popup(); } states [ disabled when !root.enabled : { @@ -95,6 +99,7 @@ export component ComboBox { font-weight: CupertinoFontSettings.body.font-weight; color: CupertinoPalette.foreground; text: root.current-value; + accessible-role: none; } VerticalLayout { diff --git a/internal/compiler/widgets/fluent/combobox.slint b/internal/compiler/widgets/fluent/combobox.slint index 06bd45e58f4..8778cd9e925 100644 --- a/internal/compiler/widgets/fluent/combobox.slint +++ b/internal/compiler/widgets/fluent/combobox.slint @@ -26,6 +26,10 @@ export component ComboBox { accessible-role: combobox; accessible-enabled: root.enabled; + accessible-expandable: true; + accessible-expanded: base.popup-has-focus; + accessible-value <=> root.current-value; + accessible-action-expand => { base.show-popup(); } states [ disabled when !root.enabled : { @@ -77,6 +81,7 @@ export component ComboBox { font-weight: FluentFontSettings.body.font-weight; color: FluentPalette.control-foreground; text: root.current-value; + accessible-role: none; } icon := Image { diff --git a/internal/compiler/widgets/material/combobox.slint b/internal/compiler/widgets/material/combobox.slint index aaae8c8a398..7ef7dc6fc99 100644 --- a/internal/compiler/widgets/material/combobox.slint +++ b/internal/compiler/widgets/material/combobox.slint @@ -25,6 +25,10 @@ export component ComboBox { forward-focus: base; accessible-role: combobox; accessible-enabled: root.enabled; + accessible-expandable: true; + accessible-expanded: base.popup-has-focus; + accessible-value <=> root.current-value; + accessible-action-expand => { base.show-popup(); } states [ disabled when !root.enabled : { @@ -74,6 +78,7 @@ export component ComboBox { // font-family: MaterialFontSettings.body-large.font; font-size: MaterialFontSettings.body-large.font-size; font-weight: MaterialFontSettings.body-large.font-weight; + accessible-role: none; } icon := Image { diff --git a/internal/compiler/widgets/qt/combobox.slint b/internal/compiler/widgets/qt/combobox.slint index ce324fda273..d6235933644 100644 --- a/internal/compiler/widgets/qt/combobox.slint +++ b/internal/compiler/widgets/qt/combobox.slint @@ -15,7 +15,12 @@ export component ComboBox { accessible-role: combobox; accessible-enabled: root.enabled; + accessible-expandable: true; + accessible-expanded: base.popup-has-focus; accessible-value <=> root.current-value; + accessible-action-expand => { + base.show-popup(); + } forward-focus: base; HorizontalLayout { diff --git a/internal/core/accessibility.rs b/internal/core/accessibility.rs index a9c147b8349..6e6e439f2e0 100644 --- a/internal/core/accessibility.rs +++ b/internal/core/accessibility.rs @@ -17,6 +17,8 @@ pub enum AccessibleStringProperty { DelegateFocus, Description, Enabled, + Expandable, + Expanded, ItemCount, ItemIndex, ItemSelectable, @@ -36,6 +38,7 @@ pub enum AccessibilityAction { Default, Decrement, Increment, + Expand, /// This is currently unused ReplaceSelectedText(SharedString), SetValue(SharedString), @@ -49,8 +52,9 @@ bitflags! { const Default = 1; const Decrement = 1 << 1; const Increment = 1 << 2; - const ReplaceSelectedText = 1 << 3; - const SetValue = 1 << 4; + const Expand = 1 << 3; + const ReplaceSelectedText = 1 << 4; + const SetValue = 1 << 5; } } diff --git a/internal/interpreter/dynamic_item_tree.rs b/internal/interpreter/dynamic_item_tree.rs index bb161e8f5c1..649b06f34b6 100644 --- a/internal/interpreter/dynamic_item_tree.rs +++ b/internal/interpreter/dynamic_item_tree.rs @@ -2113,6 +2113,7 @@ extern "C" fn accessibility_action( AccessibilityAction::Default => perform("accessible-action-default", &[]), AccessibilityAction::Decrement => perform("accessible-action-decrement", &[]), AccessibilityAction::Increment => perform("accessible-action-increment", &[]), + AccessibilityAction::Expand => perform("accessible-action-expand", &[]), AccessibilityAction::ReplaceSelectedText(_a) => { //perform("accessible-action-replace-selected-text", &[Value::String(a.clone())]) i_slint_core::debug_log!("AccessibilityAction::ReplaceSelectedText not implemented in interpreter's accessibility_action"); diff --git a/tests/cases/widgets/combobox.slint b/tests/cases/widgets/combobox.slint index 86b49caef2e..ca1450a4753 100644 --- a/tests/cases/widgets/combobox.slint +++ b/tests/cases/widgets/combobox.slint @@ -43,14 +43,22 @@ assert_eq!(instance.get_current_value(), "Aaa"); assert_eq!(instance.get_current_index(), 0); assert_eq!(instance.get_has_focus(), false); +let mut combobox_search = slint_testing::ElementHandle::find_by_element_id(&instance, "TestCase::box"); +let combobox = combobox_search.next().unwrap(); +assert_eq!(combobox.accessible_expandable(), Some(true)); +assert_eq!(combobox.accessible_expanded(), Some(false)); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Aaa"))); + // Change the index programmatically instance.set_current_index(1); assert_eq!(instance.get_current_value(), "Bbb"); assert_eq!(instance.get_current_index(), 1); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Bbb"))); assert_eq!(instance.get_output(), ""); instance.set_current_index(0); assert_eq!(instance.get_current_value(), "Aaa"); assert_eq!(instance.get_current_index(), 0); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Aaa"))); assert_eq!(instance.get_output(), ""); assert_eq!(instance.get_has_focus(), false); @@ -58,13 +66,16 @@ assert_eq!(instance.get_has_focus(), false); slint_testing::send_mouse_click(&instance, 100., 100.); assert_eq!(instance.get_output(), ""); assert_eq!(instance.get_has_focus(), true); +assert_eq!(combobox.accessible_expanded(), Some(true)); // click outside of the combobox, this should close it slint_testing::send_mouse_click(&instance, 100., 10.); assert_eq!(instance.get_output(), ""); assert_eq!(instance.get_current_value(), "Aaa"); assert_eq!(instance.get_current_index(), 0); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Aaa"))); assert_eq!(instance.get_has_focus(), true); +assert_eq!(combobox.accessible_expanded(), Some(false)); // click outside of the combobox again slint_testing::send_mouse_click(&instance, 100., 10.); @@ -72,34 +83,42 @@ assert_eq!(instance.get_output(), "clicked-under\n"); instance.set_output(Default::default()); assert_eq!(instance.get_current_value(), "Aaa"); assert_eq!(instance.get_current_index(), 0); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Aaa"))); assert_eq!(instance.get_has_focus(), true); +assert_eq!(combobox.accessible_expanded(), Some(false)); // The arrow change the values slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::DownArrow)); assert_eq!(instance.get_current_value(), "Bbb"); assert_eq!(instance.get_current_index(), 1); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Bbb"))); assert_eq!(instance.get_output(), "selected(Bbb,1)\n"); slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::DownArrow)); assert_eq!(instance.get_current_value(), "Ccc"); assert_eq!(instance.get_current_index(), 2); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Ccc"))); assert_eq!(instance.get_output(), "selected(Bbb,1)\nselected(Ccc,2)\n"); slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::UpArrow)); assert_eq!(instance.get_current_value(), "Bbb"); assert_eq!(instance.get_current_index(), 1); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Bbb"))); assert_eq!(instance.get_output(), "selected(Bbb,1)\nselected(Ccc,2)\nselected(Bbb,1)\n"); instance.set_output(Default::default()); // show the popup slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::Return)); assert_eq!(instance.get_output(), ""); +assert_eq!(combobox.accessible_expanded(), Some(true)); // click outside causes the popup to close slint_testing::send_mouse_click(&instance, 100., 10.); assert_eq!(instance.get_output(), ""); assert_eq!(instance.get_has_focus(), true); +assert_eq!(combobox.accessible_expanded(), Some(false)); slint_testing::send_mouse_click(&instance, 100., 10.); assert_eq!(instance.get_output(), "clicked-under\n"); assert_eq!(instance.get_has_focus(), true); +assert_eq!(combobox.accessible_expanded(), Some(false)); instance.set_output(Default::default()); instance.set_current_index(0); @@ -111,37 +130,56 @@ slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key: slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::DownArrow)); assert_eq!(instance.get_current_value(), "Bbb"); assert_eq!(instance.get_current_index(), 1); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Bbb"))); assert_eq!(instance.get_output(), "selected(Bbb,1)\n"); slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::DownArrow)); assert_eq!(instance.get_current_value(), "Ccc"); assert_eq!(instance.get_current_index(), 2); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Ccc"))); assert_eq!(instance.get_output(), "selected(Bbb,1)\nselected(Ccc,2)\n"); slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::UpArrow)); assert_eq!(instance.get_current_value(), "Bbb"); assert_eq!(instance.get_current_index(), 1); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Bbb"))); assert_eq!(instance.get_output(), "selected(Bbb,1)\nselected(Ccc,2)\nselected(Bbb,1)\n"); // close the popup slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::Escape)); assert_eq!(instance.get_current_value(), "Bbb"); assert_eq!(instance.get_current_index(), 1); +assert_eq!(combobox.accessible_value(), Some(SharedString::from("Bbb"))); assert_eq!(instance.get_output(), "selected(Bbb,1)\nselected(Ccc,2)\nselected(Bbb,1)\n"); instance.set_output(Default::default()); slint_testing::send_mouse_click(&instance, 100., 10.); assert_eq!(instance.get_output(), "clicked-under\n"); +assert_eq!(combobox.accessible_expanded(), Some(false)); assert_eq!(instance.get_has_focus(), true); +instance.set_output(Default::default()); + + +// The accessible expand action should open the popup +combobox.invoke_accessible_expand_action(); +assert_eq!(instance.get_output(), ""); +assert_eq!(instance.get_has_focus(), true); +assert_eq!(combobox.accessible_expanded(), Some(true)); +// close the popup +slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(Key::Escape)); +assert_eq!(instance.get_output(), ""); +assert_eq!(instance.get_has_focus(), true); +assert_eq!(combobox.accessible_expanded(), Some(false)); // Set current-index to -1 instance.set_current_index(-1); mock_elapsed_time(500); assert_eq!(instance.get_current_value(), &SharedString::from("")); +assert_eq!(combobox.accessible_value(), Some(SharedString::from(""))); // Replace model instance.set_model(Rc::new(VecModel::from_slice(&[SharedString::from("A"), SharedString::from("B")])).into()); mock_elapsed_time(500); assert_eq!(instance.get_current_index(), 0); assert_eq!(instance.get_current_value(), &SharedString::from("A")); - +assert_eq!(combobox.accessible_value(), Some(SharedString::from("A"))); ``` */