|
| 1 | +"""Keyboard focus order: expected Tab sequence, a WCAG audit, and set-focus. |
| 2 | +
|
| 3 | +Nothing in the toolkit reasons about *keyboard* navigation. ``focus_order`` adds: |
| 4 | +
|
| 5 | +* :func:`is_interactive_role` — is a role one that normally takes keyboard focus, |
| 6 | +* :func:`tab_order` — the focusable elements in the order ``Tab`` will visit them |
| 7 | + (their reading order: top-to-bottom, left-to-right), |
| 8 | +* :func:`audit_focus_order` — a WCAG 2.4.x focus-order report over a flat element |
| 9 | + list (the sequence plus flagged problems, e.g. a focusable element with no |
| 10 | + visible area), |
| 11 | +* :func:`focus_control` — set the keyboard focus on a control (device action). |
| 12 | +
|
| 13 | +The first three are pure functions over :class:`AccessibilityElement` lists — |
| 14 | +``tab_order`` reuses :func:`element_parse.reading_order` for row banding and |
| 15 | +``is_interactive_role`` reuses :func:`ax_tree_walk.humanize_role`, so no logic is |
| 16 | +duplicated. ``focus_control`` is a thin dispatch onto the injectable |
| 17 | +``accessibility.backends.get_backend()`` seam; the real ``SetFocus`` call lives in |
| 18 | +the Windows backend. Imports no ``PySide6``. |
| 19 | +""" |
| 20 | +from typing import Any, Dict, List, Optional, Sequence, Union |
| 21 | + |
| 22 | +from je_auto_control.utils.accessibility.element import AccessibilityElement |
| 23 | +from je_auto_control.utils.ax_tree_walk import humanize_role |
| 24 | +from je_auto_control.utils.element_parse import reading_order |
| 25 | + |
| 26 | +# Roles that conventionally participate in keyboard tab navigation. |
| 27 | +_INTERACTIVE_ROLES = frozenset({ |
| 28 | + "Button", "Calendar", "CheckBox", "ComboBox", "Edit", "Hyperlink", |
| 29 | + "ListItem", "MenuItem", "RadioButton", "ScrollBar", "Slider", "Spinner", |
| 30 | + "SplitButton", "Tab", "TabItem", "TreeItem", "DataItem", "Thumb", |
| 31 | +}) |
| 32 | + |
| 33 | + |
| 34 | +def is_interactive_role(role: Union[str, int]) -> bool: |
| 35 | + """Return True if ``role`` is one that normally accepts keyboard focus.""" |
| 36 | + return humanize_role(role) in _INTERACTIVE_ROLES |
| 37 | + |
| 38 | + |
| 39 | +def _box(element: AccessibilityElement, index: int) -> Dict[str, Any]: |
| 40 | + left, top, width, height = element.bounds |
| 41 | + return {"x": left, "y": top, "width": width, "height": height, "_idx": index} |
| 42 | + |
| 43 | + |
| 44 | +def tab_order(elements: Sequence[AccessibilityElement], *, |
| 45 | + row_tol: int = 12) -> List[AccessibilityElement]: |
| 46 | + """Return the focusable elements in the order ``Tab`` would visit them. |
| 47 | +
|
| 48 | + Filters to :func:`is_interactive_role` then orders by reading order (rows |
| 49 | + within ``row_tol`` px share a row, ordered left-to-right). |
| 50 | + """ |
| 51 | + interactive = [el for el in elements if is_interactive_role(el.role)] |
| 52 | + boxes = [_box(el, index) for index, el in enumerate(interactive)] |
| 53 | + ordered = reading_order(boxes, row_tol=int(row_tol)) |
| 54 | + return [interactive[box["_idx"]] for box in ordered] |
| 55 | + |
| 56 | + |
| 57 | +def audit_focus_order(elements: Sequence[AccessibilityElement], *, |
| 58 | + row_tol: int = 12) -> Dict[str, Any]: |
| 59 | + """Return a WCAG 2.4.x focus-order report over a flat element list. |
| 60 | +
|
| 61 | + ``order`` is the expected Tab sequence (``tab_index`` / ``name`` / ``role`` / |
| 62 | + ``bounds``); ``issues`` flags focusable elements with no visible area |
| 63 | + (WCAG 2.4.7 Focus Visible — focus would land somewhere unseen). |
| 64 | + """ |
| 65 | + order = tab_order(elements, row_tol=row_tol) |
| 66 | + sequence: List[Dict[str, Any]] = [] |
| 67 | + issues: List[Dict[str, Any]] = [] |
| 68 | + for tab_index, element in enumerate(order): |
| 69 | + role = humanize_role(element.role) |
| 70 | + _left, _top, width, height = element.bounds |
| 71 | + sequence.append({"tab_index": tab_index, "name": element.name, |
| 72 | + "role": role, "bounds": list(element.bounds)}) |
| 73 | + if width <= 0 or height <= 0: |
| 74 | + issues.append({"tab_index": tab_index, "name": element.name, |
| 75 | + "role": role, "issue": "zero_area_focusable", |
| 76 | + "wcag": "2.4.7 Focus Visible"}) |
| 77 | + return {"order": sequence, "issues": issues, |
| 78 | + "focusable_count": len(order), "issue_count": len(issues)} |
| 79 | + |
| 80 | + |
| 81 | +def focus_control(name: Optional[str] = None, role: Optional[str] = None, |
| 82 | + app_name: Optional[str] = None, |
| 83 | + automation_id: Optional[str] = None) -> bool: |
| 84 | + """Set keyboard focus on the matched control (UIA SetFocus); True on success.""" |
| 85 | + from je_auto_control.utils.accessibility.backends import get_backend |
| 86 | + return get_backend().set_focus(name=name, role=role, app_name=app_name, |
| 87 | + automation_id=automation_id) |
0 commit comments